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

阶段 4 · 前端开发

阶段 1 你画了草图,阶段 3 你把接口跑通了,现在把它们接起来——做出一个能点、能用的前端 SPA。这一阶段的核心不是「再学一遍 Vue」,而是三件事:把草图拆成组件、把接口串成数据流、把前后端第一次真正接通(里程碑 M4)。走完它,你就能在浏览器里看到「静夜思」从数据库一路走到页面上的样子。

从页面草图到组件

草图是给人看的,组件是给浏览器跑的
先讲为什么阶段 1 画的草图,是一整块纸:首页上轮播图 + 分类导航 + 推荐作品卡片 ×6 + 页脚全糊在一起。可「作品卡片」在首页要用、在列表页也要用、在「我的收藏」里还要用——你不可能把它复制三遍。
组件化的意义就是:把「会在多处出现的一小块界面」抽出来,起个名字,谁需要谁引用。改一次,三处生效。这就是「从草图到组件」要做的事。
动手做照 hivue 骨架的结构,先建出目录。注意 layouts/(布局)和 views/(页面)是分开的:布局管「外壳」,页面管「内容」,两者不是一回事。
hivue 目录结构text
hivue/
├─ public/
├─ src/
│  ├─ api/                 # 一个后端模块一个文件
│  │  ├─ request.js        # axios 实例 + 请求/响应拦截器
│  │  ├─ user.js  home.js  article.js  comment.js  favorite.js
│  ├─ router/
│  │  ├─ index.js          # 路由表
│  │  └─ guard.js          # 登录守卫
│  ├─ stores/
│  │  └─ user.js           # Pinia:token + 用户信息
│  ├─ layouts/
│  │  ├─ FrontLayout.vue   # 前台:顶栏 + 内容区 + 页脚
│  │  └─ UserLayout.vue    # 用户中心:侧边菜单 + 二级出口
│  ├─ views/
│  │  ├─ home/Index.vue
│  │  ├─ article/List.vue  Detail.vue
│  │  ├─ user/Profile.vue  Favorites.vue  Comments.vue
│  │  └─ Login.vue  Register.vue
│  ├─ components/
│  │  ├─ ArticleCard.vue   # 作品卡片(三处复用)
│  │  ├─ Banner.vue
│  │  └─ CommentList.vue  CommentForm.vue
│  ├─ App.vue
│  └─ main.js
├─ vite.config.js
└─ package.json
效果预览运行结果
运行结果: # 建好目录后,用 Vite 起开发服务器 $ npm run dev VITE v5.4.8 ready in 312 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ➜ press h + enter to show help # 浏览器打开 http://localhost:5173/ ,此时页面是空的(App.vue 还没内容)
讲透:怎么拆才合理
  • 按「复用」拆:作品卡片、书评条目、轮播图——这些会在多处出现,必须抽成 components/ 下的公共组件。
  • 按「页面」拆:一个路由对应一个 views/ 组件。首页、列表页、详情页各管各的,互不越界。
  • 按「外壳」拆:顶栏、侧边菜单这类「每个页面都长一样」的部分,放 layouts/。页面只填中间那块。
  • 不要按行数拆:一个 200 行的页面,如果拆成 8 个只出现一次的小组件,反而更难读。判断标准始终是「它会不会被复用、职责是否单一」,不是「代码多长」。
动手做App.vue 是全站唯一的根组件,它只干一件事:留一个出口给路由。刷新页面时顺手恢复登录态。
src/App.vuevue
<template>
  <!-- 路由匹配到哪个页面,就在这里渲染哪个页面 -->
  <router-view />
</template>

<script setup>
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'

const user = useUserStore()

// 刷新页面后内存里的数据全丢了,用 localStorage 里的 token 重新拉一次个人信息,
// 确认登录态还有效,顺带把用户信息补回来
onMounted(() => user.restore())
</script>
页面效果组件渲染结果
运行结果: # 此时浏览器里仍是一片空白 —— 因为没有任何路由指向真实页面 # 控制台没有任何报错,说明根组件挂载成功(这是后面所有页面的地基)
避坑只写 <router-view /> 会得到一个「白屏」——很多人第一次做前端会以为是自己代码写错了,其实是还没配路由。另外,组件名要用多单词驼峰(ArticleCard.vue),不要用 Card.vue 这种单词名:HTML 标签不区分大小写,单词名很容易和原生标签冲突。

