码上问学问代码之理,学编程之道

第 11 章 · 项目前端实战

这是课程三的收口。前面 10 章学的是零件——Vite、模板语法、computed、组件、路由、Pinia、axios、Element Plus。这一章把它们装成一台能用的机器:按《API 接口文档》把「古典文学网」的四大模块(首页 / 赏文学 / 书评 / 用户中心)全部做出来,跑通 浏览 → 详情 → 写书评 → 点赞 → 收藏 → 个人中心 这条完整链路,拿下里程碑 M5。

总览:课程三的能力合体

四大模块,18 个接口
页面 ↔ 接口对照:18 个接口按模块各就各位,联调就是逐格打钩鉴权要求:首页与列表/详情 6 个接口公开可访问;书评与用户模块 12 个接口需要登录(带 token)首页banner 列表 · 分类列表 · 推荐作品3个列表/详情作品列表 · 详情 · 浏览+13个书评列表 · 发表 · 点赞 · 删除4个用户注册 · 登录 · 资料 · 改资料 · 头像 · 收藏6个联调方法:一个模块一个模块打通——先公开接口(无 token),再登录态接口,最后权限细节
先讲为什么单独看每一章,你学会的都只是「一个零件」:路由能换页、Pinia 能存状态、axios 能发请求、Element Plus 能画界面。但真实项目里这些零件是长在一起的——用户在首页点了一个分类,路由要跟着变(Router)、筛选条件要记住(Pinia + query)、接口要重新请求(axios)、列表要重画(Element Plus)。
这一章的任务,就是把它们串成一条流水线。串通了,你对前端的理解才算真正立起来。

先把「古典文学网」的前端全貌摊开。四大模块、18 个接口(见 《古典文学网 · 后端 API 接口文档》),对应的页面文件如下。

hivue/src 目录(本项目最终形态)text
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       # 用户中心(资料 / 我的收藏)
效果预览运行结果
运行结果: # 每个 api/*.js 里的函数,一对一对应接口文档里的一行 # 页面只调用 api 层函数,不在组件里直接写 axios.get —— 这是可分层的底线
避坑新手最容易犯的错,是在组件里直接写 axios.get('/api/article/list')。一开始很爽,等到接口路径改了、要加统一参数、要 Mock 数据时,你就得满项目搜索替换。多花十分钟抽一层 api/,后面省下的是几十次改动。

首页模块:轮播 + 分类 + 推荐

3 个公开接口,不需要登录
动手做先把首页的三个接口函数写进 api/home.js,再用 Promise.all 一次性并发取回三份数据——它们互不依赖,串行等就是白等。
src/api/home.jsjs
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 } })
页面效果浏览器运行结果
运行结果: # request 的 baseURL 是 '/api',所以这里只写子路径 # 实际请求:GET /api/banner/list 、GET /api/category/list 、GET /api/article/recommend?limit=6 # 拦截器已把 { code, msg, data } 拆开,调用方拿到的直接就是 data
src/views/Home.vuevue
<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>
页面效果组件渲染结果
运行结果: 浏览器显示首页: [搜索作品或作者… ][🔍] ┌──────────────────────────────────────┐ │ 品读古典文学,涵养中国心灵 │ ← 4 秒自动切换 └──────────────────────────────────────┘ 唐诗 宋词 元曲 散文 小说 编辑推荐 [念奴娇·赤壁怀古] [静夜思] [春望] [陋室铭] … 点「唐诗」→ 地址栏变为 /article?category_id=1,列表页只显示唐诗
讲透
  • 为什么用 Promise.all:三个接口各 100ms,串行写要 300ms,并发只要 100ms。只要请求之间「没有依赖」,就并发。
  • 为什么筛选条件走路由 query 而不是 Pinia:/article?category_id=1 这样的地址可以直接分享、可以刷新还原、可以前进后退。用 Pinia 存,刷新就没了,用户按返回键也回不到上一个筛选。
  • ArticleCard 为什么抽成组件:首页的推荐、列表页的结果、用户中心的「我的收藏」都要展示作品卡片。抽成组件改一次全站生效,这就是第 6 章组件化的价值。

赏文学:列表页

