1. 准备工作 1.1 安装Axios 虽然现在前后端 Django + Vue 都有了,但还缺一个它们之间通信的手段。Vue 官方推荐的是 axios 这个前端库。
命令行进入 frontend
目录,安装 axios
1.2 解决跨域 跨域问题是由于浏览器的同源策略(域名,协议,端口均相同)造成的,是浏览器施加的安全限制。即Vue 服务器端口(8080)和 Django 服务器端口(8000)不一致,因此无法通过 Javascript 代码请求后端资源。
解决跨域的方法有两种:
方法一(前端解决):前端配置fronted/vite.config.js
文件并写入:
import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins : [vue()], resolve : { alias : { '@' : fileURLToPath(new URL('./src' , import .meta.url)) } }, server : { port : '5173' , proxy : { '/api' : { target : 'http://127.0.0.1:8000/api' , changeOrigin : true , rewrite : (path ) => path.replace(/^\/api/ , '' ) } } } })
方法二(后端解决):后端引入django-cors-middleware
库
两种解决方法都可以,这里选择前端代理的方法。
2. Vue结构 为了理解 Vue 的基本结构,让我们来看三个重要的文件。
2.1 index.html <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <link rel ="icon" href ="/favicon.ico" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Vite App</title > </head > <body > <div id ="app" > </div > <script type ="module" src ="/src/main.js" > </script > </body > </html >
这个页面是整个前端工程提供 html 的入口,里面的 <div id="app">
是 Vue 初始化应用程序的根容器。
不过在前端工程化的思想中,很少会直接去写这类 html
文件。
2.2 main.js import { createApp } from 'vue' import App from './App.vue' import './assets/main.css' createApp(App).mount('#app' )
作用是把后续你要写的 Vue 组件挂载到刚才那个 index.html
中。
如果你有些前端的初始化配置,都可以写到这里。
2.3 App.vue <!-- frontend/src/App.vue --> <script setup> import HelloWorld from './components/HelloWorld.vue' import TheWelcome from './components/TheWelcome.vue' </script> <template> <header> <img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" /> <div class="wrapper"> <HelloWorld msg="You did it!" /> </div> </header> <main> <TheWelcome /> </main> </template> <style scoped> header { line-height: 1.5; } .logo { display: block; margin: 0 auto 2rem; } @media (min-width: 1024px) { header { display: flex; place-items: center; padding-right: calc(var(--section-gap) / 2); } .logo { margin: 0 2rem 0 0; } header .wrapper { display: flex; place-items: flex-start; flex-wrap: wrap; } } </style>
这个文件对应 Vue 的欢迎页面,
Vue 采用组件化的思想,把同一个组件的内容打包到一起。比如这个默认的 App.vue
文件, <template>
标签就对应传统的 html
,<script>
标签对应 javascript
,<style>
标签对应了 css
。
<HelloWorld .../>
和<TheWelcome />
是封装好的组件,路径位于 frontend/src/components/
。
以上就是 Vue 项目三个重要的文件,而对入门者来说,最重要的就是各种 .vue
文件,这就是你最主要的写代码的地方。
3. 文章列表 3.1 初次尝试 Vue 把同一个组件的 template
/ script
/ style
打包到一起组成.vue
文件。在App.vue
里编写文章列表页面代码:
<!-- frontend/src/App.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div class="article-title"> {{ article.title }} </div> </div> </template> <script> import axios from 'axios' export default { name: 'App', data() { return { info: '' } }, mounted() { axios.get('/api/article').then(response => { this.info = response.data }) } } </script> <style> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } </style>
删除main.js
中引入的样式,即删除import './assets/main.css'
这一行内容。
访问地址:http://localhost:5173/ 即可看到后端文章列表数据被渲染出来了。
3.2 优化界面 <!-- frontend/src/App.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <div class="article-title"> {{ article.title }} </div> <div>{{ formatted_time(article.created) }}</div> </div> </template> <script> import axios from 'axios' export default { name: 'App', data() { return { info: '' } }, mounted() { axios.get('/api/article').then(response => { this.info = response.data }) }, methods: { formatted_time(iso_date_string) { const date = new Date(iso_date_string) return date.toLocaleDateString() } } } </script> <style> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } </style>
现在博客页面大概是这样子的:
3.3 添加页眉页脚 <!-- frontend/src/App.vue --> <template> <div id="header"> <h1>My Django REST framework-Vue Blog</h1> <hr> </div> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <div class="article-title"> {{ article.title }} </div> <div>{{ formatted_time(article.created) }}</div> </div> <div id="footer"> <p>http://localhost:5173/</p> </div> </template> <script> import axios from 'axios' export default { name: 'App', data() { return { info: '' } }, mounted() { axios.get('/api/article').then(response => { this.info = response.data }) }, methods: { formatted_time(iso_date_string) { const date = new Date(iso_date_string) return date.toLocaleDateString() } } } </script> <style> #app { font-family: Georgia, Arial, sans-serif; margin-left: 40px; margin-right: 40px; } #header { text-align: center; margin-top: 20px; } #footer { position: fixed; left: 0; bottom: 0; height: 50px; width: 100%; background: whitesmoke; text-align: center; font-weight: bold; } #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } </style>
现在博客页面大概是这样子的:
3.4 组件化 组件化是 Vue 的核心思想之一。组件可以把网页分解成一个个的小功能,达到代码解耦及复用。
在 frontend/src/components/
路径下分别创建 ArticleList.vue
/ BlogHeader.vue
/ BlogFooter.vue
三个文件,并且把我们之前在 App.vue
中写的代码分别搬运到对应的位置。
三个文件的内容如下(注意 export
中的 name
有对应的更改):
<!-- frontend/src/components/ArticleList.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <div class="article-title"> {{ article.title }} </div> <div>{{ formatted_time(article.created) }}</div> </div> </template> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '' } }, mounted() { axios.get('/api/article').then(response => { this.info = response.data }) }, methods: { formatted_time(iso_date_string) { const date = new Date(iso_date_string) return date.toLocaleDateString() } } } </script> <style scoped> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } </style>
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <h1>My Django REST framework-Vue Blog</h1> <hr> </div> </template> <script> export default { name: 'BlogHeader' } </script> <style scoped> #header { text-align: center; margin-top: 20px; } </style>
<!-- frontend/src/components/BlogFooter.vue --> <template> <div id="footer"> <p>http://localhost:5173/</p> </div> </template> <script> export default { name: 'BlogFooter' } </script> <style scoped> #footer { position: fixed; left: 0; bottom: 0; height: 50px; width: 100%; background: whitesmoke; text-align: center; font-weight: bold; } </style>
<!-- frontend/src/App.vue --> <template> <blog-header /> <article-list /> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import ArticleList from '@/components/ArticleList.vue' export default { name: 'App', components: { BlogHeader, BlogFooter, ArticleList } } </script> <style> #app { font-family: Georgia, Arial, sans-serif; margin-left: 40px; margin-right: 40px; } </style>
刷新页面,功能虽然与修改前完全相同,但代码变得更加规整。
4. 文章详情 4.1 优化文件结构 由于后续页面会越来越多,为了避免 App.vue
越发臃肿,因此必须优化文件结构。
新建 frontend/src/views/
目录,用来存放现在及将来所有的页面文件。在此目录新建 Home.vue
文件,把之前的首页代码稍加修改搬运过来:
<!-- frontend/src/views/Home.vue --> <template> <blog-header /> <article-list /> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import ArticleList from '@/components/ArticleList.vue' export default { name: 'Home', components: { BlogHeader, BlogFooter, ArticleList } } </script>
新增文章详情页面:
<!-- frontend/src/views/ArticleDetail.vue --> <template> <blog-header /> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' export default { name: 'ArticleDetail', components: { BlogHeader, BlogFooter } } </script>
修改 App.vue
:
<!-- frontend/src/App.vue --> <template> <router-view /> </template> <script> export default { name: 'App', } </script> <style> #app { font-family: Georgia, Arial, sans-serif; margin-left: 40px; margin-right: 40px; } </style>
从列表到详情,需要解决页面跳转,采用前端路由 的方式来实现页面跳转
4.2 配置路由 执行npm install vue-router
安装 Vue 的官方前端路由库vue-router
因为 vue-router 会用到文章的 id 作为动态地址,所以修改 Django 后端 文件article/serializers.py
的ArticleBaseSerializer
,添加一行 id = serializers.IntegerField(read_only=True)
,简单的把文章的 id 值增加到接口数据中。
新建 frontend/src/router/index.js
文件用于存放路由相关的文件,写入:
import { createRouter, createWebHistory } from 'vue-router' const Home = () => import ('@/views/Home.vue' )const ArticleDetail = () => import ('@/views/ArticleDetail.vue' )const routes = [ { path : '/' , name : 'home' , component : Home }, { path : '/article/:id' , name : 'ArticleDetail' , component : ArticleDetail }, ] const router = createRouter({ history : createWebHistory(), routes, }) export default router
列表 routes
定义了所有需要挂载到路由中的路径,成员为路径 url 、路径名 和路径的 vue 对象 。详情页面的动态路由采用冒号 :id
的形式来定义。 接着就用 createRouter()
创建 router。参数里的 history
定义具体的路由形式,createWebHashHistory()
为哈希模式(具体路径在 # 符号后面);createWebHistory()
为 HTML5 模式(路径中没有丑陋的 # 符号),此为推荐模式 ,但是部署时需要额外的配置 。 各模式的详细介绍看 文档 。
将 vue-router 加载 Vue 实例中:
import { createApp } from 'vue' import App from './App.vue' import router from './router' let app = createApp(App)app.use(router) app.mount('#app' )
此时,浏览器访问 http://localhost:5173/ 得到 Home 页面
4.3 文章详情页面 修改文章列表的组件代码
<!-- frontend/src/components/ArticleList.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <router-link :to="{ name: 'ArticleDetail', params: { id: article.id } }" class="article-title"> {{ article.title }} </router-link> <div>{{ formatted_time(article.created) }}</div> </div> </template> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '' } }, mounted() { axios.get('/api/article').then(response => { this.info = response.data }) }, methods: { formatted_time(iso_date_string) { const date = new Date(iso_date_string) return date.toLocaleDateString() } } } </script> <style scoped> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } </style>
调用 vue-router 不再需要常规的 <a>
标签,而是 <router-link>
,:to
属性指定了跳转位置,注意看动态参数 id 是如何传递的。
在 Vue 中,属性前面的冒号 :
表示此属性被”绑定“了。”绑定“的对象可以是某个动态的参数(比如这里的 id 值),也可以是 Vue 所管理的 data,也可以是 methods。冒号 :
实际上是 v-bind:
的缩写。
Router 骨架就搭建完毕了。此时点击首页的文章标题链接后,应该就顺利跳转到一个只有页眉页脚的详情页面了。
接下来编写详情页面:
<!-- frontend/src/views/ArticleDetail.vue --> <template> <blog-header /> <div v-if="article !== null" class="grid-container"> <div> <h1 id="title">{{ article.title }}</h1> <p id="subtitle">本文由 {{ article.author.username }} 发布于 {{ formatted_time(article.created) }}</p> <div v-html="article.body_html" class="article-body"></div> </div> <div> <h3>目录</h3> <div v-html="article.toc_html" class="toc"></div> </div> </div> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import axios from 'axios' export default { name: 'ArticleDetail', components: { BlogHeader, BlogFooter }, data() { return { article: null } }, mounted() { axios.get('/api/article/' + this.$route.params.id).then(response => { this.article = response.data }) }, methods: { formatted_time(iso_date_string) { const date = new Date(iso_date_string) return date.toLocaleDateString() } } } </script> <style scoped> .grid-container { display: grid; grid-template-columns: 3fr 1fr; } #title { text-align: center; font-size: x-large; } #subtitle { text-align: center; color: gray; font-size: small; } </style> <style> .article-body p img { max-width: 100%; border-radius: 50px; box-shadow: gray 0 0 20px; } .toc ul { list-style-type: none; } .toc a { color: gray; } </style>
模板 - template 部分:
在渲染文章前,逻辑控制语句 v-if
先确认数据是否存在,避免出现潜在的 调用数据不存在的 bug。 由于 body_html
、toc_html
都是后端渲染好的 markdown 文本,需要将其直接转换为 HTML ,所以需要用 v-html
标注。 脚本 - script 部分:
通过 $route.params.id
可以获得路由中的动态参数,以此拼接为接口向后端请求数据。 样式 - style 部分:
.grid-container
简单的给文章内容、目录划分了网格区域。<style>
标签可以有多个,满足“分块强迫症患者”的需求。这里分两个的原因是文章内容、目录都是从原始 HTML 渲染的,不在 scoped
的管理范围内。5. 翻页与监听 5.1 路由与查询参数 详情页面跳转,用到了 vue-router 动态匹配路由的能力。而翻页功能通常不会直接改变当前路由,而是修改 url 中的查询参数来实现。区别如下:
# 改变路由 https://abc.com/2 # 改变查询参数 http://abc.com/?page=2
5.2 实现翻页 翻页在 ArticleList.vue
中完成
<!-- frontend/src/components/ArticleList.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <router-link :to="{ name: 'ArticleDetail', params: { id: article.id } }" class="article-title"> {{ article.title }} </router-link> <div>{{ formatted_time(article.created) }}</div> </div> <div id="paginator"> <span v-if="is_page_exists('previous')"> <router-link :to="{ name: 'Home', query: { page: get_page_param('previous') } }"> Prev </router-link> </span> <span class="current-page"> {{ get_page_param('current') }} </span> <span v-if="is_page_exists('next')"> <router-link :to="{ name: 'Home', query: { page: get_page_param('next') } }"> Next </router-link> </span> </div> </template> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '', } }, mounted() { this.get_article_data() }, methods: { // 判断页面是否存在 is_page_exists(direction) { if (direction === 'next') { return this.info.next !== null } return this.info.previous !== null }, // 获取页码 get_page_param(direction) { try { let url_string switch (direction) { case 'next': url_string = this.info.next break case 'previous': url_string = this.info.previous break default: if (!('page' in this.$route.query)) { return 1 } if (this.$route.query.page === null) { return 1 } return this.$route.query.page } const url = new URL(url_string) return url.searchParams.get('page') } catch (err) { return } }, // 获取文章列表数据 get_article_data() { let url = '/api/article/' const page = Number(this.$route.query.page) if (!isNaN(page) && (page !== 0)) { url = url + '?page=' + page } axios.get(url).then(response => { this.info = response.data }).catch(error => { console.log(error) }) }, }, watch: { // 监听路由变化 $route() { this.get_article_data() } }, computed: { // 格式化时间 formatted_time() { return timeString => new Date(timeString).toLocaleDateString() } } } </script> <style scoped> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } #paginator { text-align: center; padding-top: 50px; } a { color: black; } .current-page { font-size: x-large; font-weight: bold; padding-left: 10px; padding-right: 10px; } </style>
is_page_exists(...)
用于确认需要跳转的页面是否存在,如果不存在那就不渲染对应的跳转标签。它的唯一参数用于确定页面的方向(当前页、上一页或下一页)。get_page_param(...)
用于获取页码。router-link
通过 query 传递参数try
是为了避免潜在的取值问题(比如网速缓慢时 info
还未获取到数据), catch
语句向控制台打印报错。switch
用来控制翻页方向,它默认查询了当前的页码,用于显示。watch
的作用是监听路由的变化,一旦发生变化则立即根据当前页码更新对应的文章数据。computed
用来格式化时间,与methods
不同之处在于,competed 有缓存,在值不变的情况下不会再次计算,而是直接使用缓存中的值。6. 搜索文章 6.1 实现输入框 将搜索框放在页眉,这样用户在博客的所有页面都能找到搜索框。修改BlogHeader.vue
:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <div class="search"> <form> <input v-model="searchText" type="text" placeholder="输入搜索内容..."> <button @click.prevent="searchArticle"></button> </form> </div> </div> <hr> </div> </template> <script> export default { name: 'BlogHeader', data() { return { searchText: '' } }, methods: { searchArticle() { const text = this.searchText.trim() if (text.charAt(0) !== '') { this.$router.push({ name: 'Home', query: { search: text } }) } } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .search { padding-top: 22px; } * { box-sizing: border-box; } form { position: relative; width: 200px; margin: 0 auto; } input, button { border: none; outline: none; } input { width: 100%; height: 35px; padding-left: 13px; padding-right: 46px; } button { height: 35px; width: 35px; cursor: pointer; position: absolute; } .search input { border: 2px solid gray; border-radius: 5px; background: transparent; top: 0; right: 0; } .search button { background: gray; border-radius: 0 5px 5px 0; width: 45px; top: 0; right: 0; } .search button:before { content: "搜索"; font-size: 13px; color: white; } </style>
v-model
指令可以在表单控件上创建双向数据绑定 。具体来说,就是上面的 <input>
中的数据和 Vue 管理的 searchText
数据绑定在一起了,其中一个发生变化,另一个也会改变。@click
绑定了按钮的鼠标点击事件 ,即点击则触发 searchArticles()
方法。.prevent
用于阻止按钮原本的表单提交功能。<router-link>
标签实现路由跳转。在必要时候也可以通过脚本来动态实现 路由跳转,即 this.$router.push(...)
。注意 this.$route
和 this.$router
,前者代表路径对象,后者代表路由器对象。6.2 实现搜索 在 ArticleList.vue
里进行修改(主要是 Javascript 部分)。
旧的翻页 <router-link>
仅考虑了路径参数中的 page
值。为了在翻页后取得包括 page
和 search
的正确路径,新写一个方法 get_path()
:
<!-- frontend/src/components/ArticleList.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <router-link :to="{ name: 'ArticleDetail', params: { id: article.id } }" class="article-title"> {{ article.title }} </router-link> <div>{{ formatted_time(article.created) }}</div> </div> <div id="paginator"> <span v-if="is_page_exists('previous')"> <router-link :to="get_path('previous')"> Prev </router-link> </span> <span class="current-page"> {{ get_page_param('current') }} </span> <span v-if="is_page_exists('next')"> <router-link :to="get_path('next')"> Next </router-link> </span> </div> </template> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '', } }, mounted() { this.get_article_data() }, methods: { // 判断页面是否存在 is_page_exists(direction) { if (direction === 'next') { return this.info.next !== null } return this.info.previous !== null }, // 获取页码 get_page_param(direction) { try { let url_string switch (direction) { case 'next': url_string = this.info.next break case 'previous': url_string = this.info.previous break default: if (!('page' in this.$route.query)) { return 1 } if (this.$route.query.page === null) { return 1 } return this.$route.query.page } const url = new URL(url_string) return url.searchParams.get('page') } catch (err) { return } }, // 获取文章列表数据 get_article_data() { let url = '/api/article/' let params = new URLSearchParams() if (this.isExists(this.$route.query.page)) { params.append('page', this.$route.query.page) } if (this.isExists(this.$route.query.search)) { params.append('search', this.$route.query.search) } const paramsString = params.toString() if (paramsString.charAt(0) !== '') { url += '/?' + paramsString } axios.get(url).then(response => { this.info = response.data }).catch(error => { console.log(error) }) }, // 获取路径 get_path(direction) { let url = '' try { switch (direction) { case 'next': if (this.info.next !== undefined) { url += (new URL(this.info.next)).search } break case 'previous': if (this.info.previous !== undefined) { url += (new URL(this.info.previous)).search } break } } catch { return url } return url }, // 检查参数是否存在 isExists(value) { return value !== null && value !== undefined } }, watch: { // 监听路由变化 $route() { this.get_article_data() } }, computed: { // 格式化时间 formatted_time() { return timeString => new Date(timeString).toLocaleDateString() } } } </script> <style scoped> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } #paginator { text-align: center; padding-top: 50px; } a { color: black; } .current-page { font-size: x-large; font-weight: bold; padding-left: 10px; padding-right: 10px; } </style>
用于处理路径参数的 URLSearchParams()
对象。为了将路径中已有的参数添加到 URLSearchParams()
中,编写isExists方法判断值是否存在 ,然后用 append()
方法添加到路径。
7. 用户注册 前面都是实现文章的 GET 请求,接下来实现更新、删除之类的请求,从用户管理入手。
7.1 注册页面 新建 frontend/src/views/Login.vue
文件作为用户注册(以及登录)的页面:
<!-- frontend/src/views/Login.vue --> <template> <blog-header /> <div id="grid"> <div id="signup"> <h3>注册账号</h3> <form> <div class="form-elem"> <span class="label">账号:</span> <input type="text" v-model="signupName" placeholder="输入用户名"> </div> <div class="form-elem"> <span class="label">密码:</span> <input type="password" v-model="signupPwd" placeholder="输入密码"> </div> <div class="form-elem"> <span class="label">确认密码:</span> <input type="password" v-model="signupPwdConfirm" placeholder="再次输入密码"> </div> <div class="form-elem"> <button @click.prevent="signup">提交</button> </div> </form> </div> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; export default { name: "Login", components: { BlogHeader, BlogFooter }, data() { return { signupName: '', signupPwd: '', signupPwdConfirm: '', signupResponse: null, } }, methods: { signup() { if (this.signupPwd !== this.signupPwdConfirm) { alert("两次密码不一致,请重新输入!") return } axios.post('/api/user/', { username: this.signupName, password: this.signupPwd, }).then((response) => { this.signupResponse = response.data alert('用户注册成功') }).catch((error) => { alert('用户注册失败:', error.message) }) } } } </script> <style scoped> #grid { display: grid; grid-template-columns: 1fr 1fr; } #signup { text-align: center; } .form-elem { padding: 10px; } .label { display: inline-block; width: 80px; text-align: justify; text-align-last: justify; margin-right: 1px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 60px; } </style>
上面代码的功能是将表单中的用户名和密码 post
到 /api/user/
接口,若创建成功则提醒用户前往登录,失败则将提示信息显示出来。
7.2 注册路由 在frontend/src/router/index.js
中添加注册路由
import { createRouter, createWebHistory } from 'vue-router' const Home = () => import ('@/views/Home.vue' )const ArticleDetail = () => import ('@/views/ArticleDetail.vue' )const Login = () => import ('@/views/Login.vue' )const routes = [ { path : '/' , name : 'Home' , component : Home }, { path : '/article/:id' , name : 'ArticleDetail' , component : ArticleDetail }, { path : '/login' , name : 'Login' , component : Login }, ] const router = createRouter({ history : createWebHistory(), routes, }) export default router
在frontend/src/components/BlogHeader.vue
添加入口:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <div class="search"> <form> <input v-model="searchText" type="text" placeholder="输入搜索内容..."> <button @click.prevent="searchArticle"></button> </form> </div> </div> <hr> <div class="login"> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </template> <script> export default { name: 'BlogHeader', data() { return { searchText: '' } }, methods: { searchArticle() { const text = this.searchText.trim() if (text.charAt(0) !== '') { this.$router.push({ name: 'Home', query: { search: text } }) } } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .search { padding-top: 22px; } * { box-sizing: border-box; } form { position: relative; width: 200px; margin: 0 auto; } input, button { border: none; outline: none; } input { width: 100%; height: 35px; padding-left: 13px; padding-right: 46px; } button { height: 35px; width: 35px; cursor: pointer; position: absolute; } .search input { border: 2px solid gray; border-radius: 5px; background: transparent; top: 0; right: 0; } .search button { background: gray; border-radius: 0 5px 5px 0; width: 45px; top: 0; right: 0; } .search botton:before { content: "搜索"; font-size: 13px; color: white; } .login { text-align: right; padding-right: 5px; } </style>
8. 用户登录 由于后端的认证方式为 JWT 认证,即后端返回给前端一个 token,前端在请求的 Header 中附带此 token 令牌来证明身份。token 保存在前端的什么地方?
本教程将采用 token 保存在 localStorage
中,实现登录功能。
此问题有广泛的讨论,因为 token 无论是保存在 localStorage、sessionStorage 或者 cookie 中均存在某些情况下被盗取的可能。网络安全不是本教程重点关注的问题,因此为了入门平滑将 token 保存于 localStorage 中,更深入的对安全的讨论请见 HASURA 、 MDN 以及 Stackoverflow 。有关 localStorage 的入门讲解 看这里 。
8.1 登录页面 在 Login.vue
添加登录的表单的代码
<!-- frontend/src/views/Login.vue --> <template> <blog-header /> <div id="grid"> <div id="signup"> <h3>注册账号</h3> <form> <div class="form-elem"> <span class="label">账号:</span> <input type="text" v-model="signupName" placeholder="输入用户名"> </div> <div class="form-elem"> <span class="label">密码:</span> <input type="password" v-model="signupPwd" placeholder="输入密码"> </div> <div class="form-elem"> <span class="label">确认密码:</span> <input type="password" v-model="signupPwdConfirm" placeholder="再次输入密码"> </div> <div class="form-elem"> <button @click.prevent="signup">提交</button> </div> </form> </div> <div id="signin"> <h3>登录账号</h3> <form> <div class="form-elem"> <span class="label">账号:</span> <input type="text" v-model="signinName" placeholder="输入用户名"> </div> <div class="form-elem"> <span class="label">密码:</span> <input type="password" v-model="signinPwd" placeholder="输入密码"> </div> <div class="form-elem"> <button @click.prevent="signin">登录</button> </div> </form> </div> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; export default { name: "Login", components: { BlogHeader, BlogFooter }, data() { return { signupName: '', signupPwd: '', signupPwdConfirm: '', signinName: '', signinPwd: '', signupResponse: null, } }, methods: { signup() { if (this.signupPwd !== this.signupPwdConfirm) { alert("两次密码不一致,请重新输入!") return } axios.post('/api/user/', { username: this.signupName, password: this.signupPwd, }).then((response) => { this.signupResponse = response.data alert('用户注册成功') }).catch((error) => { alert('用户注册失败:', error.message) }) }, signin() { axios.post('/api/token/', { username: this.signinName, password: this.signinPwd, }).then((response) => { const storage = localStorage const expiredTime = Date.parse(response.headers.date) + 10800000 storage.setItem('access.myblog', response.data.access) storage.setItem('refresh.myblog', response.data.refresh) storage.setItem('expiredTime.myblog', expiredTime) storage.setItem('username.myblog', this.signinName) this.$router.push({ name: 'Home' }) }).catch((error) => { alert('用户登录失败:', error.message) }) } } } </script> <style scoped> #grid { display: grid; grid-template-columns: 1fr 1fr; } #signup { text-align: center; } #signin { text-align: center; } .form-elem { padding: 10px; } .label { display: inline-block; width: 80px; text-align: justify; text-align-last: justify; margin-right: 1px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 60px; } </style>
8.2 显示登录状态 修改frontend/src/components/Blogheader.vue
:
<!-- frontend/src/components/Blogheader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <div class="search"> <form> <input v-model="searchText" type="text" placeholder="输入搜索内容..."> <button @click.prevent="searchArticle"></button> </form> </div> </div> <hr> <div class="login"> <div v-if="hasLogin">欢迎,{{ username }}</div> <div v-else> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </div> </template> <script> import axios from 'axios' export default { name: 'BlogHeader', data() { return { searchText: '', username: '', hasLogin: false, } }, methods: { searchArticle() { const text = this.searchText.trim() if (text.charAt(0) !== '') { this.$router.push({ name: 'Home', query: { search: text } }) } } }, mounted() { const storage = localStorage const expiredTime = Number(storage.getItem('expiredTime.myblog')) const current = (new Date()).getTime() const refreshToken = storage.getItem('refresh.myblog') this.username = storage.getItem('username.myblog') if (expiredTime > current) { this.hasLogin = true } else if (refreshToken !== null) { axios.post('/api/token/refresh/', { refresh: refreshToken }).then((response) => { const nextExpiredTime = Date.parse(response.headers.date) + 10800000 storage.setItem('access.myblog', response.data.access) storage.setItem('expiredTime.myblog', nextExpiredTime) this.hasLogin = true }).catch(() => { storage.clear() this.hasLogin = false alert('请重新登录') }) } else { storage.clear() this.hasLogin = false alert('请重新登录') } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .search { padding-top: 22px; } * { box-sizing: border-box; } form { position: relative; width: 200px; margin: 0 auto; } input, button { border: none; outline: none; } input { width: 100%; height: 35px; padding-left: 13px; padding-right: 46px; } button { height: 35px; width: 35px; cursor: pointer; position: absolute; } .search input { border: 2px solid gray; border-radius: 5px; background: transparent; top: 0; right: 0; } .search button { background: gray; border-radius: 0 5px 5px 0; width: 45px; top: 0; right: 0; } .search button:before { content: "搜索"; font-size: 13px; color: white; } .login { text-align: right; padding-right: 5px; } </style>
9 用户资料 9.1 搜索框组件化 把搜索框组件化,新建一个SearchButton.vue
文件,把BlogHeader.vue
中与搜索相关的内容全部搬运过来。
<!-- frontend/src/components/SearchButton.vue --> <template> <div class="search"> <form> <input type="text" v-model="searchText" placeholder="输入搜索内容..."> <button @click.prevent="searchArticle"></button> </form> </div> </template> <script> export default { name: 'SearchButton', data() { return { searchText: '', } }, methods: { searchArticle() { const text = this.searchText.trim() if (text.charAt(0) !== '') { this.$router.push({ name: 'Home', query: { search: text } }) } else { this.$router.push({ name: 'Home' }) } } } } </script> <style scoped> .search { padding-top: 22px; } * { box-sizing: border-box; } form { position: relative; width: 200px; margin: 0 auto; } input, button { border: none; outline: none; } input { width: 100%; height: 35px; padding-left: 13px; padding-right: 46px; } button { height: 35px; width: 35px; cursor: pointer; position: absolute; } .search input { border: 2px solid gray; border-radius: 5px; background: transparent; top: 0; right: 0; } .search button { background: gray; border-radius: 0 5px 5px 0; width: 45px; top: 0; right: 0; } .search button:before { content: "搜索"; font-size: 13px; color: white; } </style>
把BlogHeader.vue
对应搜索的部分删除:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <search-button /> </div> <hr> <div class="login"> <div v-if="hasLogin">欢迎,{{ username }}</div> <div v-else> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </div> </template> <script> import axios from 'axios' import SearchButton from '@/components/SearchButton.vue' export default { name: 'BlogHeader', data() { return { searchText: '', username: '', hasLogin: false, } }, components: { SearchButton }, methods: { }, mounted() { const storage = localStorage const expiredTime = Number(storage.getItem('expiredTime.myblog')) const current = (new Date()).getTime() const refreshToken = storage.getItem('refresh.myblog') this.username = storage.getItem('username.myblog') if (expiredTime > current) { this.hasLogin = true } else if (refreshToken !== null) { axios.post('/api/token/refresh/', { refresh: refreshToken }).then((response) => { const nextExpiredTime = Date.parse(response.headers.date) + 10800000 storage.setItem('access.myblog', response.data.access) storage.setItem('expiredTime.myblog', nextExpiredTime) this.hasLogin = true }).catch(() => { storage.clear() this.hasLogin = false alert('请重新登录') }) } else { storage.clear() this.hasLogin = false alert('请重新登录') } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .login { text-align: right; padding-right: 5px; } </style>
9.2 异步和重构 用户资料页面涉及 POST/PATCH 等操作,需要验证用户的身份和 token 有效性;前面写的 BlogHeader.vue
也有类似的需求。因此需要将验证代码 重构为一个单独的函数。 把验证代码 抽象为单独的函数后,由于 axios
发送的请求是异步的,所以要将此处的异步代码转换为同步代码,否则 localStorage 的存取顺序会因为网速的快慢而不可预测,带来潜在 bug。 新建路径和文件frontend/src/utils/authorization.js
:
import axios from 'axios' async function authorization ( ) { const storage = localStorage let hasLogin = false let username = storage.getItem('username.myblog' ) const expiredTime = Number (storage.getItem('expiredTime.myblog' )) const current = (new Date ()).getTime() const refreshToken = storage.getItem('refresh.myblog' ) if (expiredTime > current) { hasLogin = true console .log('authorization access' ) } else if (refreshToken !== null ) { try { let response = await axios.post(('/api/token/refresh/' , { refresh : refreshToken })) const nextExpiredTime = Date .parse(response.headers.date) + 10800000 storage.setItem('access.myblog' , response.data.access) storage.setItem('expiredTime.myblog' , nextExpiredTime) hasLogin = true console .log('authorization refresh' ) } catch (err) { storage.clear() hasLogin = false console .log('authorization err' ) } } else { storage.clear() hasLogin = false console .log('authorization exp' ) } console .log('authorization done' ) return [hasLogin, username] } export default authorization
async/await
: async
表示函数里含有异步操作,await
表示紧跟在后面的表达式需要等待结果。await
关键字只能用在 async
函数中,并且由于它返回的 Promise
对象运行的结果可能是 rejected
,所以最好放到 try...catch
语句中。async
函数返回的不再是 return
后面的数据,而是包含数据的 Promise
对象,因此调用它的位置需要改为 Promise.then().catch()
进行异常处理。(有点像 axios.then().catch()
)9.3 用户中心 新建 frontend/src/views/UserCenter.vue
:
<!-- frontend/src/views/UserCenter.vue --> <template> <blog-header /> <div id="user-center"> <h3>更新资料信息</h3> <form> <div class="form-elem"> <span>用户名:</span> <input type="text" v-model="username" placeholder="输入用户名"> </div> <div class="form-elem"> <span>新密码:</span> <input type="password" v-model="password" placeholder="输入密码"> </div> <div class="form-elem"> <button @click.prevent="changeInfo">更新</button> </div> </form> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; import authorization from '@/utils/authorization' const storage = localStorage export default { name: 'UserCenter', components: { BlogHeader, BlogFooter }, data() { return { username: '', password: '', token: '', } }, mounted() { this.username = storage.getItem('username.myblog') }, methods: { changeInfo() { authorization().then((resoponse) => { if (!resoponse[0]) { alert('登录已过期,请重新登录') return } console.log('Change info start') if (this.password.length > 0 && this.password.length < 6) { alert('Password too short') return } const oldName = storage.getItem('username.myblog') let data = {} if (this.username !== '') { data.username = this.username } if (this.password !== '') { data.password = this.password } this.token = storage.getItem('access.myblog') axios.patch('/api/user/' + oldName + '/', data, { headers: { Authorization: 'Bearer ' + this.token } }).then((response) => { const name = response.data.username storage.setItem('username.myblog', name) this.$router.push({ name: 'UserCenter', params: { username: name } }) }) }) } } } </script> <style scoped> #user-center { text-align: center; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 200px; } </style>
检查函数返回的数据,如果登录失效,或者密码太短,则拒绝执行后面的逻辑。 拿到用户填写的表单数据,并取出保存在本地的令牌,发送到后端接口更新用户数据。 修改BlogHeader.vue
代码:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <search-button /> </div> <hr> <div class="login"> <div v-if="hasLogin"> <div class="dropdown"> <button class="dropbtn">欢迎,{{ username }}</button> <div class="dropdown-content"> <router-link :to="{ name: 'UserCenter', params: { username: username } }">用户中心</router-link> <router-link @click.prevent="logout()" :to="{ name: 'Home' }">注销</router-link> </div> </div> </div> <div v-else> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </div> </template> <script> import SearchButton from '@/components/SearchButton.vue' import authorization from '@/utils/authorization' export default { name: 'BlogHeader', data() { return { username: '', hasLogin: false, } }, components: { SearchButton }, mounted() { authorization().then((data) => [this.hasLogin, this.username] = data) }, methods: { logout() { localStorage.clear() window.location.reload(false) } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .login { text-align: right; padding-right: 5px; } .dropbtn { background-color: mediumaquamarine; color: white; padding: 8px 8px 30px 8px; font-size: 16px; border: none; cursor: pointer; height: 16px; border-radius: 5px; } .dropdown { position: relative; display: inline-block; } .dropdown-content { display: none; position: absolute; background-color: #f9f9f9; min-width: 120px; box-shadow: 0 8px 16px 0 rgb(0, 0, 0, 0.2); text-align: center; } .dropdown-content a { color: black; padding: 12px 16px; text-decoration: none; display: block; } .dropdown-content a:hover { background-color: #f1f1f1; } .dropdown:hover .dropdown-content { display: block; } .dropdown:hover .dropbtn { background-color: darkslateblue; } </style>
路由注册到backend/src/router/index.js
:
import { createRouter, createWebHistory } from 'vue-router' const Home = () => import ('@/views/Home.vue' )const ArticleDetail = () => import ('@/views/ArticleDetail.vue' )const Login = () => import ('@/views/Login.vue' )const UserCenter = () => import ('@/views/UserCenter.vue' )const routes = [ { path : '/' , name : 'Home' , component : Home }, { path : '/article/:id' , name : 'ArticleDetail' , component : ArticleDetail }, { path : '/login' , name : 'Login' , component : Login }, { path : '/user/:username' , name : 'UserCenter' , component : UserCenter }, ] const router = createRouter({ history : createWebHistory(), routes, }) export default router
9.4 组件通信 Vue 中父组件向子组件传递信息的方式就是 Props
了,使用 Props 来实现欢迎词的更新。
修改UserCenter.vue
<!-- frontend/src/views/UserCenter.vue --> <template> <blog-header :welcome-name="welcomeName" /> <div id="user-center"> <h3>更新资料信息</h3> <form> <div class="form-elem"> <span>用户名:</span> <input type="text" v-model="username" placeholder="输入用户名"> </div> <div class="form-elem"> <span>新密码:</span> <input type="password" v-model="password" placeholder="输入密码"> </div> <div class="form-elem"> <button @click.prevent="changeInfo">更新</button> </div> </form> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; import authorization from '@/utils/authorization' const storage = localStorage export default { name: 'UserCenter', components: { BlogHeader, BlogFooter }, data() { return { username: '', password: '', token: '', welcomeName: '', } }, mounted() { this.username = storage.getItem('username.myblog') this.welcomeName = storage.get('username.myblog') }, methods: { changeInfo() { authorization().then((resoponse) => { if (!resoponse[0]) { alert('登录已过期,请重新登录') return } console.log('Change info start') if (this.password.length > 0 && this.password.length < 6) { alert('Password too short') return } const oldName = storage.getItem('username.myblog') let data = {} if (this.username !== '') { data.username = this.username } if (this.password !== '') { data.password = this.password } this.token = storage.getItem('access.myblog') axios.patch('/api/user/' + oldName + '/', data, { headers: { Authorization: 'Bearer ' + this.token } }).then((response) => { const name = response.data.username storage.setItem('username.myblog', name) this.$router.push({ name: 'UserCenter', params: { username: name } }) this.welcomeName = name }) }) } } } </script> <style scoped> #user-center { text-align: center; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 200px; } </style>
可以看到组件是可以带参数的(也就是 Props 了),这个参数会传递到子组件 中使用。
修改BlogHeader.vue
:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <search-button /> </div> <hr> <div class="login"> <div v-if="hasLogin"> <div class="dropdown"> <button class="dropbtn">欢迎,{{ name }}</button> <div class="dropdown-content"> <router-link :to="{ name: 'UserCenter', params: { username: username } }">用户中心</router-link> <router-link @click.prevent="logout()" :to="{ name: 'Home' }">注销</router-link> </div> </div> </div> <div v-else> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </div> </template> <script> import SearchButton from '@/components/SearchButton.vue' import authorization from '@/utils/authorization' export default { name: 'BlogHeader', props: ['welcomeName'], data() { return { username: '', hasLogin: false, } }, components: { SearchButton }, mounted() { authorization().then((data) => [this.hasLogin, this.username] = data) }, methods: { logout() { localStorage.clear() window.location.reload(false) } }, computed: { name() { return this.welcomeName !== undefined ? this.welcomeName : this.username } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .login { text-align: right; padding-right: 5px; } .dropbtn { background-color: mediumaquamarine; color: white; padding: 8px 8px 30px 8px; font-size: 16px; border: none; cursor: pointer; height: 16px; border-radius: 5px; } .dropdown { position: relative; display: inline-block; } .dropdown-content { display: none; position: absolute; background-color: #f9f9f9; min-width: 120px; box-shadow: 0 8px 16px 0 rgb(0, 0, 0, 0.2); text-align: center; } .dropdown-content a { color: black; padding: 12px 16px; text-decoration: none; display: block; } .dropdown-content a:hover { background-color: #f1f1f1; } .dropdown:hover .dropdown-content { display: block; } .dropdown:hover .dropbtn { background-color: darkslateblue; } </style>
computed
计算属性:
计算属性是基于它们的响应式依赖进行缓存的 。只在相关响应式依赖发生改变时它们才会重新求值。这就意味着只要与它有关系的参数没有发生改变,多次访问此计算属性会立即返回之前的计算结果,而不必再次执行函数。相比之下,每当触发重新渲染时,方法 将总会 再次执行函数。**计算属性默认不接受参数,并且不能产生副作用。**也就是说,在它的执行过程中不能改变任何 Vue 所管理的数据,否则将会报错。计算属性是依赖数据工作的,副作用会使代码不可预测。 一般来说,能用 computed
就尽量用它,不能的再考虑 methods
,算是用空间(缓存)换取时间(效率)
Vue 的子组件给父组件传递信息采用的是事件 的形式。
Vuex 是一个专为 Vue 应用程序开发的状态管理模式 。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。也就是说,Vuex 把组件的共享状态抽取出来,以一个全局单例模式管理。在这种模式下,组件树构成了一个巨大的“视图”,不管在树的哪个位置,任何组件都能获取状态或者触发行为。
Vuex 可以帮助我们管理共享状态,并附带了更多的概念和框架。这需要对短期和长期效益进行权衡。
如果您不打算开发大型单页应用,使用 Vuex 可能是繁琐冗余的;应用够简单,最好不要使用 Vuex。一个简单的 store 模式 就足够了。如果需要构建一个中大型单页应用,Vuex 将会成为自然而然的选择。中小型单页应用也可以使用 Pinia。
Props 虽然能够解决我们的问题,但总要持有 welcomeName
和 username
两个状态,可以使用 ref
访问子组件来改进。在 BlogHeader.vue
中写一个刷新数据的方法:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <search-button /> </div> <hr> <div class="login"> <div v-if="hasLogin"> <div class="dropdown"> <button class="dropbtn">欢迎,{{ username }}</button> <div class="dropdown-content"> <router-link :to="{ name: 'UserCenter', params: { username: username } }">用户中心</router-link> <router-link @click.prevent="logout()" :to="{ name: 'Home' }">注销</router-link> </div> </div> </div> <div v-else> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </div> </template> <script> import SearchButton from '@/components/SearchButton.vue' import authorization from '@/utils/authorization' export default { name: 'BlogHeader', data() { return { username: '', hasLogin: false, } }, components: { SearchButton }, mounted() { authorization().then((data) => [this.hasLogin, this.username] = data) }, methods: { logout() { localStorage.clear() window.location.reload(false) }, refresh() { this.username = localStorage.getItem('username.myblog') } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .login { text-align: right; padding-right: 5px; } .dropbtn { background-color: mediumaquamarine; color: white; padding: 8px 8px 30px 8px; font-size: 16px; border: none; cursor: pointer; height: 16px; border-radius: 5px; } .dropdown { position: relative; display: inline-block; } .dropdown-content { display: none; position: absolute; background-color: #f9f9f9; min-width: 120px; box-shadow: 0 8px 16px 0 rgb(0, 0, 0, 0.2); text-align: center; } .dropdown-content a { color: black; padding: 12px 16px; text-decoration: none; display: block; } .dropdown-content a:hover { background-color: #f1f1f1; } .dropdown:hover .dropdown-content { display: block; } .dropdown:hover .dropbtn { background-color: darkslateblue; } </style>
然后在 UserCenter.vue
更新用户数据时访问此函数:
<!-- frontend/src/views/UserCenter.vue --> <template> <blog-header ref="header" /> <div id="user-center"> <h3>更新资料信息</h3> <form> <div class="form-elem"> <span>用户名:</span> <input type="text" v-model="username" placeholder="输入用户名"> </div> <div class="form-elem"> <span>新密码:</span> <input type="password" v-model="password" placeholder="输入密码"> </div> <div class="form-elem"> <button @click.prevent="changeInfo">更新</button> </div> </form> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; import authorization from '@/utils/authorization' const storage = localStorage export default { name: 'UserCenter', components: { BlogHeader, BlogFooter }, data() { return { username: '', password: '', token: '', } }, mounted() { this.username = storage.getItem('username.myblog') }, methods: { changeInfo() { authorization().then((resoponse) => { if (!resoponse[0]) { alert('登录已过期,请重新登录') return } console.log('Change info start') if (this.password.length > 0 && this.password.length < 6) { alert('Password too short') return } const oldName = storage.getItem('username.myblog') let data = {} if (this.username !== '') { data.username = this.username } if (this.password !== '') { data.password = this.password } this.token = storage.getItem('access.myblog') axios.patch('/api/user/' + oldName + '/', data, { headers: { Authorization: 'Bearer ' + this.token } }).then((response) => { const name = response.data.username storage.setItem('username.myblog', name) this.$router.push({ name: 'UserCenter', params: { username: name } }) this.$refs.header.refresh() }) }) } } } </script> <style scoped> #user-center { text-align: center; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 200px; } </style>
9.5 用户删除 删除用户按钮通常会放在用户中心页面,并且为了避免用户误操作,点击后还要进行第二次确认,方可删除。
修改 UserCenter.vue
文件:
<!-- frontend/src/views/UserCenter.vue --> <template> <blog-header ref="header" /> <div id="user-center"> <h3>更新资料信息</h3> <form> <div class="form-elem"> <span>用户名:</span> <input type="text" v-model="username" placeholder="输入用户名"> </div> <div class="form-elem"> <span>新密码:</span> <input type="password" v-model="password" placeholder="输入密码"> </div> <div class="form-elem"> <button @click.prevent="changeInfo">更新</button> </div> <div class="form-elem"> <button @click.prevent="showingDeleteAlert = true" class="delete-btn">删除用户</button> <div :class="{ shake: showingDeleteAlert }"> <button v-if="showingDeleteAlert" class="confirm-btn" @click.prevent="confirmDelete"> 确定? </button> </div> </div> </form> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; import authorization from '@/utils/authorization' const storage = localStorage export default { name: 'UserCenter', components: { BlogHeader, BlogFooter }, data() { return { username: '', password: '', token: '', showingDeleteAlert: false } }, mounted() { this.username = storage.getItem('username.myblog') }, methods: { changeInfo() { authorization().then((resoponse) => { if (!resoponse[0]) { alert('登录已过期,请重新登录') return } console.log('Change info start') if (this.password.length > 0 && this.password.length < 6) { alert('Password too short') return } const oldName = storage.getItem('username.myblog') let data = {} if (this.username !== '') { data.username = this.username } if (this.password !== '') { data.password = this.password } this.token = storage.getItem('access.myblog') axios.patch('/api/user/' + oldName + '/', data, { headers: { Authorization: 'Bearer ' + this.token } }).then((response) => { const name = response.data.username storage.setItem('username.myblog', name) this.$router.push({ name: 'UserCenter', params: { username: name } }) this.$refs.header.refresh() }) }) }, confirmDelete() { authorization().then((response) => { if (response[0]) { this.token = storage.getItem('access.myblog') axios.delete('/api/user/' + this.username + '/', { headers: { Authorization: 'Bearer ' + this.token } }).then(() => { storage.clear() this.$router.push({ name: 'Home' }) }) } }) } } } </script> <style scoped> #user-center { text-align: center; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 200px; } .confirm-btn { width: 80px; background-color: darkorange; } .delete-btn { background-color: darkred; margin-bottom: 10px; } .shake { animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; transform: translate3d(0, 0, 0); backface-visibility: hidden; perspective: 1000px; } @keyframes shake { 10%, 90% { transform: translate3d(-1px, 0, 0); } 20%, 80% { transform: translate3d(2px, 0, 0); } 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } 40%, 60% { transform: translate3d(4px, 0, 0); } } </style>
10. 文章操作 10.1 准备工作 修改后端文件 user_info/serializers.py
,增加返回当前用户 是否为超级用户 的信息
from django.contrib.auth.models import Userfrom rest_framework import serializersclass UserDescSerializer (serializers.ModelSerializer ): """ 文章列表中引用的嵌套用户信息 """ class Meta : model = User fields = [ 'id' , 'username' , 'last_login' , 'date_joined' ] class UserRegisterSerializer (serializers.ModelSerializer ): """ 用户管理序列化器 """ url = serializers.HyperlinkedIdentityField(view_name='user-detail' , lookup_field='username' ) class Meta : model = User fields = ['url' , 'id' , 'username' , 'password' , 'is_superuser' ] extra_kwargs = {'password' : {'write_only' : True }, 'is_superuser' : {'read_only' : True }} def create (self, validated_data ): user = User.objects.create_user(**validated_data) return user def update (self, instance, validated_data ): if 'password' in validated_data: password = validated_data.pop('password' ) instance.set_password(password) return super ().update(instance, validated_data) class UserDetailSerializer (serializers.ModelSerializer ): class Meta : model = User fields = [ 'id' , 'username' , 'first_name' , 'last_name' , 'email' , 'last_login' , 'date_joined' ]
修改后端文件 article/views.py
:
from rest_framework import viewsets, filtersfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Article, Category, Tag, Avatarfrom article.serializers import ( ArticleSerializer, CategorySerializer, CategoryDetailSerializer, TagSerializer, ArticleDetailSerializer, AvatarSerializer, ) class AvatarViewSet (viewsets.ModelViewSet ): """ 文章标题图视图集 """ queryset = Avatar.objects.all () serializer_class = AvatarSerializer permission_classes = [IsAdminUserOrReadOnly] class TagViewSet (viewsets.ModelViewSet ): """ 标签视图集 """ queryset = Tag.objects.all () serializer_class = TagSerializer permission_classes = [IsAdminUserOrReadOnly] pagination_class = None class CategoryViewSet (viewsets.ModelViewSet ): """ 分类视图集 """ queryset = Category.objects.all () serializer_class = CategorySerializer permission_classes = [IsAdminUserOrReadOnly] pagination_class = None def get_serializer_class (self ): if self.action == 'list' : return CategorySerializer else : return CategoryDetailSerializer class ArticleViewSet (viewsets.ModelViewSet ): """ 文章视图集 """ queryset = Article.objects.all () serializer_class = ArticleSerializer permission_classes = [IsAdminUserOrReadOnly] filter_backends = [filters.SearchFilter] search_fields = ['title' ] def perform_create (self, serializer ): serializer.save(author=self.request.user) def get_serializer_class (self ): if self.action == 'list' : return ArticleSerializer else : return ArticleDetailSerializer
10.2 发布文章界面 在用户登录时追加记录用户是否为超级管理员 :
<!-- frontend/src/views/Login.vue --> <template> <blog-header /> <div id="grid"> <div id="signup"> <h3>注册账号</h3> <form> <div class="form-elem"> <span class="label">账号:</span> <input type="text" v-model="signupName" placeholder="输入用户名"> </div> <div class="form-elem"> <span class="label">密码:</span> <input type="password" v-model="signupPwd" placeholder="输入密码"> </div> <div class="form-elem"> <span class="label">确认密码:</span> <input type="password" v-model="signupPwdConfirm" placeholder="再次输入密码"> </div> <div class="form-elem"> <button @click.prevent="signup">提交</button> </div> </form> </div> <div id="signin"> <h3>登录账号</h3> <form> <div class="form-elem"> <span class="label">账号:</span> <input type="text" v-model="signinName" placeholder="输入用户名"> </div> <div class="form-elem"> <span class="label">密码:</span> <input type="password" v-model="signinPwd" placeholder="输入密码"> </div> <div class="form-elem"> <button @click.prevent="signin">登录</button> </div> </form> </div> </div> <blog-footer /> </template> <script> import axios from 'axios' import BlogHeader from '@/components/BlogHeader.vue'; import BlogFooter from '@/components/BlogFooter.vue'; export default { name: "Login", components: { BlogHeader, BlogFooter }, data() { return { signupName: '', signupPwd: '', signupPwdConfirm: '', signinName: '', signinPwd: '', signupResponse: null, } }, methods: { signup() { if (this.signupPwd !== this.signupPwdConfirm) { alert("两次密码不一致,请重新输入!") return } axios.post('/api/user/', { username: this.signupName, password: this.signupPwd, }).then((response) => { this.signupResponse = response.data alert('用户注册成功') }).catch((error) => { alert('用户注册失败:', error.message) }) }, signin() { axios.post('/api/token/', { username: this.signinName, password: this.signinPwd, }).then((response) => { const storage = localStorage const expiredTime = Date.parse(response.headers.date) + 10800000 storage.setItem('access.myblog', response.data.access) storage.setItem('refresh.myblog', response.data.refresh) storage.setItem('expiredTime.myblog', expiredTime) storage.setItem('username.myblog', this.signinName) axios.get('/api/user/' + this.signinName + '/').then((response) => { storage.setItem('isSuperuser.myblog', response.data.is_superuser) this.$router.push({ name: 'Home' }) }) }).catch((error) => { alert('用户登录失败:', error.message) }) } } } </script> <style scoped> #grid { display: grid; grid-template-columns: 1fr 1fr; } #signup { text-align: center; } #signin { text-align: center; } .form-elem { padding: 10px; } .label { display: inline-block; width: 80px; text-align: justify; text-align-last: justify; margin-right: 1px; } input { height: 25px; padding-left: 10px; } button { height: 35px; cursor: pointer; border: none; outline: none; background: gray; color: whitesmoke; border-radius: 5px; width: 60px; } </style>
发表文章页面 frontend/src/views/ArticleCreate.vue
:
<!-- frontend/src/views/ArticleCreate.vue --> <template> <blog-header /> <div id="article-create"> <h3>发表文章</h3> <form> <div class="form-elem"> <span>标题:</span> <input type="text" v-model="title" placeholder="输入标题"> </div> <div class="form-elem"> <span>分类:</span> <span v-for="category in categories" :key="category.id"> <button class="category-btn" :style="categoryStyle(category)" @click.prevent="chooseCategory(category)"> {{ category.title }} </button> </span> </div> <div class="form-elem"> <span>标签:</span> <input type="text" v-model="tags" placeholder="输入标签,用逗号分隔"> </div> <div class="form-elem"> <span>正文:</span> <textarea v-model="body" placeholder="输入正文" cols="80" rows="20"></textarea> </div> <div class="form-elem"> <button @click.prevent="submit">提交</button> </div> </form> </div> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import axios from 'axios' import authorization from '@/utils/authorization' export default { name: 'ArticleCreate', components: { BlogHeader, BlogFooter }, data() { return { title: '', body: '', categories: [], selectedCategory: null, tags: '', } }, mounted() { axios.get('/api/category/').then((response) => { this.categories = response.data }) }, methods: { categoryStyle(category) { if (this.selectedCategory !== null && category.id === this.selectedCategory.id) { return { backgroundColor: 'black' } } return { backgroundColor: 'lightgrey', color: 'black' } }, chooseCategory(category) { if (this.selectedCategory !== null && this.selectedCategory.id === category.id) { this.selectedCategory = null } else { this.selectedCategory = category } }, submit() { authorization().then((response) => { if (response[0]) { let data = { title: this.title, body: this.body } if (this.selectedCategory) { data.category_id = this.selectedCategory.id } data.tags = this.tags.split(/[,,]/).map(x => x.trim()).filter(x => x.charAt(0) !== '') const token = localStorage.getItem('access.myblog') axios.post('/api/article/', data, { headers: { Authorization: 'Bearer ' + token } }).then((response) => { this.$router.push({ name: 'ArticleDetail', params: { id: response.data.id } }) }) } else { alert('令牌过期,请重新登录。') } }) } } } </script> <style scoped> .category-btn { margin-right: 10px; } #article-create { text-align: center; font-size: large; } form { text-align: left; padding-left: 100px; padding-right: 10px; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; width: 50%; } button { height: 35px; cursor: pointer; border: none; outline: none; background: steelblue; color: whitesmoke; border-radius: 5px; width: 60px; } </style>
注册发布文章页面的路由:
import { createRouter, createWebHistory } from 'vue-router' const Home = () => import ('@/views/Home.vue' )const ArticleDetail = () => import ('@/views/ArticleDetail.vue' )const Login = () => import ('@/views/Login.vue' )const UserCenter = () => import ('@/views/UserCenter.vue' )const ArticleCreate = () => import ('@/views/ArticleCreate.vue' )const routes = [ { path : '/' , name : 'Home' , component : Home }, { path : '/article/:id' , name : 'ArticleDetail' , component : ArticleDetail }, { path : '/login' , name : 'Login' , component : Login }, { path : '/user/:username' , name : 'UserCenter' , component : UserCenter, meta : { requireAuth : true } }, { path : '/article/create' , name : 'ArticleCreate' , component : ArticleCreate, }, ] const router = createRouter({ history : createWebHistory(), routes, }) router.beforeEach((to, from , next ) => { if (to.meta.requireAuth) { if (localStorage .getItem('access.myblog' ) && to.params.username === localStorage .getItem('username.myblog' )) { next() } else if (to.params.username !== localStorage .getItem('username.myblog' )) { next({ path : from .path }) } else { if (to.path === '/login' ) { next() } else { alert('请先登录!' ) next({ path : '/login' }) } } } else { next() } }) export default router
页眉的欢迎词下拉框用 v-if
仅对超级用户 显示入口,普通用户不显示:
<!-- frontend/src/components/BlogHeader.vue --> <template> <div id="header"> <div class="grid"> <div></div> <h1>My Django REST framework-Vue Blog</h1> <search-button /> </div> <hr> <div class="login"> <div v-if="hasLogin"> <div class="dropdown"> <button class="dropbtn">欢迎,{{ username }}</button> <div class="dropdown-content"> <router-link :to="{ name: 'UserCenter', params: { username: username } }">用户中心</router-link> <router-link :to="{ name: 'ArticleCreate' }" v-if="isSuperuser">发布文章</router-link> <router-link @click.prevent="logout()" :to="{ name: 'Home' }">注销</router-link> </div> </div> </div> <div v-else> <router-link to="/login" class="login-link">登录</router-link> </div> </div> </div> </template> <script> import SearchButton from '@/components/SearchButton.vue' import authorization from '@/utils/authorization' export default { name: 'BlogHeader', data() { return { username: '', hasLogin: false, isSuperuser: JSON.parse(localStorage.getItem('isSuperuser.myblog')) } }, components: { SearchButton }, mounted() { authorization().then((data) => [this.hasLogin, this.username] = data) }, methods: { logout() { localStorage.clear() window.location.reload(false) }, refresh() { this.username = localStorage.getItem('username.myblog') } } } </script> <style scoped> #header { text-align: center; margin-top: 20px; } .grid { display: grid; grid-template-columns: 1fr 4fr 1fr; } .login { text-align: right; padding-right: 5px; } .dropbtn { background-color: mediumaquamarine; color: white; padding: 8px 8px 30px 8px; font-size: 16px; border: none; cursor: pointer; height: 16px; border-radius: 5px; } .dropdown { position: relative; display: inline-block; } .dropdown-content { display: none; position: absolute; background-color: #f9f9f9; min-width: 120px; box-shadow: 0 8px 16px 0 rgb(0, 0, 0, 0.2); text-align: center; } .dropdown-content a { color: black; padding: 12px 16px; text-decoration: none; display: block; } .dropdown-content a:hover { background-color: #f1f1f1; } .dropdown:hover .dropdown-content { display: block; } .dropdown:hover .dropbtn { background-color: darkslateblue; } </style>
为了让列表页面也能显示分类 信息,稍微改一改 ArticleList.vue
:
<!-- frontend/src/components/ArticleList.vue --> <template> <div v-for="article in info.results" :key="article.url" id="articles"> <div> <span v-if="article.category !== null" class="category">{{ article.category.title }}</span> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <router-link :to="{ name: 'ArticleDetail', params: { id: article.id } }" class="article-title"> {{ article.title }} </router-link> <div>{{ formatted_time(article.created) }}</div> </div> <div id="paginator"> <span v-if="is_page_exists('previous')"> <router-link :to="get_path('previous')"> Prev </router-link> </span> <span class="current-page"> {{ get_page_param('current') }} </span> <span v-if="is_page_exists('next')"> <router-link :to="get_path('next')"> Next </router-link> </span> </div> </template> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '', } }, mounted() { this.get_article_data() }, methods: { // 判断页面是否存在 is_page_exists(direction) { if (direction === 'next') { return this.info.next !== null } return this.info.previous !== null }, // 获取页码 get_page_param(direction) { try { let url_string switch (direction) { case 'next': url_string = this.info.next break case 'previous': url_string = this.info.previous break default: if (!('page' in this.$route.query)) { return 1 } if (this.$route.query.page === null) { return 1 } return this.$route.query.page } const url = new URL(url_string) return url.searchParams.get('page') } catch (err) { return } }, // 获取文章列表数据 get_article_data() { let url = '/api/article/' let params = new URLSearchParams() if (this.isExists(this.$route.query.page)) { params.append('page', this.$route.query.page) } if (this.isExists(this.$route.query.search)) { params.append('search', this.$route.query.search) } const paramsString = params.toString() if (paramsString.charAt(0) !== '') { url += '/?' + paramsString } axios.get(url).then(response => { this.info = response.data }).catch(error => { console.log(error) }) }, // 获取路径 get_path(direction) { let url = '' try { switch (direction) { case 'next': if (this.info.next !== undefined) { url += (new URL(this.info.next)).search } break case 'previous': if (this.info.previous !== undefined) { url += (new URL(this.info.previous)).search } break } } catch { return url } return url }, // 检查参数是否存在 isExists(value) { return value !== null && value !== undefined } }, watch: { // 监听路由变化 $route() { this.get_article_data() } }, computed: { // 格式化时间 formatted_time() { return timeString => new Date(timeString).toLocaleDateString() } } } </script> <style scoped> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } #paginator { text-align: center; padding-top: 50px; } a { color: black; } .current-page { font-size: x-large; font-weight: bold; padding-left: 10px; padding-right: 10px; } .category { padding: 5px 10px 5px 10px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: darkred; color: whitesmoke; border-radius: 15px; } </style>
10.3 文章更新与删除页面 新建 frontend/src/views/ArticleEdit.vue
文件:
<!-- frontend/src/views/ArticleEdit.vue --> <template> <blog-header /> <div id="article-create"> <h3>更新文章</h3> <form> <div class="form-elem"> <span>标题:</span> <input type="text" v-model="title" placeholder="输入标题"> </div> <div class="form-elem"> <span>分类:</span> <span v-for="category in categories" :key="category.id"> <button class="category-btn" :style="categoryStyle(category)" @click.prevent="chooseCategory(category)"> {{ category.title }} </button> </span> </div> <div class="form-elem"> <span>标签:</span> <input type="text" v-model="tags" placeholder="输入标签,用逗号分隔"> </div> <div class="form-elem"> <span>正文:</span> <textarea v-model="body" placeholder="输入正文" cols="80" rows="20"></textarea> </div> <div class="form-elem"> <button @click.prevent="submit">提交</button> </div> <div class="form-elem"> <button @click.prevent="deleteArticle" style="background-color: darkred;">删除</button> </div> </form> </div> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import axios from 'axios' import authorization from '@/utils/authorization' export default { name: 'ArticleEdit', components: { BlogHeader, BlogFooter, }, data() { return { title: '', body: '', categories: [], selectedCategory: null, tags: '', articleID: null, } }, mounted() { axios.get('/api/category/').then(response => this.categories = response.data) axios.get('/api/article/' + this.$route.params.id + '/').then((response) => { this.title = response.data.title this.body = response.data.body this.selectedCategory = response.data.category this.tags = response.data.tags.join(',') this.articleID = response.data.id }) }, methods: { categoryStyle(category) { if (this.selectedCategory !== null && category.id === this.selectedCategory.id) { return { backgroundColor: 'black', } } return { backgroundColor: 'lightgrey', color: 'black' } }, chooseCategory(category) { if (this.selectedCategory !== null && category.id == this.selectedCategory.id) { this.selectedCategory = null } else { this.selectedCategory = category } }, submit() { authorization().then((response) => { if (response[0]) { let data = { title: this.title, body: this.body } data.category_id = this.selectedCategory ? this.selectedCategory.id : null data.tags = this.tags.split(/[,,]/).map(x => x.trim()).filter(x => x.charAt(0) !== '') const token = localStorage.getItem('access.myblog') axios.put('/api/article/' + this.articleID + '/', data, { headers: { Authorization: 'Bearer ' + token } }).then((response) => { this.$router.push({ name: 'ArticleDetail', params: { id: response.data.id } }) }) } else { alert('令牌过期,请重新登录') } }) }, deleteArticle() { const token = localStorage.getItem('access.myblog') authorization().then((response) => { if (response[0]) { axios.delete('/api/article/' + this.articleID + '/', { headers: { Authorization: 'Bearer ' + token } }).then((response) => { this.$router.push({ name: 'Home' }) }) } else { alert('令牌过期,请重新登录') } }) } } } </script> <style scoped> .category-btn { margin-left: 10px; } #article-create { text-align: center; font-size: large; } form { text-align: left; padding-left: 100px; padding-right: 10px; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; width: 50%; } button { height: 35px; cursor: pointer; border: none; outline: none; background: steelblue; color: whitesmoke; border-radius: 5px; width: 60px; } </style>
注册路由:
import { createRouter, createWebHistory } from 'vue-router' const Home = () => import ('@/views/Home.vue' )const ArticleDetail = () => import ('@/views/ArticleDetail.vue' )const Login = () => import ('@/views/Login.vue' )const UserCenter = () => import ('@/views/UserCenter.vue' )const ArticleCreate = () => import ('@/views/ArticleCreate.vue' )const ArticleEdit = () => import ('@/views/ArticleEdit.vue' )const routes = [ { path : '/' , name : 'Home' , component : Home }, { path : '/article/:id' , name : 'ArticleDetail' , component : ArticleDetail }, { path : '/login' , name : 'Login' , component : Login }, { path : '/user/:username' , name : 'UserCenter' , component : UserCenter, meta : { requireAuth : true } }, { path : '/article/create' , name : 'ArticleCreate' , component : ArticleCreate, }, { path : '/article/edit/:id' , name : 'ArticleEdit' , component : ArticleEdit, }, ] const router = createRouter({ history : createWebHistory(), routes, }) router.beforeEach((to, from , next ) => { if (to.meta.requireAuth) { if (localStorage .getItem('access.myblog' ) && to.params.username === localStorage .getItem('username.myblog' )) { next() } else if (to.params.username !== localStorage .getItem('username.myblog' )) { next({ path : from .path }) } else { if (to.path === '/login' ) { next() } else { alert('请先登录!' ) next({ path : '/login' }) } } } else { next() } }) export default router
在文章详情页中放一个更新和删除页面的入口:
<!-- frontend/src/views/ArticleDetail.vue --> <template> <blog-header /> <div v-if="article !== null" class="grid-container"> <div> <h1 id="title">{{ article.title }}</h1> <p id="subtitle">本文由 {{ article.author.username }} 发布于 {{ formatted_time }} <span if="isSuperuser"> <router-link :to="{ name: 'ArticleEdit', params: { id: article.id } }">更新与删除</router-link> </span> </p> <div v-html="article.body_html" class="article-body"></div> </div> <div> <h3>目录</h3> <div v-html="article.toc_html" class="toc"></div> </div> </div> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import axios from 'axios' export default { name: 'ArticleDetail', components: { BlogHeader, BlogFooter }, data() { return { article: null } }, mounted() { axios.get('/api/article/' + this.$route.params.id).then(response => { this.article = response.data }).catch(error => { console.log(error) }) }, computed: { formatted_time() { return new Date(this.article.created).toLocaleDateString() }, isSuperuser() { return localStorage.getItem('isSuperuser.myblog') === 'true' } } } </script> <style scoped> .grid-container { display: grid; grid-template-columns: 3fr 1fr; } #title { text-align: center; font-size: x-large; } #subtitle { text-align: center; color: gray; font-size: small; } </style> <style> .article-body p img { max-width: 100%; border-radius: 50px; box-shadow: gray 0 0 20px; } .toc ul { list-style-type: none; } .toc a { color: gray; } </style>
10.4 文章标题图 图片提交 的流程:在 multipart/form-data
中发送文件,然后将保存好的文件 id 返回给客户端。客户端拿到文件 id 后,发送带有 id 的 Json 数据,在服务器端将它们关联起来。
在发表新文章页面中选定图片后,不等待文章的提交而是立即将图片上传。 图片上传成功后返回图片 id,前端将 id 保存待用。 提交文章时,将图片 id 一并打包提交即可。 在 ArticleCreate.vue
中添加代码:
<!-- frontend/src/views/ArticleCreate.vue --> <!-- frontend/src/views/ArticleCreate.vue --> <template> <BlogHeader /> <div id="article-create"> <h3>发表文章</h3> <form id="image-form"> <div class="form-elem"> <span>图片:</span> <input type="file" id="file" @change="onFileChange"> </div> </form> <form> <div class="form-elem"> <span>标题:</span> <input v-model="title" type="text" placeholder="输入标题"> </div> <div class="form-elem"> <span>分类:</span> <span v-for="category in categories" :key="category.id"> <!--样式也可以通过 :style 绑定--> <button class="category-btn" :style="categoryStyle(category)" @click.prevent="chooseCategory(category)"> {{ category.title }} </button> </span> </div> <div class="form-elem"> <span>标签:</span> <input v-model="tags" type="text" placeholder="输入标签,用逗号分隔"> </div> <div class="form-elem"> <span>正文:</span> <textarea v-model="body" placeholder="输入正文" rows="20" cols="80"></textarea> </div> <div class="form-elem"> <button v-on:click.prevent="submit">提交</button> </div> </form> </div> <BlogFooter /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import axios from 'axios'; import authorization from '@/utils/authorization'; export default { name: 'ArticleCreate', components: { BlogHeader, BlogFooter }, data: function () { return { // 文章标题 title: '', // 文章正文 body: '', // 数据库中所有的分类 categories: [], // 选定的分类 selectedCategory: null, // 标签 tags: '', // 标题图 avatarID: null } }, mounted() { // 页面初始化时获取所有分类 axios .get('/api/category/') .then(response => this.categories = response.data) }, methods: { onFileChange(e) { // 将文件二进制数据添加到提交数据中 const file = e.target.files[0] let formData = new FormData() formData.append("content", file) axios.post('/api/avatar/', formData, { headers: { 'Content-Type': 'multipart/form-data', 'Authorization': 'Bearer ' + localStorage.getItem('access.myblog') } }).then((response) => { this.avatarID = response.data.id }) }, // 根据分类是否被选中,按钮的颜色发生变化 // 这里可以看出 css 也是可以被 vue 绑定的,很方便 categoryStyle(category) { if (this.selectedCategory !== null && category.id === this.selectedCategory.id) { return { backgroundColor: 'black', } } return { backgroundColor: 'lightgrey', color: 'black', } }, // 选取分类的方法 chooseCategory(category) { // 如果点击已选取的分类,则将 selectedCategory 置空 if (this.selectedCategory !== null && this.selectedCategory.id === category.id) { this.selectedCategory = null } // 如果没选中当前分类,则选中它 else { this.selectedCategory = category; } }, // 点击提交按钮 submit() { const that = this; // 前面封装的验证函数又用上了 authorization() .then(function (response) { if (response[0]) { // 需要传给后端的数据字典 let data = { title: that.title, body: that.body, }; // 添加分类 if (that.selectedCategory) { data.category_id = that.selectedCategory.id } // 标签预处理 data.tags = that.tags // 用逗号分隔标签 .split(/[,,]/) // 剔除标签首尾空格 .map(x => x.trim()) // 剔除长度为零的无效标签 .filter(x => x.charAt(0) !== ''); data.avatar_id = this.avatarID // 将发表文章请求发送至接口 // 成功后前往详情页面 const token = localStorage.getItem('access.myblog'); axios .post('/api/article/', data, { headers: { Authorization: 'Bearer ' + token } }) .then(function (response) { that.$router.push({ name: 'ArticleDetail', params: { id: response.data.id } }); }) } else { alert('令牌过期,请重新登录。') } } ) } } } </script> <style scoped> .category-btn { margin-right: 10px; } #article-create { text-align: center; font-size: large; } form { text-align: left; padding-left: 100px; padding-right: 10px; } .form-elem { padding: 10px; } input { height: 25px; padding-left: 10px; width: 50%; } button { height: 35px; cursor: pointer; border: none; outline: none; background: steelblue; color: whitesmoke; border-radius: 5px; width: 60px; } </style>
新增了一个表单 (不用表单其实也没关系),表单含有一个提交文件的控件;v-on:change
将控件绑定到 onFileChange()
方法,即只要用户选定了任何图片,都会触发此方法。 onFileChange(e)
中的参数为控件所触发的事件对象。由于图片二进制流不能以简单的字符串数据进行表示,所以将其添加到 FormData
表单对象中,发送到图片上传接口。若接口返回成功,则将返回的 id 值保存待用。submit()
对应增加了图片 id 的赋值语句。接下来在文章列表页面显示 它,修改 ArticleList.vue
代码。
<template> <div v-for="article in info.results" :key="article.url" id="articles"> <div class="grid" :style="gridStyle(article)"> <div class="image-container"> <img :src="imageIfExists(article)" alt="" class="image" accept="image/gif, image/jpeg"> </div> <div> <div> <span v-if="article.category !== null" class="category">{{ article.category.title }}</span> <span v-for="tag in article.tags" :key="tag" class="tag"> {{ tag }} </span> </div> <router-link :to="{ name: 'ArticleDetail', params: { id: article.id } }" class="article-title"> {{ article.title }} </router-link> <div>{{ formatted_time(article.created) }}</div> </div> </div> </div> <div id="paginator"> <span v-if="is_page_exists('previous')"> <router-link :to="get_path('previous')"> Prev </router-link> </span> <span class="current-page"> {{ get_page_param('current') }} </span> <span v-if="is_page_exists('next')"> <router-link :to="get_path('next')"> Next </router-link> </span> </div> </template> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '', } }, mounted() { this.get_article_data() }, methods: { // 判断标题图是否存在并返回图像 imageIfExists(article) { if (article.avatar) { return article.avatar.content } }, // 修改grid样式 gridStyle(article) { if (article.avatar) { return { display: 'grid', gridTemplateColumns: '1fr 7fr' } } }, // 判断页面是否存在 is_page_exists(direction) { if (direction === 'next') { return this.info.next !== null } return this.info.previous !== null }, // 获取页码 get_page_param(direction) { try { let url_string switch (direction) { case 'next': url_string = this.info.next break case 'previous': url_string = this.info.previous break default: if (!('page' in this.$route.query)) { return 1 } if (this.$route.query.page === null) { return 1 } return this.$route.query.page } const url = new URL(url_string) return url.searchParams.get('page') } catch (err) { return } }, // 获取文章列表数据 get_article_data() { let url = '/api/article/' let params = new URLSearchParams() if (this.isExists(this.$route.query.page)) { params.append('page', this.$route.query.page) } if (this.isExists(this.$route.query.search)) { params.append('search', this.$route.query.search) } const paramsString = params.toString() if (paramsString.charAt(0) !== '') { url += '/?' + paramsString } axios.get(url).then(response => { this.info = response.data }).catch(error => { console.log(error) }) }, // 获取路径 get_path(direction) { let url = '' try { switch (direction) { case 'next': if (this.info.next !== undefined) { url += (new URL(this.info.next)).search } break case 'previous': if (this.info.previous !== undefined) { url += (new URL(this.info.previous)).search } break } } catch { return url } return url }, // 检查参数是否存在 isExists(value) { return value !== null && value !== undefined } }, watch: { // 监听路由变化 $route() { this.get_article_data() } }, computed: { // 格式化时间 formatted_time() { return timeString => new Date(timeString).toLocaleDateString() } } } </script> <style scoped> #articles { padding: 10px; } .article-title { font-size: large; font-weight: bolder; color: black; text-decoration: none; padding: 5px 0 5px 0; } .tag { padding: 2px 5px 2px 5px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: #4e4e4e; color: whitesmoke; border-radius: 5px; } #paginator { text-align: center; padding-top: 50px; } a { color: black; } .current-page { font-size: x-large; font-weight: bold; padding-left: 10px; padding-right: 10px; } .category { padding: 5px 10px 5px 10px; margin: 5px 5px 5px 0; font-family: Georgia, Arial, sans-serif; font-size: small; background-color: darkred; color: whitesmoke; border-radius: 15px; } .image { width: 180px; border-radius: 10px; box-shadow: darkslategrey 0 0 12px; } .image-container { width: 200px; } .grid { padding-bottom: 10px; } </style>
11. 发布评论 11.1 发布评论组件 新建 frontend/src/components/Comments.vue
:
<!-- frontend/src/components/Comments.vue --> <template> <br><br> <hr> <h3>发表评论</h3> <!-- 评论多行文本输入控件 --> <textarea v-model="message" :placeholder="placeholder" name="comment" id="comment-area" cols="60" rows="10"></textarea> <div> <button @click="submit" class="submitBtn">发布</button> </div> <br> <p>已有 {{ comments.length }} 条评论</p> <hr> <!-- 渲染所有评论内容 --> <div v-for="comment in comments" :key="comment.id"> <div class="comments"> <div> <span class="username"> {{ comment.author.username }} </span> 于 <span class="created"> {{ formatted_time(comment.created) }} </span> <span v-if="comment.parent"> 对 <span class="parent"> {{ comment.parent.author.username }} </span> </span> 说道: </div> <div class="content"> {{ comment.content }} </div> <div> <button class="commentBtn" @click="replyTo(comment)">回复</button> </div> </div> <hr> </div> </template> <script> import axios from 'axios'; import authorization from '@/utils/authorization'; export default { name: 'Comments', // 通过 props 获取当前文章 props: { article: Object }, data() { return { // 所有评论 comments: [], // 评论控件绑定的文本和占位符 message: '', placeholder: '说点啥吧...', // 评论的评论 parentID: null } }, // 监听 article 对象 // 以便实时更新评论 watch: { article() { this.comments = this.article !== null ? this.article.comments : [] } }, methods: { // 提交评论 submit() { authorization().then((response) => { if (response[0]) { axios.post('/api/comment/', { content: this.message, article_id: this.article.id, parent_id: this.parentID }, { headers: { Authorization: 'Bearer ' + localStorage.getItem('access.myblog') } }).then((response) => { // 将新评论添加到顶部 this.comments.unshift(response.data); this.message = ''; alert('留言成功') }) } else { alert('请登录后评论。') } }) }, // 对某条评论进行评论 // 即二级评论 replyTo(comment) { this.parentID = comment.id; this.placeholder = '对' + comment.author.username + '说:' }, }, computed: { // 格式化时间 formatted_time() { return timeString => new Date(timeString).toLocaleDateString() } } } </script> <style scoped> button { cursor: pointer; border: none; outline: none; color: whitesmoke; border-radius: 5px; } .submitBtn { height: 35px; background: steelblue; width: 60px; } .commentBtn { height: 25px; background: lightslategray; width: 40px; } .comments { padding-top: 10px; } .username { font-weight: bold; color: darkorange; } .created { font-weight: bold; color: darkblue; } .parent { font-weight: bold; color: orangered; } .content { font-size: large; padding: 15px; } </style>
11.2 添加发布评论功能 修改 ArticleDetail.vue
,使用 Comments
组件 :
<!-- frontend/src/views/ArticleDetail.vue --> <template> <blog-header /> <div v-if="article !== null" class="grid-container"> <div> <h1 id="title">{{ article.title }}</h1> <p id="subtitle">本文由 {{ article.author.username }} 发布于 {{ formatted_time }} <span if="isSuperuser"> <router-link :to="{ name: 'ArticleEdit', params: { id: article.id } }">更新与删除</router-link> </span> </p> <div v-html="article.body_html" class="article-body"></div> </div> <div> <h3>目录</h3> <div v-html="article.toc_html" class="toc"></div> </div> </div> <comments :article="article" /> <blog-footer /> </template> <script> import BlogHeader from '@/components/BlogHeader.vue' import BlogFooter from '@/components/BlogFooter.vue' import Comments from '@/components/Comments.vue' import axios from 'axios' export default { name: 'ArticleDetail', components: { BlogHeader, BlogFooter, Comments }, data() { return { article: null } }, mounted() { axios.get('/api/article/' + this.$route.params.id).then(response => { this.article = response.data }).catch(error => { console.log(error) }) }, computed: { formatted_time() { return new Date(this.article.created).toLocaleDateString() }, isSuperuser() { return localStorage.getItem('isSuperuser.myblog') === 'true' } } } </script> <style scoped> .grid-container { display: grid; grid-template-columns: 3fr 1fr; } #title { text-align: center; font-size: x-large; } #subtitle { text-align: center; color: gray; font-size: small; } </style> <style> .article-body p img { max-width: 100%; border-radius: 50px; box-shadow: gray 0 0 20px; } .toc ul { list-style-type: none; } .toc a { color: gray; } </style>
12. 组合式API 12.1 什么是组合式API 之前我们用的是选项式API方式编写Vue组件,Vue还提供组合式API方式编写组件。
官方文档 解释总结:
选项式API能够很好的胜任任何中小大型项目,但是对于超大型项目(几百个以上的组件)有天生的缺陷,最显著的矛盾是逻辑关注点分离 :你可能很难短时间分清哪些方法在操作哪些数据、哪些变量又被哪些组件所更改了。 组合式 API 将相同逻辑关注点代码聚合在了一起,并且很自然的支持代码复用。 12.2 编写组合式API 将文章列表页面 ArticleList.vue
的选项式API 改为组合式API ,本部分的所有修改只涉及到 ArticleList.vue
的 Javascript 脚本部分,因此先把原代码贴出来:
<!-- frontend/src/components/ArticleList.vue --> <script> import axios from 'axios' export default { name: 'ArticleList', data() { return { info: '', } }, mounted() { this.get_article_data() }, methods: { // 判断标题图是否存在并返回图像 imageIfExists(article) { if (article.avatar) { return article.avatar.content } }, // 修改grid样式 gridStyle(article) { if (article.avatar) { return { display: 'grid', gridTemplateColumns: '1fr 7fr' } } }, // 判断页面是否存在 is_page_exists(direction) { if (direction === 'next') { return this.info.next !== null } return this.info.previous !== null }, // 获取页码 get_page_param(direction) { try { let url_string switch (direction) { case 'next': url_string = this.info.next break case 'previous': url_string = this.info.previous break default: if (!('page' in this.$route.query)) { return 1 } if (this.$route.query.page === null) { return 1 } return this.$route.query.page } const url = new URL(url_string) return url.searchParams.get('page') } catch (err) { return } }, // 获取文章列表数据 get_article_data() { let url = '/api/article/' let params = new URLSearchParams() if (this.isExists(this.$route.query.page)) { params.append('page', this.$route.query.page) } if (this.isExists(this.$route.query.search)) { params.append('search', this.$route.query.search) } const paramsString = params.toString() if (paramsString.charAt(0) !== '') { url += '/?' + paramsString } axios.get(url).then(response => { this.info = response.data }).catch(error => { console.log(error) }) }, // 获取路径 get_path(direction) { let url = '' try { switch (direction) { case 'next': if (this.info.next !== undefined) { url += (new URL(this.info.next)).search } break case 'previous': if (this.info.previous !== undefined) { url += (new URL(this.info.previous)).search } break } } catch { return url } return url }, // 检查参数是否存在 isExists(value) { return value !== null && value !== undefined } }, watch: { // 监听路由变化 $route() { this.get_article_data() } }, computed: { // 格式化时间 formatted_time() { return timeString => new Date(timeString).toLocaleDateString() } } } </script>
本文标题: Django-Vue搭建个人博客(4):前端功能完善 原文链接: https://www.dusaiphoto.com/article/122/ ~ https://www.dusaiphoto.com/article/122/ 原文作者: 杜赛 许可协议: 署名-非商业性使用 4.0 国际许可协议 本文对原始作品作了修改,转载请保留原文链接及作者