路由与布局规划

单页应用为什么需要「路由」
先讲为什么传统网页每点一个链接,浏览器就向服务器要一个新 HTML,整页刷新一次。SPA(单页应用)不是这样:整个网站只有一张 HTML,切换的是「渲染哪个组件」。而「当前该渲染哪个组件」这件事,就交给路由。
路由要解决三个问题:哪个地址渲染哪个页面、公共外壳(顶栏页脚)怎么复用、没登录的人能不能进。这一节一次讲清。
动手做写路由表。注意两个关键设计:前台页面挂在 FrontLayout 下(共享顶栏页脚);用户中心挂在 UserLayout 下(共享侧边菜单),并且整块加上 meta.requiresAuth——这就是嵌套路由。
src/router/index.jsjavascript
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('@/layouts/FrontLayout.vue'),
    children: [
      { path: '', name: 'home', component: () => import('@/views/home/Index.vue') },
      { path: 'article/list', name: 'articleList', component: () => import('@/views/article/List.vue') },
      { path: 'article/:id', name: 'articleDetail', component: () => import('@/views/article/Detail.vue') },
    ],
  },
  { path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
  { path: '/register', name: 'register', component: () => import('@/views/Register.vue') },
  {
    // 用户中心:整块需要登录,内部再分子页面
    path: '/user',
    component: () => import('@/layouts/UserLayout.vue'),
    meta: { requiresAuth: true },
    children: [
      { path: '', redirect: '/user/profile' },
      { path: 'profile', name: 'userProfile', component: () => import('@/views/user/Profile.vue') },
      { path: 'favorites', name: 'userFavorites', component: () => import('@/views/user/Favorites.vue') },
      { path: 'comments', name: 'userComments', component: () => import('@/views/user/Comments.vue') },
    ],
  },
  { path: '/:pathMatch(.*)*', name: 'notFound', component: () => import('@/views/NotFound.vue') },
]

const router = createRouter({
  history: createWebHistory(),   // 去掉地址栏里的 #,靠 Nginx 的 try_files 兜底
  routes,
})

export default router
页面效果浏览器运行结果
运行结果: # 地址栏访问下面这些路径,路由分别命中不同页面 http://localhost:5173/ → FrontLayout + home/Index http://localhost:5173/article/list → FrontLayout + article/List http://localhost:5173/article/1 → FrontLayout + article/Detail(route.params.id = "1") http://localhost:5173/user/profile → UserLayout + user/Profile http://localhost:5173/xxx → NotFound
动手做写登录守卫。它在每次跳转之前执行,检查目标路由要不要登录、本地有没有 token。
src/router/guard.jsjavascript
import router from './index'
import { useUserStore } from '@/stores/user'

router.beforeEach((to, from, next) => {
  const user = useUserStore()

  // 目标路由要求登录,但本地没有 token → 拦下来,跳登录页
  // 顺便把「原本来想去哪」记在 query 里,登录成功后好跳回去
  if (to.meta.requiresAuth && !user.token) {
    return next({ name: 'login', query: { redirect: to.fullPath } })
  }

  next()   // 放行:千万别忘了这一句,漏了页面会一直卡住
})

export default router
页面效果浏览器运行结果
运行结果: # 未登录状态下,直接在地址栏敲 /user/profile http://localhost:5173/user/profile ↓ 守卫命中 http://localhost:5173/login?redirect=/user/profile # 登录成功后,登录页读 redirect 跳回去 router.push(route.query.redirect || '/') → 回到 /user/profile
讲透:嵌套路由到底嵌套了什么
  • 父组件必须有 <router-view /> 出口。UserLayout.vue 里写上侧边菜单,再留一个 <router-view />,子页面才会渲染在那个位置。
  • path 会拼接:父的 /user + 子的 profile = /user/profile。子路由的 path 不要以 / 开头,否则会被当成绝对路径、丢掉父级外壳。
  • meta 会向下继承:父路由写了 requiresAuth: true,三个子页面都自动需要登录,不必各写一遍。
  • 路由守卫只拦「前端跳转」,它挡不住直接调接口的人。真正的权限必须由后端 @login_required 兜底——前端守卫只是「体验层」,后端校验才是「安全层」。