分类筛选 + 关键词搜索 + 排序 + 分页
先讲为什么列表页有一个典型难点:它有四种输入来源——用户点分类、用户输关键词、用户改排序、用户翻页。如果每个来源各写一套请求逻辑,很快就会乱。
正确做法是把它们全部收敛到一个「请求参数对象」里,任何一个变了,都走同一个 loadList()。同时,这个参数对象要和 URL 的 query 保持同步——这样用户刷新、分享、后退,看到的都还是同一批结果。
动手做列表页接口是 GET /api/article/list,六个查询参数:page、page_size、category_id、keyword、dynasty、order_by。先写 api 函数(空值不要发给后端)。
src/api/article.jsjs
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 })
页面效果浏览器运行结果
运行结果: # 传入 { page:1, page_size:6, category_id:1, keyword:'', dynasty:null } # 实际请求:GET /api/article/list?page=1&page_size=6&category_id=1 # 返回 { code:200, msg:"success", data:{ list:[...], total:3, page:1, page_size:6 } }
src/views/ArticleList.vue(核心)vue
<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>
页面效果组件渲染结果
运行结果: 浏览器显示列表页: (•)全部 ( )唐诗 ( )宋词 ( )元曲 ( )散文 ( )小说 [最多浏览 ▾] [搜索作品] ┌────────┐ ┌────────┐ ┌────────┐ │ 静夜思 │ │ 春望 │ │ 陋室铭 │ ← 选「唐诗」后只剩 3 张 └────────┘ └────────┘ └────────┘ 共 3 条 [6 条/页 ▾] ‹ 1 › 地址栏同步变为 /article?category_id=1&order_by=hot
避坑两个坑,都跟「同一份数据改了两遍」有关:
① 用 watch(query, loadList) 监听参数,又在工具栏的 @change 里手动调 loadList(),结果一次操作发两个相同请求。二选一,别都写。
② loadList 里用 router.push 把条件写回地址栏,而地址栏变化又会触发请求——形成环路。所以这里用 router.replace,且回写时不要再触发一次请求。

赏文学:详情页

正文 + 书评 + 点赞 + 收藏
动手做详情页从地址栏拿 :id,调 GET /api/article/detail/<id>。这个接口很特别:登录和不登录,返回的字段不完全一样——带了 token 才会回填 is_favorite / is_liked,前端靠它俩决定按钮是高亮还是灰色。
src/views/ArticleDetail.vue(正文 + 点赞 + 收藏)vue
<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>
页面效果组件渲染结果
运行结果: 浏览器显示详情页: 静夜思 李白 · 唐 · 唐诗 床前明月光,疑是地上霜。举头望明月,低头思故乡。 [☆ 点赞 42] [☆ 收藏] 未登录点「点赞」→ 黄色提示「请先登录」,跳到 /login?redirect=/article/1 登录后点「点赞」→ 按钮变蓝「已赞 43」;再点一次 → 变回「点赞 42」
讲透
  • 点赞 / 收藏都是「toggle」语义:同一个接口,调一次加、再调一次取消,返回值告诉你现在是哪个状态。所以前端不要自己猜状态,一定要用接口返回的 liked / favorited 去更新界面——猜测在并发点击时会出错。
  • redirect 参数是登录页的礼仪:用户从哪来,登录完回哪去。登录成功后执行 router.replace(route.query.redirect || '/'),体验立刻不一样。
  • 正文里的 {{ article.content }} 是纯文本插值,默认会做 HTML 转义,不会执行脚本——这是 Vue 的安全设计。除非数据完全可信,否则不要用 v-html。

书评模块

