这是课程三的收口。前面 10 章学的是零件——Vite、模板语法、computed、组件、路由、Pinia、axios、Element Plus。这一章把它们装成一台能用的机器:按《API 接口文档》把「古典文学网」的四大模块(首页 / 赏文学 / 书评 / 用户中心)全部做出来,跑通 浏览 → 详情 → 写书评 → 点赞 → 收藏 → 个人中心 这条完整链路,拿下里程碑 M5。
先把「古典文学网」的前端全貌摊开。四大模块、18 个接口(见 《古典文学网 · 后端 API 接口文档》),对应的页面文件如下。
src/
├── main.js # 挂载 app:router + pinia + ElementPlus
├── App.vue # 只有一行 <router-view />
├── router/index.js # 路由表 + 登录守卫
├── stores/user.js # token / userInfo / isLogin / login / logout
├── utils/request.js # axios 封装:带 token、统一判 code
├── api/
│ ├── user.js # 用户 6 个接口
│ ├── home.js # 轮播 / 分类 / 推荐 3 个接口
│ ├── article.js # 作品 3 个接口
│ ├── comment.js # 书评 4 个接口
│ └── favorite.js # 收藏 2 个接口
├── layouts/BasicLayout.vue # 前台壳:顶栏 + <router-view />
├── components/
│ ├── ArticleCard.vue # 作品卡片(首页 / 列表 / 收藏共用)
│ └── AvatarUpload.vue # 头像上传(第 10 章写过)
└── views/
├── Home.vue # 首页
├── ArticleList.vue # 赏文学 · 列表
├── ArticleDetail.vue # 赏文学 · 详情
├── Login.vue # 登录 / 注册
└── UserCenter.vue # 用户中心(资料 / 我的收藏)
import request from '@/utils/request'
// 首页三个接口都不需要登录,路径严格对齐接口文档「三、首页模块」
export const getBannerList = () => request.get('/banner/list')
export const getCategoryList = () => request.get('/category/list')
export const getRecommend = (limit = 6) =>
request.get('/article/recommend', { params: { limit } })
<template>
<div class="home">
<!-- ① 搜索入口:回车跳到列表页并带上关键词 -->
<div class="search-bar">
<el-input v-model="keyword" placeholder="搜索作品或作者…" size="large" clearable @keyup.enter="goSearch">
<template #append>
<el-button :icon="Search" @click="goSearch" />
</template>
</el-input>
</div>
<!-- ② 轮播图 -->
<el-carousel height="320px" :interval="4000" arrow="always">
<el-carousel-item v-for="b in banners" :key="b.id">
<div class="banner" :title="b.title">{{ b.title }}</div>
</el-carousel-item>
</el-carousel>
<!-- ③ 分类导航:点一下带 category_id 去列表页 -->
<div class="cats">
<span v-for="c in categories" :key="c.id" class="cat" @click="goCategory(c.id)">
{{ c.name }}
</span>
</div>
<!-- ④ 推荐作品:复用 ArticleCard 组件 -->
<h2 class="sec-title">编辑推荐</h2>
<div class="card-grid">
<ArticleCard v-for="a in recommends" :key="a.id" :article="a" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getBannerList, getCategoryList, getRecommend } from '@/api/home'
import ArticleCard from '@/components/ArticleCard.vue'
const router = useRouter()
const keyword = ref('')
const banners = ref([])
const categories = ref([])
const recommends = ref([])
onMounted(async () => {
// 三个请求互不依赖,并发发出,总耗时 = 最慢的那个
const [b, c, r] = await Promise.all([
getBannerList(),
getCategoryList(),
getRecommend(6),
])
banners.value = b
categories.value = c
recommends.value = r
})
const goSearch = () => router.push({ path: '/article', query: { keyword: keyword.value } })
const goCategory = (id) => router.push({ path: '/article', query: { category_id: id } })
</script>
import request from '@/utils/request'
// 作品列表:分页 / 筛选 / 搜索 / 排序
// 关键点:把 page=1(第 1 页)之外的默认值、以及空的筛选条件统统删掉,
// 否则会发出 ?category_id=&keyword= 这种无效参数
export function getArticleList(params = {}) {
const query = { ...params }
Object.keys(query).forEach((k) => {
if (query[k] === '' || query[k] === null || query[k] === undefined) delete query[k]
})
return request.get('/article/list', { params: query })
}
// 作品详情:每次访问后端会 views + 1,带上 token 时还会回填 is_favorite / is_liked
export const getArticleDetail = (id) => request.get(`/article/detail/${id}`)
// 点赞作品:同一用户重复点赞 = 取消点赞
export const likeArticle = (article_id) =>
request.post('/article/like', { article_id })
<template>
<div class="list-page">
<!-- 工具栏:分类 / 排序 / 搜索,全都改 query,再由 watch 触发请求 -->
<div class="toolbar">
<el-radio-group v-model="query.category_id" @change="resetPage">
<el-radio-button :value="null">全部</el-radio-button>
<el-radio-button v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</el-radio-button>
</el-radio-group>
<el-select v-model="query.order_by" style="width:130px" @change="resetPage">
<el-option label="最新发布" value="latest" />
<el-option label="最多浏览" value="hot" />
<el-option label="最多点赞" value="likes" />
</el-select>
<el-input v-model="query.keyword" placeholder="搜索作品" clearable style="width:200px" @keyup.enter="resetPage" />
</div>
<!-- 结果区 -->
<div v-loading="loading" class="card-grid">
<ArticleCard v-for="a in list" :key="a.id" :article="a" />
</div>
<el-empty v-if="!loading && !list.length" description="没有找到相关作品" />
<!-- 分页 -->
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[6, 12, 24]"
layout="total, sizes, prev, pager, next"
background
@current-change="loadList"
@size-change="resetPage"
/>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getArticleList } from '@/api/article'
import { getCategoryList } from '@/api/home'
import ArticleCard from '@/components/ArticleCard.vue'
const route = useRoute()
const router = useRouter()
const categories = ref([])
const list = ref([])
const total = ref(0)
const loading = ref(false)
// 唯一的请求参数真相源
const query = reactive({
page: Number(route.query.page) || 1,
page_size: 6,
category_id: route.query.category_id ? Number(route.query.category_id) : null,
keyword: route.query.keyword || '',
order_by: route.query.order_by || 'latest',
})
async function loadList() {
loading.value = true
try {
const data = await getArticleList(query)
list.value = data.list
total.value = data.total
// 把筛选条件写回地址栏,但不新增历史记录
router.replace({ query: { ...route.query, ...clean(query) } })
} finally {
loading.value = false
}
}
// 只要 query 变了就重新请求(分页的两个 v-model 也会触发)
watch(() => ({ ...query }), loadList, { deep: true })
function resetPage() {
query.page = 1 // 改筛选条件必须回到第 1 页
loadList()
}
onMounted(async () => {
categories.value = await getCategoryList()
loadList()
})
</script>
<template>
<div v-loading="loading" class="detail">
<h1>{{ article.title }}</h1>
<p class="meta">{{ article.author }} · {{ article.dynasty }} · {{ article.category_name }}</p>
<div class="content">{{ article.content }}</div>
<div class="actions">
<el-button :type="article.is_liked ? 'primary' : 'default'" :icon="StarFilled" @click="onLike">
{{ article.is_liked ? '已赞' : '点赞' }} {{ article.likes }}
</el-button>
<el-button :type="article.is_favorite ? 'warning' : 'default'" :icon="Collection" @click="onFavorite">
{{ article.is_favorite ? '已收藏' : '收藏' }}
</el-button>
</div>
<!-- 书评区交给书评组件,见下一节 -->
<CommentSection :article-id="article.id" @count-change="(n) => (article.comment_count = n)" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { StarFilled, Collection } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getArticleDetail, likeArticle } from '@/api/article'
import { toggleFavorite } from '@/api/favorite'
import { useUserStore } from '@/stores/user'
import CommentSection from '@/components/CommentSection.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const article = ref({})
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
article.value = await getArticleDetail(route.params.id)
} finally {
loading.value = false
}
})
// 未登录的通用处理:提示一句,带着「回来时的地址」去登录页
function needLogin() {
if (userStore.isLogin) return false
ElMessage.warning('请先登录')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return true
}
async function onLike() {
if (needLogin()) return
const data = await likeArticle(article.value.id) // { liked, likes }
article.value.is_liked = data.liked
article.value.likes = data.likes
}
async function onFavorite() {
if (needLogin()) return
const data = await toggleFavorite(article.value.id) // { favorited }
article.value.is_favorite = data.favorited
ElMessage.success(data.favorited ? '已加入收藏' : '已取消收藏')
}
</script>
import request from '@/utils/request'
// 书评列表:只统计顶层书评,回复在各自的 children 里
export const getCommentList = (params) => request.get('/comment/list', { params })
// 发表书评:parent_id 不为空时就是「回复某条书评」
export const addComment = (data) => request.post('/comment/add', data)
export const likeComment = (comment_id) =>
request.post('/comment/like', { comment_id })
// 删除书评:DELETE /api/comment/<id>,只能删自己的
export const deleteComment = (id) => request.delete(`/comment/${id}`)
<template>
<section class="comments">
<h3>书评({{ total }})</h3>
<!-- 发表区:登录才显示输入框 -->
<div v-if="userStore.isLogin" class="editor">
<el-input v-model="content" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="写下你的读后感…" />
<el-button type="primary" :loading="posting" :disabled="!content.trim()" @click="submit">发表书评</el-button>
</div>
<el-alert v-else type="info" :closable="false" title="登录后即可发表书评" />
<!-- 列表区 -->
<ul class="comment-list">
<li v-for="c in list" :key="c.id">
<img :src="c.user.avatar || defaultAvatar" class="av" />
<div class="body">
<b>{{ c.user.nickname }}</b>
<p>{{ c.content }}</p>
<div class="ops">
<span @click="onLike(c)">赞 {{ c.likes }}</span>
<span v-if="c.user.id === userStore.userInfo?.id" @click="onDelete(c)">删除</span>
</div>
<!-- 楼中楼:children -->
<ul v-if="c.children?.length" class="children">
<li v-for="r in c.children" :key="r.id">{{ r.user.nickname }}:{{ r.content }}</li>
</ul>
</div>
</li>
</ul>
<el-empty v-if="!list.length" description="还没有书评,来写第一条吧" />
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCommentList, addComment, likeComment, deleteComment } from '@/api/comment'
import { useUserStore } from '@/stores/user'
const props = defineProps({ articleId: { type: [Number, String], required: true } })
const emit = defineEmits(['count-change'])
const userStore = useUserStore()
const list = ref([])
const total = ref(0)
const content = ref('')
const posting = ref(false)
const defaultAvatar = ''
async function load(pullToLast = false) {
const data = await getCommentList({ article_id: props.articleId, page: 1, page_size: 10 })
list.value = pullToLast ? [...data.list, ...list.value] : data.list
total.value = data.total
emit('count-change', data.total)
}
async function submit() {
posting.value = true
try {
await addComment({ article_id: Number(props.articleId), content: content.value.trim(), parent_id: null })
content.value = ''
ElMessage.success('发表成功')
await load()
} finally {
posting.value = false
}
}
async function onLike(c) {
if (!userStore.isLogin) return ElMessage.warning('请先登录')
const data = await likeComment(c.id) // { liked, likes }
c.likes = data.likes
}
async function onDelete(c) {
try {
await ElMessageBox.confirm('确定删除这条书评吗?', '提示', { type: 'warning' })
} catch {
return // 用户取消
}
await deleteComment(c.id)
ElMessage.success('已删除')
await load()
}
onMounted(load)
</script>
<template>
<div class="uc">
<el-card>
<div class="profile-head">
<AvatarUpload />
<div>
<h3>{{ profile.nickname }}</h3>
<p class="mono">@{{ profile.username }}</p>
<p>书评 {{ profile.stat?.comment_count ?? 0 }} 条 · 收藏 {{ profile.stat?.favorite_count ?? 0 }} 篇</p>
</div>
</div>
</el-card>
<el-tabs v-model="tab">
<!-- 资料修改 -->
<el-tab-pane label="个人资料" name="info">
<el-form :model="form" label-width="80px" style="max-width:460px">
<el-form-item label="昵称"><el-input v-model="form.nickname" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="简介"><el-input v-model="form.intro" type="textarea" /></el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.gender">
<el-radio :value="1">男</el-radio>
<el-radio :value="2">女</el-radio>
<el-radio :value="0">保密</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item><el-button type="primary" :loading="saving" @click="save">保存</el-button></el-form-item>
</el-form>
</el-tab-pane>
<!-- 我的收藏 -->
<el-tab-pane label="我的收藏" name="fav">
<div class="card-grid">
<ArticleCard v-for="f in favorites" :key="f.article_id" :article="f" />
</div>
<el-empty v-if="!favorites.length" description="还没有收藏的作品" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getUserProfile, updateProfile } from '@/api/user'
import { getFavoriteList } from '@/api/favorite'
import { useUserStore } from '@/stores/user'
import ArticleCard from '@/components/ArticleCard.vue'
import AvatarUpload from '@/components/AvatarUpload.vue'
const userStore = useUserStore()
const profile = ref({})
const favorites = ref([])
const tab = ref('info')
const saving = ref(false)
const form = reactive({ nickname: '', email: '', intro: '', gender: 0 })
onMounted(async () => {
profile.value = await getUserProfile() // GET /api/user/profile
Object.assign(form, {
nickname: profile.value.nickname || '',
email: profile.value.email || '',
intro: profile.value.intro || '',
gender: profile.value.gender ?? 0,
})
})
// 切到「我的收藏」时才去拉数据,别在进页面时就白请求一次
watch(tab, async (v) => {
if (v !== 'fav') return
const data = await getFavoriteList({ page: 1, page_size: 12 })
favorites.value = data.list
})
async function save() {
saving.value = true
try {
await updateProfile({ nickname: form.nickname, email: form.email, intro: form.intro, gender: form.gender })
userStore.userInfo.nickname = form.nickname // 同步顶栏显示的昵称
ElMessage.success('修改成功')
} finally {
saving.value = false
}
}
</script>
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/',
component: () => import('@/layouts/BasicLayout.vue'), // 前台壳:顶栏 + <router-view />
children: [
{ path: '', name: 'home', component: () => import('@/views/Home.vue') },
{ path: 'article', name: 'article-list', component: () => import('@/views/ArticleList.vue') },
{ path: 'article/:id', name: 'article-detail', component: () => import('@/views/ArticleDetail.vue') },
{
path: 'user',
name: 'user-center',
component: () => import('@/views/UserCenter.vue'),
meta: { requiresAuth: true }, // 只有这一页需要登录
},
],
},
{ path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' }, // 兜底:访问不存在的地址回首页
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 }), // 换页自动回到顶部
})
// 全局前置守卫:比页面渲染更早执行
router.beforeEach((to) => {
const userStore = useUserStore() // 必须在守卫内部调用,此时 pinia 已安装
if (to.meta.requiresAuth && !userStore.isLogin) {
return { path: '/login', query: { redirect: to.fullPath } }
}
return true // 放行
})
export default router
页面 / 动作 接口 触发时机
-------------------------------- -------------------------------------- ------------------------
Home.vue 轮播图 GET /api/banner/list 进入首页
Home.vue 分类导航 GET /api/category/list 进入首页
Home.vue 推荐作品 GET /api/article/recommend?limit=6 进入首页
ArticleList.vue 列表 GET /api/article/list 进入/改筛选/翻页
ArticleDetail.vue 正文 GET /api/article/detail/<id> 进入详情
ArticleDetail.vue 点赞作品 POST /api/article/like 点「点赞」【登录】
CommentSection 书评列表 GET /api/comment/list 进入详情
CommentSection 发表书评 POST /api/comment/add 点「发表」【登录】
CommentSection 点赞书评 POST /api/comment/like 点「赞」【登录】
CommentSection 删除书评 DELETE /api/comment/<id> 点「删除」【登录】
ArticleDetail.vue 收藏切换 POST /api/favorite/toggle 点「收藏」【登录】
UserCenter.vue 我的收藏 GET /api/favorite/list 切到收藏页签【登录】
UserCenter.vue 个人信息 GET /api/user/profile 进入用户中心【登录】
UserCenter.vue 改资料 PUT /api/user/profile 点「保存」【登录】
AvatarUpload.vue 上传头像 POST /api/user/avatar 选完文件【登录】
Login.vue 登录 POST /api/user/login 点「登录」
Login.vue 注册 POST /api/user/register 点「注册」
BasicLayout.vue 退出 POST /api/user/logout 点「退出」【登录】
联调时最常出问题的是「401」。把它收敛到响应拦截器里统一处理,页面就再也不用关心 token 过期这件事了。
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE, // 开发环境 '/api',由 Vite 代理到 Flask
timeout: 10000,
})
// 请求拦截器:每个请求自动带上 token
request.interceptors.request.use((config) => {
const token = useUserStore().token
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 响应拦截器:统一判 code,成功直接给 data,失败统一提示
request.interceptors.response.use(
(res) => {
const { code, msg, data } = res.data
if (code === 200) return data
if (code === 401) {
useUserStore().logout() // 清掉本地 token
ElMessage.error(msg || '登录已过期,请重新登录')
router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
return Promise.reject(new Error(msg))
}
ElMessage.error(msg || '操作失败') // 400 / 403 / 404 / 500 都走这里
return Promise.reject(new Error(msg))
},
(err) => {
ElMessage.error('网络异常,请稍后再试')
return Promise.reject(err)
}
)
export default request
把这条链路写在纸上,逐个环节验证。任何一步断了,就顺着那张「页面 ↔ 接口对照表」往回查:是没发请求?发了但参数不对?还是返回了但没渲染?
① 浏览 访问 / → 看到轮播图、分类、推荐作品 POST/GET 三个首页接口
↓
② 详情 点「静夜思」→ /article/1 → 看到正文,浏览量 +1 GET /api/article/detail/1
↓
③ 写书评 未登录 → 跳 /login?redirect=/article/1 GET /api/user/login 前先注册
登录成功 → 回到 /article/1 → 写「好诗!」→ 发表 POST /api/comment/add
书评立刻出现在列表顶部,计数 +1 GET /api/comment/list
↓
④ 点赞 点作品「点赞」→ 按钮变「已赞 43」 POST /api/article/like
再点一次 → 变回「点赞 42」(toggle 语义) POST /api/article/like
↓
⑤ 收藏 点「收藏」→ 提示「已加入收藏」,按钮变黄 POST /api/favorite/toggle
↓
⑥ 个人中心 点顶栏头像 → /user(守卫放行,因为已登录) GET /api/user/profile
看到「书评 1 条 · 收藏 1 篇」
切「我的收藏」→ 看到「静夜思」 GET /api/favorite/list
改昵称 → 保存 → 顶栏昵称同步变化 PUT /api/user/profile
↓
⑦ 退出 点「退出」→ 清 token → 顶栏变回「登录/注册」 POST /api/user/logout
再访问 /user → 被守卫拦回登录页(闭环完成)
七步全绿 = 里程碑 M5 达成
想清楚「每个状态该住在哪」,是这一章最值钱的一句话。放错地方,就会出现各种玄学 bug:刷新就退出登录、返回上一页筛选条件丢失、点赞数点了变点了又变。
<!-- 第 1 题:朝代筛选(复用已有的 query 与 resetPage) -->
<el-select v-model="query.dynasty" clearable placeholder="全部朝代" style="width:120px" @change="resetPage">
<el-option label="唐" value="唐" />
<el-option label="宋" value="宋" />
<el-option label="元" value="元" />
</el-select>
// 注意 clean(query) 已经会删掉空值,所以「全部朝代」时不会发出 &dynasty=
// 请求变为 GET /api/article/list?page=1&page_size=6&dynasty=宋
// 地址栏同步为 /article?dynasty=宋
// 第 2 题:楼中楼回复
const replyTo = ref(null) // 正在回复的顶层书评
const replyText = ref('')
async function submitReply(parent) {
if (!replyText.value.trim()) return
await addComment({
article_id: Number(props.articleId),
content: replyText.value.trim(),
parent_id: parent.id, // 关键:带上父书评 id
})
replyText.value = ''
replyTo.value = null
await load() // 重新拉列表,新回复出现在 parent.children 里
}
/* 第 3 题:红影赏析的复用与改造
可直接复用:ArticleCard(改字段)、CommentSection(改文案「书评」→「影评」)、
AvatarUpload、utils/request.js、stores/user.js、router 守卫
必须改:api 路径(/api/article → /api/movie)、详情字段(content → intro、
dynasty → genre、rating)、点赞收藏的 target 类型
is_liked / is_favorite 的目的:让前端「知道自己当前处于哪个状态」,
从而决定按钮是灰色还是高亮。没有它们,前端只能靠本地猜,
一刷新就丢,多端登录还会不一致。 */
第 3 题的关键不在于记字段,而在于理解「点赞/收藏状态为什么必须由后端给」:它是带用户身份的持久状态,前端内存里的布尔值扛不住刷新和多端。