避坑守卫函数里必须调用 next()(或用 return 返回目标路由)。漏掉它,路由会「停在原地」——表现是点链接地址栏变了、页面却不动,或者整个白屏。排查时第一眼就看这里。另外别把 beforeEach 写在组件里反复执行,它应该只在路由文件里写一次。

逐个模块实现要点

首页 / 列表 / 详情 / 书评 / 用户中心
先讲为什么页面很多,但套路是同一个:进页面先取数据 → 渲染列表/详情 → 用户点了就发请求 → 拿到结果更新界面。所以不用五个模块各学一套,只要把「列表」和「详情+交互」这两种最典型的写法吃透,剩下的都是换接口名。
动手做(列表页)作品列表要支持分类筛选、关键词搜索、分页。要点:筛选条件统一放在一个 reactive 对象里,条件一变就「回到第 1 页重新取」,交给后端筛(不是前端筛)。
src/views/article/List.vuevue
<template>
  <div class="article-list">
    <div class="filter">
      <el-select v-model="query.category_id" placeholder="全部分类" clearable @change="reload">
        <el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
      </el-select>
      <el-input v-model="query.keyword" placeholder="搜索标题 / 摘要"
                clearable @keyup.enter="reload" />
    </div>

    <el-row :gutter="16">
      <el-col v-for="item in list" :key="item.id" :xs="24" :sm="12" :md="8">
        <ArticleCard :article="item" />
      </el-col>
    </el-row>

    <el-empty v-if="!list.length" description="没有符合条件的作品" />

    <el-pagination v-model:current-page="query.page"
                   :page-size="query.page_size" :total="total"
                   layout="prev, pager, next" @current-change="load" />
  </div>
</template>

<script setup>
import { reactive, ref, onMounted } from 'vue'
import { getArticleList } from '@/api/article'
import { getCategoryList } from '@/api/home'
import ArticleCard from '@/components/ArticleCard.vue'

const list = ref([])
const total = ref(0)
const categories = ref([])
const query = reactive({
  page: 1, page_size: 9,
  category_id: null, keyword: '', order_by: 'latest',
})

async function load() {
  // 拦截器已经把 {code,msg,data} 拆过包,这里直接拿到 data
  const { list: rows, total: t } = await getArticleList(query)
  list.value = rows
  total.value = t
}

function reload() {        // 条件一变,必须回到第 1 页
  query.page = 1
  load()
}

onMounted(async () => {
  categories.value = await getCategoryList()
  load()
})
</script>
页面效果组件渲染结果
运行结果: # 打开 /article/list,Network 里看到一次请求: GET /api/article/list?page=1&page_size=9&order_by=latest 200 OK # 点「唐诗」分类后再看: GET /api/article/list?page=1&page_size=9&order_by=latest&category_id=1 200 OK # 页面上 3 张卡片排成一行(md 断点每行 3 个): 静夜思 · 李白 · 唐 · 唐诗 春望 · 杜甫 · 唐 · 唐诗 陋室铭 · 刘禹锡 · 唐 · 唐诗 -- total = 3,分页控件只剩 1 页
动手做(详情页 + 书评)详情页要点:点赞、收藏都要先判断登录;书评是两级结构(一级书评下的 children 是「回复」),后端一次返回,前端用两层 v-for 渲染。
src/views/article/Detail.vue(脚本部分)javascript
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getArticleDetail, likeArticle } from '@/api/article'
import { toggleFavorite } from '@/api/favorite'
import { useUserStore } from '@/stores/user'

const route = useRoute()
const router = useRouter()
const user = useUserStore()

const articleId = Number(route.params.id)   // 地址栏拿到的是字符串,转成数字
const article = ref({})

onMounted(async () => {
  article.value = await getArticleDetail(articleId)
})

// 需要登录的操作,统一先过一道「登录闸门」
function needLogin() {
  if (user.token) return false
  ElMessage.warning('请先登录')
  router.push({ name: 'login', query: { redirect: route.fullPath } })
  return true
}

// 点赞:同一个人再点一次 = 取消点赞,后端把最新状态和总数一起返回
async function onLike() {
  if (needLogin()) return
  const { liked, likes } = await likeArticle(articleId)
  article.value.is_liked = liked
  article.value.likes = likes
}

