1. 准备工作

1.1 安装Axios

虽然现在前后端 Django + Vue 都有了,但还缺一个它们之间通信的手段。Vue 官方推荐的是 axios 这个前端库。

命令行进入 frontend 目录,安装 axios

> npm install axios

1.2 解决跨域

跨域问题是由于浏览器的同源策略(域名,协议,端口均相同)造成的,是浏览器施加的安全限制。即Vue 服务器端口(8080)和 Django 服务器端口(8000)不一致,因此无法通过 Javascript 代码请求后端资源。

解决跨域的方法有两种:

方法一(前端解决):前端配置fronted/vite.config.js文件并写入:

// frontend/vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
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

<!-- frontend/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

// frontend/src/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>

现在博客页面大概是这样子的:

image-20230328201737241

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.pyArticleBaseSerializer,添加一行 id = serializers.IntegerField(read_only=True) ,简单的把文章的 id 值增加到接口数据中。

新建 frontend/src/router/index.js 文件用于存放路由相关的文件,写入:

// 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 实例中:

// frontend/src/main.js

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_htmltoc_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.$routethis.$router ,前者代表路径对象,后者代表路由器对象。

6.2 实现搜索

ArticleList.vue 里进行修改(主要是 Javascript 部分)。

旧的翻页 <router-link> 仅考虑了路径参数中的 page 值。为了在翻页后取得包括 pagesearch 的正确路径,新写一个方法 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中添加注册路由

// 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 中,更深入的对安全的讨论请见 HASURAMDN以及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

// 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/awaitasync 表示函数里含有异步操作,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

// 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 虽然能够解决我们的问题,但总要持有 welcomeNameusername 两个状态,可以使用 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 ,增加返回当前用户是否为超级用户的信息

# user_info/serializers.py

from django.contrib.auth.models import User
from rest_framework import serializers


class 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

# article/views.py

from rest_framework import viewsets, filters

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article, Category, Tag, Avatar
from 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>

注册发布文章页面的路由:

// 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 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>

注册路由:

// 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 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 国际许可协议
本文对原始作品作了修改,转载请保留原文链接及作者