阶段 1 你画了草图,阶段 3 你把接口跑通了,现在把它们接起来——做出一个能点、能用的前端 SPA。这一阶段的核心不是「再学一遍 Vue」,而是三件事:把草图拆成组件、把接口串成数据流、把前后端第一次真正接通(里程碑 M4)。走完它,你就能在浏览器里看到「静夜思」从数据库一路走到页面上的样子。
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
<template>
<!-- 路由匹配到哪个页面,就在这里渲染哪个页面 -->
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
const user = useUserStore()
// 刷新页面后内存里的数据全丢了,用 localStorage 里的 token 重新拉一次个人信息,
// 确认登录态还有效,顺带把用户信息补回来
onMounted(() => user.restore())
</script>
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
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
<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>
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 ? '已加入收藏' : '已取消收藏')
}
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
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 })
}
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')
},
},
})
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,
},
},
},
})
# 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
# 首页推荐位渲染出来的卡片(数据来自 article/recommend)
念奴娇·赤壁怀古 苏轼 · 宋 · 宋词 浏览 412 · 赞 58
静夜思 李白 · 唐 · 唐诗 浏览 328 · 赞 42
...(共 6 张推荐卡片)
# 分类导航(数据来自 category/list)
[ 唐诗 ] [ 宋词 ] [ 元曲 ] [ 散文 ] [ 小说 ]
-- 里程碑 M4 达成:Vue 页面成功调用 Flask 接口,把作品列表渲染出来了
-- 这一步走通,说明「前端 → 代理 → 后端 → 数据库」整条链路没有断点
# ① 跨域(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,重新启动一次
[ ] 首页:轮播图 / 分类导航 / 推荐作品 6 张卡片,全部来自接口
[ ] 列表页:分类筛选、关键词搜索、分页三件套都能用,条件变动回第 1 页
[ ] 详情页:正文、浏览量、点赞数、收藏状态、书评列表都正常展示
[ ] 书评:登录后能发表;一级书评下的回复(children)能正确渲染
[ ] 点赞 / 收藏:未登录时先弹提示并跳登录,登录后能切换状态
[ ] 用户中心:/user 下三个子页面都能打开,左侧菜单高亮正确
[ ] 未登录直接访问 /user/profile,会被守卫拦到登录页,且登录后跳回原页面
[ ] 全站刷新(F5)不掉登录态
[ ] Network 面板:所有接口 200,Console 无红色报错
[ ] 打包通过:npm run build 无报错,dist/ 目录生成成功
<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>
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)
第 3 题的要点:复用——顶栏/页脚布局、卡片列表容器、书评组件、分页组件可以直接搬;必改——卡片里的「朝代」要换成「类型」,动作区要加「导演」,详情页要新增星级评分组件。换的是字段名和个别展示组件,不变的是「列表 + 详情 + 交互」这套骨架——能一眼看出哪些能搬,说明你真的理解了组件化。