async function onFavorite() {
  if (needLogin()) return
  const { favorited } = await toggleFavorite(articleId)
  article.value.is_favorite = favorited
  ElMessage.success(favorited ? '已加入收藏' : '已取消收藏')
}
页面效果浏览器运行结果
运行结果: # 打开 /article/1(未登录) GET /api/article/detail/1 200 OK {"code":200,"msg":"success","data":{"id":1,"title":"静夜思","views":329, "likes":42,"is_favorite":false,"is_liked":false,"comment_count":2,...}} # 未登录时点「收藏」,不会发请求,直接弹提示并跳登录 ElMessage: 请先登录 → /login?redirect=/article/1 # 登录后再点「收藏」(已带 token) POST /api/favorite/toggle Body: {"article_id":1} {"code":200,"msg":"操作成功","data":{"favorited":true}} 按钮文案变成「已收藏」
讲透:五个模块各自的「考点」
  • 首页:三个接口并行取(banner/list、category/list、article/recommend),用轮播 + 卡片网格铺开。
  • 列表页:筛选/搜索/排序/分页四件事都传给后端,前端只负责把参数拼进 params。
  • 详情页:is_liked / is_favorite 是「当前用户的状态」,未登录时后端返回 false,前端照常渲染。
  • 书评:一级书评的 children 是回复数组;发表后要重新拉列表(而不是手动往数组里塞),保证分页和总数都对。
  • 用户中心:UserLayout 左侧菜单 + 右侧二级出口;三个子页共用一个需要登录的父路由。
避坑两个高频错误:① v-for 忘了写 :key,或者用数组下标当 key——列表增删时会出现「前面的内容串到后面那条」的诡异现象,务必用 :key="item.id"。② 子组件里直接改 props(比如 props.article.likes++),Vue 会警告「Set operation on key failed: target is readonly」。props 是只读的,要在子组件里改,就 emit 事件让父组件改。

接口对接与数据流

axios → api 函数 → 组件 → Pinia
前端数据流:用户操作一路传递,数据一路回流渲染用户操作点筛选 / 翻页组件调用 api 函数api/article.jsaxios 封装实例自动带 token · 拆响应壳Flask 接口 {code,msg,data}查 MySQL 返回数据ref/computed 更新列表自动重新渲染页面更新Pinia 只放全局态(用户信息/token);页面私有数据(列表、loading)留在组件里
先讲为什么最省事的写法是「每个组件里直接 axios.get('http://127.0.0.1:5000/api/...')」。可你马上会遇到:18 个接口里每个都要手写 Authorization 头、每个都要判断 code 是不是 200、地址一换就要全站改。这些重复劳动应该在「一个地方」一次做完——这就是封装。
本课程定了一条死规矩的数据流:拦截器 → api 函数 → 组件 → Pinia,谁都不许越级。
动手做(第一层:拦截器)request.js 是全站唯一的「出入口」。它做三件事:请求前自动带 token、响应后自动拆 code/msg/data、出错统一提示。
src/api/request.jsjavascript
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'

const request = axios.create({
  // 开发环境走 Vite 代理(/api → Flask),生产环境由 Nginx 反代,前端只认 /api
  baseURL: import.meta.env.VITE_API_BASE || '/api',
  timeout: 10000,
})

// ── 请求拦截器:出站前统一加 token ──────────────────
request.interceptors.request.use((config) => {
  const user = useUserStore()
  if (user.token) {
    config.headers.Authorization = `Bearer ${user.token}`
  }
  return config
})

// ── 响应拦截器:入站后统一拆包 + 统一报错 ────────────
request.interceptors.response.use(
  (response) => {
    const res = response.data        // { code, msg, data }

    if (res.code === 200) {
      return res.data               // 成功:直接把 data 交给调用方,组件里不用再 .data
    }

    if (res.code === 401) {         // 未登录 / token 失效
      const user = useUserStore()
      user.logout()                 // 清掉本地 token
      router.push({ name: 'login' })
      return Promise.reject(new Error(res.msg))
    }

    ElMessage.error(res.msg || '请求失败')   // 400/403/404/500
    return Promise.reject(new Error(res.msg))
  },
  (error) => {
    ElMessage.error('网络异常,请稍后再试')   // 断网 / 后端没起来
    return Promise.reject(error)
  }
)