发表 / 列表 / 点赞 / 删除自己的
动手做书评的四个接口:列表(公开)、发表(登录)、点赞(登录)、删除(登录,且只能删自己的)。先写 api 层。
src/api/comment.jsjs
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}`)
页面效果浏览器运行结果
运行结果: # getCommentList({ article_id:1, page:1, page_size:10 }) # → GET /api/comment/list?article_id=1&page=1&page_size=10 # deleteComment(9) → DELETE /api/comment/9
src/components/CommentSection.vuevue
<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>
页面效果组件渲染结果
运行结果: 书评(2) [写下你的读后感… 0/1000] [发表书评] [头像] 管理员 短短二十字,写尽思乡之情。 赞 5 删除 └ 青莲居士:同感! [头像] 青莲居士 李白的月亮永远挂在游子心里。 赞 2 点「删除」→ 确认框 → 确定 → 书评消失,标题计数变成「书评(1)」 未登录点「赞」→ 黄色提示「请先登录」
避坑「删除」按钮一定要用 c.user.id === userStore.userInfo?.id 先在前端藏起来,但同时也必须接受「后端才是最终权威」——接口文档写得很清楚,删别人的书评后端会返回 {"code":403,"msg":"无权删除他人书评"}。前端隐藏只是体验,后端校验才是安全;反过来,只靠前端隐藏、后端不校验,等于没做权限。

用户中心

资料 / 改资料 / 头像 / 我的收藏
动手做用户中心把「改资料」「换头像」「我的收藏」放在同一个页面,用 el-tabs 切换。数据来自 GET /api/user/profile(注意里面还带了 stat 统计)。
src/views/UserCenter.vuevue
<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>
页面效果组件渲染结果
运行结果: 浏览器显示用户中心: (●头像) 张三 @zhangsan 书评 5 条 · 收藏 12 篇 ┌ 个人资料 ┬ 我的收藏 ┐ 昵称 [张三丰 ] 简介 [爱读唐诗宋词 ] [保存] 点「我的收藏」→ 才发起 GET /api/favorite/list?page=1&page_size=12
讲透
  • 改资料用 PUT,只传要改的字段(接口文档 2.4)。后端是「传什么改什么」,所以别把整个 profile 塞回去——那样会把 avatar、stat 这些不该改的字段一起发过去。
  • 「我的收藏」返回的不是完整作品对象,只有 article_id / title / author / category_name / created_at。所以卡片组件要能兼容两种数据形状,或者在这里把 article_id 映射成卡片认识的 id。
  • 改了昵称要同步 Pinia:顶栏显示的是 userStore.userInfo.nickname,不更新的话会出现「页面里改了、右上角还是旧的」。

路由与布局规划

嵌套路由 + 登录守卫 + 懒加载
路由与布局规划:哪些页面进外壳、哪些全屏、哪些要登录带外壳(App 布局:顶栏+底栏)/ 首页/articles 列表/article/:id 详情/user/* 用户中心(需登录)全屏(无外壳)/login 登录/register 注册404 兜底页登录注册页不该顶着全站导航——用户正专注于输入凭据守卫规则:requiresAuth 的路由没 token → 踢去 /login 并记录来路;懒加载让首屏只下载首页的代码
先讲为什么「用户中心要登录才能看」这件事,如果放在页面里判断,会有个尴尬的瞬间:页面先闪出来、再跳走。正确的位置是路由守卫——在导航真正发生之前就拦下来,用户根本看不到那个页面。
另外首页、列表、详情这些「前台页面」共用同一个顶栏,用户中心是其中的一个子页面——这就是嵌套路由:父路由负责壳,子路由负责内容。
动手做写路由表:前台统一挂在 BasicLayout 下,/user 需要登录,非登录用户访问会被带去登录页并记住回来的地址。
src/router/index.jsjs
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
页面效果浏览器运行结果
运行结果: # 未登录直接访问 /user # → 地址变成 /login?redirect=/user,页面不会闪出用户中心 # 登录成功后 → 自动回到 /user # 直接访问 /not-exist → 重定向回首页
避坑守卫里有三个新手坑:① 在 router/index.js 的顶层写 const userStore = useUserStore()——那时 Pinia 还没装到 app 上,直接报错。要在 beforeEach 内部调用。② 忘记 return true,所有路由都被拦死,页面一片空白。③ 用 createWebHistory 却没配 Nginx 的 try_files,刷新页面就 404——第 12 章专门讲这个。

与后端联调:页面 ↔ 接口对照

18 个接口逐个对上号
动手做把「哪个页面在什么时候调哪个接口」列成一张表,逐个点一遍。这张表就是你的联调清单,也是出问题时定位的依据。
联调清单(页面 ↔ 接口)text
页面 / 动作                        接口                                    触发时机
--------------------------------  --------------------------------------  ------------------------
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                   点「退出」【登录】
效果预览运行结果
运行结果: # 18 行,正好对应接口文档「1.7 接口总览」的 18 个接口 # 带【登录】的 11 个接口,在 F12 Network 里检查请求头必须含 Authorization: Bearer xxx

联调时最常出问题的是「401」。把它收敛到响应拦截器里统一处理,页面就再也不用关心 token 过期这件事了。

src/utils/request.js(拦截器核心)js
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
页面效果浏览器运行结果
运行结果: # token 有效:调用方 await getUserProfile() 直接拿到 data 对象 # token 过期:接口返回 {"code":401,"msg":"登录已过期,请重新登录","data":null} # → 自动清 token、弹红字提示、跳登录页,三个页面全站生效,不用各写一遍
避坑后端约定「HTTP 状态码统一返回 200,业务成败看 code」。所以拦截器的成功分支里还要再判一次 code——千万不要直接 return res.data 就完事,那样 400 错误也会被当成成功走一遍,页面拿到的是 {code:400,msg:'...'} 而不是数据,然后到处报「undefined 上取属性」。
反过来,如果后端某天改成「401 用真实 HTTP 状态码返回」,那么它就会落到 err 分支(err.response.status === 401)——联调前先问清楚这套约定,比写完再改便宜得多。

里程碑 M5:业务闭环

一条链路走到底
先讲为什么单个页面能跑,不等于项目能用。真正的验收标准是一条完整的用户旅程:一个陌生人打开网站,能一路走到「我的收藏」里看到自己刚收藏的那篇作品。这条路上每一个环节都用到不同的接口、不同的状态、不同的页面——能走通,说明你的前端是「一台机器」,而不是「一堆零件」。

把这条链路写在纸上,逐个环节验证。任何一步断了,就顺着那张「页面 ↔ 接口对照表」往回查:是没发请求?发了但参数不对?还是返回了但没渲染?

M5 验收链路(闭环)text
① 浏览    访问 /  →  看到轮播图、分类、推荐作品            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 达成
效果预览运行结果
运行结果: # 全程 F12 Network 里 18 个接口按链路依次被调用,无 4xx/5xx # 每个【登录】请求的 Headers 里都有 Authorization: Bearer eyJ... # 刷新任意页面(含 /article/1 与 /user)都正常还原,不出现 404 与白屏
讲透:每一步的「状态」住在哪
  • token 与登录用户 → Pinia + localStorage,跨页面、跨刷新都要在。
  • 筛选条件(分类 / 关键词 / 页码) → 路由 query,能分享能刷新能后退。
  • 列表数据、书评数据 → 组件内部的 ref,离开页面就丢,不需要共享。
  • 点赞 / 收藏的当前状态 → 以后端返回值为准(liked / favorited),前端只负责显示。

想清楚「每个状态该住在哪」,是这一章最值钱的一句话。放错地方,就会出现各种玄学 bug:刷新就退出登录、返回上一页筛选条件丢失、点赞数点了变点了又变。

动手练习
1给列表页再加一个「朝代」筛选(接口支持 dynasty 参数,可选 唐 / 宋 / 元)。要求:切换朝代时页码回到第 1 页,且条件同步到地址栏。
2实现「楼中楼回复」:在每条顶层书评下面加一个「回复」按钮,点开后弹出输入框,提交时把 parent_id 设为该条书评的 id,提交成功后刷新列表,回复出现在 children 里。
3迁移练习:「红影赏析」要做电影详情页,包含「电影简介 + 影评列表 + 点赞 + 收藏」。请说明:可以复用本项目里的哪几个组件?哪些地方必须改?is_liked / is_favorite 这类字段的目的是什么?
参考答案
练习答案:朝代筛选 + 楼中楼回复vue
<!-- 第 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 的目的:让前端「知道自己当前处于哪个状态」,
   从而决定按钮是灰色还是高亮。没有它们,前端只能靠本地猜,
   一刷新就丢,多端登录还会不一致。 */
页面效果组件渲染结果
运行结果: # 第 1 题:点「宋」→ 列表只剩《念奴娇·赤壁怀古》《声声慢》,地址栏 ?dynasty=宋 # 第 2 题:点「回复」→ 输入「同感!」→ 提交 # → POST /api/comment/add { "article_id":1, "content":"同感!", "parent_id":1 } # → 列表刷新,1 号书评下方出现 children:「青莲居士:同感!」 # 第 3 题:结论 —— 页面结构照搬,换的是「接口路径 + 字段名」,骨架不变

第 3 题的关键不在于记字段,而在于理解「点赞/收藏状态为什么必须由后端给」:它是带用户身份的持久状态,前端内存里的布尔值扛不住刷新和多端。

本章小结