export default request
页面效果浏览器运行结果
运行结果: # 组件里调用后,浏览器 Network 中的请求头自动带上了 token POST /api/favorite/toggle Request Headers: Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... # token 失效时,不用组件自己处理,拦截器统一跳登录 Response: {"code":401,"msg":"登录已过期,请重新登录","data":null} → 本地 token 被清除,页面跳到 /login
动手做(第二层:api 函数)按后端模块拆文件,一个接口一个函数,函数名和《API 接口文档》一一对应。组件只认函数名,不认 URL。
src/api/article.jsjavascript
import request from './request'

// 作品列表:支持分页 / 分类 / 关键词 / 朝代 / 排序(全部走查询参数)
export function getArticleList(params) {
  return request.get('/article/list', { params })
}

// 作品详情:后端每次访问会把 views + 1
export function getArticleDetail(id) {
  return request.get(`/article/detail/${id}`)
}

// 点赞作品(需登录):同一个人再点一次即取消点赞
export function likeArticle(articleId) {
  return request.post('/article/like', { article_id: articleId })
}
页面效果浏览器运行结果
运行结果: # 组件里这样用,拿到的已经是 data,不用管 code const { list, total } = await getArticleList({ page: 1, page_size: 9 }) # list → [{id:1,title:"静夜思",...}, ...] total → 6 # 需要别名的场景(比如和 home.js 里的同名函数区分) import * as articleApi from '@/api/article' articleApi.getArticleList({ order_by: 'hot' })
动手做(第三层:Pinia)只有跨页面共享的状态才放 Pinia——token 和用户信息是最典型的一对。列表数据是「页面自己的」,留在组件里即可,别全塞进 store。
src/stores/user.jsjavascript
import { defineStore } from 'pinia'
import { login as loginApi, getProfile } from '@/api/user'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: localStorage.getItem('token') || '',   // 初始化时就从本地取,刷新不丢
    info: null,
  }),

  getters: {
    isLogin: (state) => !!state.token,
    nickname: (state) => state.info?.nickname || '',
  },

  actions: {
    async login(form) {
      const { token, user } = await loginApi(form)   // 后端返回 { token, user }
      this.token = token
      this.info = user
      localStorage.setItem('token', token)
      return user
    },

    // 刷新页面后:拿本地 token 重新拉一次个人信息
    async restore() {
      if (!this.token) return
      try {
        this.info = await getProfile()
      } catch (e) {
        this.logout()      // token 已失效,清理掉
      }
    },

    logout() {
      this.token = ''
      this.info = null
      localStorage.removeItem('token')
    },
  },
})
页面效果浏览器运行结果
运行结果: # 登录成功后,localStorage 与 store 里都有 token localStorage: token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # 刷新页面(F5)后,内存里的 store 会重置,但 restore() 用本地 token 拉回了信息 user.isLogin → true user.nickname → "张三"
讲透:为什么数据流要分四层
  • 拦截器(request.js):管「通用规矩」——加 token、拆包、报错。改一处,18 个接口全生效。
  • api 函数:管「接口清单」——URL、方法、参数长什么样。改接口地址只改这里。
  • 组件:管「这一页要什么」——调函数、拿数据、渲染。不关心 token 怎么带、地址从哪来。
  • Pinia:管「跨页共享」——token、用户信息。页面私有数据不要放进来,否则会越写越乱。
  • 一句话记忆:拦截器在最底层兜底,api 是接口目录,组件是消费者,store 是公共仓库。
避坑① token 只存内存会「刷新即掉线」:Pinia 的数据在内存里,F5 一下全没了。必须同步写一份到 localStorage,并在 state 初始化时读回来。② 404 不要当成 401 处理:只有 code === 401 才清 token 跳登录;把 404 也跳登录,用户点了个失效的作品链接就被登出了,体验极差。

首次联调(里程碑 M4)

Vue 把 Flask 的数据渲染出来的那一刻
先讲为什么前面所有工作,到这里才第一次「合体」:前端在自己家里(5173 端口),后端在自己家里(5000 端口)。浏览器有「同源策略」——页面只能向「同源」的地址发请求,端口不同就算跨域,请求会被浏览器拦下。
解决办法不是让后端「放宽限制」,而是在开发阶段让前端的请求先发给 Vite 开发服务器,再由它转发给 Flask。这样从浏览器看,请求始终是「同源」的。这个动作叫代理(proxy),也是里程碑 M4 的关键一环。
动手做给 Vite 加代理配置。之后再让前端请求 /api/...,就自动转发到 Flask。
vite.config.jsjavascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': path.resolve(__dirname, 'src') },
  },
  server: {
    port: 5173,
    proxy: {
      // 开发环境:所有 /api 开头的请求转发给 Flask,绕开浏览器跨域限制
      '/api': {
        target: 'http://127.0.0.1:5000',
        changeOrigin: true,
      },
    },
  },
})
页面效果浏览器运行结果
运行结果: # 两个终端一起开着 终端 1(后端):flask --app app run --debug → Running on http://127.0.0.1:5000 终端 2(前端):npm run dev → Local: http://localhost:5173/ # 改完 vite.config.js 必须重启前端开发服务器,否则代理不生效 $ npm run dev VITE v5.4.8 ready in 315 ms ➜ Local: http://localhost:5173/
动手做(见证 M4)打开首页,按 F12 → Network → 切到 Fetch/XHR,然后刷新。你要亲眼看到请求真的发出去了、状态码是 200、Response 里有数据。
浏览器 Network 面板text
# F12 → Network → Fetch/XHR → 刷新 http://localhost:5173/
Name                                 Status   Method   Size     Time
banner/list                          200      GET      1.2 kB   18 ms
category/list                        200      GET      0.8 kB   12 ms
article/recommend?limit=6            200      GET      2.4 kB   21 ms
article/list?page=1&page_size=9       200      GET      3.1 kB   25 ms

# 点开 article/recommend 这一条 → 看 Headers
Request URL:   http://localhost:5173/api/article/recommend?limit=6
Request Method: GET
Status Code:   200 OK

# 注意地址栏是 5173(Vite),不是 5000 —— 请求先到 Vite,再由它转发给 Flask
效果预览运行结果
运行结果: # 切到 Response 标签,看到的是标准响应结构 { "code": 200, "msg": "success", "data": [ { "id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "dynasty": "宋", "category_name": "宋词", "cover": "/static/images/a3.jpg", "summary": "大江东去,浪淘尽,千古风流人物。", "views": 412, "likes": 58 } ] }
页面实际效果(Console 无报错)text
# 首页推荐位渲染出来的卡片(数据来自 article/recommend)
念奴娇·赤壁怀古   苏轼 · 宋 · 宋词    浏览 412 · 赞 58
静夜思             李白 · 唐 · 唐诗    浏览 328 · 赞 42
...(共 6 张推荐卡片)

# 分类导航(数据来自 category/list)
[ 唐诗 ]  [ 宋词 ]  [ 元曲 ]  [ 散文 ]  [ 小说 ]

-- 里程碑 M4 达成:Vue 页面成功调用 Flask 接口,把作品列表渲染出来了
-- 这一步走通,说明「前端 → 代理 → 后端 → 数据库」整条链路没有断点
效果预览运行结果
运行结果: # 联调成功的三个证据(截图放进阶段 4 交付物) 1)Network 面板:4 个接口全部 200,无 pending、无 failed 2)Console 面板:没有任何红色报错 3)页面上:轮播图、分类、6 张推荐卡片、分页控件都正常显示
讲透:联调要按这个顺序,别乱
  • 第一步:先通「最不需要登录的接口」——article/list。它不需要 token,能排除掉鉴权带来的干扰。
  • 第二步:再通带参数的接口——分页、筛选、搜索,确认 params 真的拼进了 URL。
  • 第三步:最后通需要登录的接口——登录拿到 token,再测点赞、收藏。这一步出问题,八成是鉴权头没带上。
  • 顺序的意义:从简单到复杂,每通一步都只新增一个变量。一上来就测最复杂的接口,出错时你分不清是 URL 错了、参数错了,还是 token 错了。
避坑联调时最常见的「三件乌龙」,先自查再求助:① Flask 没启动——Network 里状态是 (failed) 或 net::ERR_CONNECTION_REFUSED;② 改了 vite.config.js 没重启前端——代理配置只在启动时读一次,热更新不会重载它;③ 浏览器地址栏直接敲 5000——那当然跨域,请始终通过 5173 访问。

常见联调错误与排查

CORS / 404 / 401 / 代理不生效
联调排错决策树:先定位「断在哪一层」,再对症下药页面拿不到数据Network 里请求都看不到有请求,但失败前端问题· api 函数没被调用?· 路由没到页面?· onMounted 写对了?· 控制台有无报错?看状态码CORS 报错→ 配 Vite 代理404→ URL 拼错/后端没起401→ token 没带/过期500→ 看后端日志栈Network 面板是第一现场口诀:前端查调用,后端查日志,中间查状态码
先讲为什么联调阶段 90% 的时间不是在写代码,而是在「看报错、定位问题」。好消息是:错误类型其实就那么几种,每一种都有明确的特征和固定的解法。把下面这张「症状—病因—药方」对照表记住,你排查的速度会快一个数量级。
动手做对照这四种典型报错,逐个在浏览器 Console / Network 里认出它们。
Console 里能看到的四类报错text
# ① 跨域(CORS):没走代理,直接请求 5000 端口
Access to XMLHttpRequest at 'http://127.0.0.1:5000/api/article/list'
from origin 'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
→ 药方:让前端始终请求 /api(走 Vite 代理),别写死 5000 的绝对地址

# ② 404:接口路径写错了,或者后端没注册这个蓝图
GET http://localhost:5173/api/article/lists   404 (Not Found)
{"code":404,"msg":"接口不存在","data":null}
→ 药方:对着《API 接口文档》1.7 节逐字核对路径(是 list 不是 lists)

# ③ 401:需要登录的接口没带 token,或 token 过期
{"code":401,"msg":"请先登录","data":null}
→ 药方:确认 request.js 的请求拦截器真的加上了 Authorization 头;
        点开 Network 看 Request Headers 里有没有 Bearer xxx

# ④ 代理不生效:改了 vite.config.js 但没重启
GET http://localhost:5173/api/article/list   404 (Not Found)
(注意:这条是 Vite 自己返回的 404,说明它没把 /api 转出去)
→ 药方:Ctrl+C 停掉 npm run dev,重新启动一次
效果预览运行结果
运行结果: # 快速区分「谁的错」的方法 —— 看 Network 里请求的 Request URL localhost:5173/api/... → 请求到了 Vite,代理该干活了 localhost:5000/api/... → 请求越过了 Vite,跨域就是这么来的 # 看响应体里的 code code=401 → 后端的鉴权拦住了,是「有没有登录」的问题 code=404 → 后端没找到这个接口,是「路径写没写对」的问题 code=500 → 后端代码抛异常了,去看 Flask 终端的堆栈
讲透:一条通用的排查口诀
  • 先看 Network,不看代码。请求到底发出去了没?状态码是多少?Request URL 指向谁?这三个问题答完,问题范围就缩小一大半。
  • 「请求没发出去」和「请求发了但结果不对」要分开治。前者查前端(按钮有没有绑事件、函数有没有执行);后者查后端(参数对不对、SQL 查没查到)。
  • 一定要会看 Request Payload。空输入、字段名写错(articleId vs article_id)都会在这里露馅。
  • 后端的问题看 Flask 终端。500 一定有堆栈,堆栈最后一行就是真正的原因。
  • 前端的问题看 Console。语法错、未定义变量、undefined 取值,都在这里。
避坑「用 CORS 通配符 origins:"*" 糊弄过去」是初学者最省事、也最危险的做法。开发阶段后端确实配了 origins:"*",但那只是方便调试;生产环境必须收紧成你的域名,否则任何一个网站都能拿用户的 token 去调用你的接口。真正的解法始终是「前端走同源 + 代理」,而不是「让后端对全世界开放」。

本阶段验收标准

达到这些,M4 才算真的完成
动手做逐条自查,全部打勾才允许进入阶段 5。任何一条不过,都记为「待修复缺陷」,进阶段 5 的缺陷清单。
阶段 4 验收清单text
[ ] 首页:轮播图 / 分类导航 / 推荐作品 6 张卡片,全部来自接口
[ ] 列表页:分类筛选、关键词搜索、分页三件套都能用,条件变动回第 1 页
[ ] 详情页:正文、浏览量、点赞数、收藏状态、书评列表都正常展示
[ ] 书评:登录后能发表;一级书评下的回复(children)能正确渲染
[ ] 点赞 / 收藏:未登录时先弹提示并跳登录,登录后能切换状态
[ ] 用户中心:/user 下三个子页面都能打开,左侧菜单高亮正确
[ ] 未登录直接访问 /user/profile,会被守卫拦到登录页,且登录后跳回原页面
[ ] 全站刷新(F5)不掉登录态
[ ] Network 面板:所有接口 200,Console 无红色报错
[ ] 打包通过:npm run build 无报错,dist/ 目录生成成功
效果预览运行结果
运行结果: # 打包检查(阶段 6 部署要用的就是 dist/) $ npm run build vite v5.4.8 building for production... ✓ 62 modules transformed. dist/index.html 0.48 kB │ gzip: 0.32 kB dist/assets/index-Dx3k1a2b.css 38.21 kB │ gzip: 7.94 kB dist/assets/index-C9mQp4tR.js 196.44 kB │ gzip: 68.10 kB ✓ built in 2.63s
讲透:本阶段的交付物是什么
  • 一份可运行的前端 SPA:源码 + npm run dev 能起、npm run build 能打包。
  • 一张 M4 联调成功的截图:Network 面板四条 200 + 页面渲染效果,这是「三端打通」的证据。
  • 一份接口对接记录:18 个接口里,前端分别用到了哪几个、对应哪个 api 函数。
  • 一份缺陷清单:本阶段没解决的问题,原样带进阶段 5,别偷偷跳过。
动手练习
1把 ArticleCard.vue 写成接收一个 article prop 的公共组件,然后分别在首页推荐位与列表页复用。要求:点击卡片整体跳转到 /article/:id。
2给「我的收藏」页(user/Favorites.vue)接上 GET /api/favorite/list 接口,渲染出收藏的作品列表,并在每一条后面加一个「取消收藏」按钮(调 /api/favorite/toggle,成功后把这条从列表里移除)。
3迁移练习:「红影赏析」的电影详情页结构与作品详情页几乎一样,但多了一个「导演」字段(director)、评分用星级展示(rating,1–10 分)。请你画出组件树、写出路由表,并说明哪些组件可以直接复用、哪些必须新写。
参考答案
第 1 题 · ArticleCard.vuevue
<template>
  <div class="card" @click="goDetail">
    <img :src="article.cover || defaultCover" :alt="article.title">
    <h3>{{ article.title }}</h3>
    <p class="meta">{{ article.author }} · {{ article.dynasty }} · {{ article.category_name }}</p>
    <p class="stat">浏览 {{ article.views }} · 赞 {{ article.likes }}</p>
  </div>
</template>

<script setup>
import { useRouter } from 'vue-router'
const props = defineProps({ article: { type: Object, required: true } })
const router = useRouter()
const defaultCover = '/default-cover.png'

function goDetail() {
  router.push({ name: 'articleDetail', params: { id: props.article.id } })
}
</script>
页面效果组件渲染结果
运行结果: # 首页与列表页都用同一行代码渲染卡片 <ArticleCard :article="item" /> # 点击任意一张卡片,地址栏变成 http://localhost:5173/article/3
第 2 题 · 我的收藏(脚本部分)javascript
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getFavoriteList, toggleFavorite } from '@/api/favorite'

const list = ref([])
const total = ref(0)
const page = ref(1)

async function load() {
  const data = await getFavoriteList({ page: page.value, page_size: 10 })
  list.value = data.list
  total.value = data.total
}

// 取消收藏:成功后本地同步移除,不必整页重取
async function remove(item) {
  const { favorited } = await toggleFavorite(item.article_id)
  if (!favorited) {
    list.value = list.value.filter((row) => row.article_id !== item.article_id)
    total.value -= 1
    ElMessage.success('已取消收藏')
  }
}

onMounted(load)
页面效果浏览器运行结果
运行结果: GET /api/favorite/list?page=1&page_size=10 200 OK {"code":200,"msg":"success","data":{"list":[{"article_id":3,"title":"念奴娇·赤壁怀古", "author":"苏轼","category_name":"宋词","created_at":"2026-10-12 11:00:00"}], "total":1,"page":1,"page_size":10}} # 点「取消收藏」 POST /api/favorite/toggle {"article_id":3} {"code":200,"msg":"操作成功","data":{"favorited":false}} → 该条从列表中移除,列表变为空,显示「还没有收藏的作品」

第 3 题的要点:复用——顶栏/页脚布局、卡片列表容器、书评组件、分页组件可以直接搬;必改——卡片里的「朝代」要换成「类型」,动作区要加「导演」,详情页要新增星级评分组件。换的是字段名和个别展示组件,不变的是「列表 + 详情 + 交互」这套骨架——能一眼看出哪些能搬,说明你真的理解了组件化。

本阶段小结