后端契约立好,前端按"合同"施工:baseURL换
/api、列表解信封、错误统一渲染、收藏页接真接口------前端断掉的页面,今天整体接回来。🎯 本篇产出:早报站前端全面恢复 + 分页组件 + 登录态完整打通 + 统一错误处理。含代码约140行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | 前端按新契约整体对接后端 |
| 代码行数 | ~140行(含注释) |
| 依赖 | 无新增 |
| 核心功能 | 信封解包 + 统一错误 + 分页 + 收藏页 |
| 跑起来的命令 | npm run dev → 浏览器验证 |
| 核心知识点 | 响应拦截器、四态渲染、分页组件 |
| 做完你能得到 | 一个完整可用的前后端分离产品 |
一、第0步:换baseURL + 响应拦截器(信封解包与错误处理的"总开关")
src/lib/api.js 全面升级:
javascript
import axios from 'axios'
const api = axios.create({
baseURL: 'http://127.0.0.1:8000/api', // ① 换上新契约的前缀
})
// 请求拦截器:自动带令牌(不变)
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 响应拦截器:信封解包 + 统一错误处理(本篇主角)
api.interceptors.response.use(
(resp) => resp.data, // ② 自动解开axios的response,页面拿到直接可用
(error) => {
// ③ 统一错误:从响应里提取人话,或给兜底文案
const detail = error.response?.data?.detail || '网络异常,请稍后重试'
// ④ 401全家桶:令牌失效 → 清登录态 → 回登录页
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('username')
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(new Error(detail))
},
)
export default api
🎯 四个设计的价值
| # | 设计 | 收益 |
|---|---|---|
| ① | 解包一次,全站受益 | 所有页面const data = await api.get(...)直接拿业务数据 |
| ② | 错误也统一了 | 每个页面的catch收到的都是人话字符串 ,不用各自解析e.response.data.detail(后端契约的"错误全站一种长相"在前端兑现) |
| ③ | 401集中处置 | 令牌过期不用每个接口单独判断 |
| ④ | 零依赖 | 不import router/store------避免循环引用 |
🚨 为什么401用window.location而不是router?
📌 api.js是独立模块,import router会和路由模块互相引用 (circular import,第六节③的坑)------用原生跳转绕开,简单可靠。
二、第1步:HomeView------信封数据 + 分页状态
src/stores/search.js:
javascript
import { ref } from 'vue'
import { defineStore } from 'pinia'
// 搜索状态:顶栏搜索框(App.vue)与首页列表(HomeView.vue)共享
export const useSearchStore = defineStore('search', () => {
const keyword = ref('')
return { keyword }
})
src/views/HomeView.vue(关键逻辑):
vue
<script setup>
import { computed, onMounted, ref } from 'vue'
import api from '@/lib/api'
import ArticleCard from '@/components/ArticleCard.vue'
import SearchInput from '@/components/SearchInput.vue'
import PaginationBar from '@/components/PaginationBar.vue'
import { useSearchStore } from '@/stores/search'
const PAGE_SIZE = 10
const articles = ref([])
const loading = ref(true)
const errorMsg = ref('')
const search = useSearchStore()
const keyword = computed(() => search.keyword) // 顶栏搜索框共享的状态
const page = ref(1)
const total = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
async function loadArticles() {
loading.value = true
errorMsg.value = ''
try {
const data = await api.get('/articles', {
params: { keyword: keyword.value, page: page.value, size: PAGE_SIZE },
})
articles.value = data.items // 信封已由拦截器解开:直接用.items
total.value = data.total
} catch (e) {
errorMsg.value = e.message // 拦截器已转成人话
} finally {
loading.value = false
}
}
// 防抖加载(避免搜索时每敲一个字发一次请求)
let timer = null
function debounceLoad() {
clearTimeout(timer)
timer = setTimeout(loadArticles, 500)
}
watch(() => search.keyword, () => {
page.value = 1
debounceLoad()
})
function goPage(p) {
page.value = p
loadArticles()
}
onBeforeUnmount(() => clearTimeout(timer))
onMounted(() => loadArticles())
</script>
<template>
<div class="page">
<main class="container">
<div class="result-bar">
<template v-if="loading">正在抓取今日早报...</template>
<template v-else-if="errorMsg">{{ errorMsg }}</template>
<template v-else-if="articles.length === 0">暂无文章</template>
<template v-else>共 {{ articles.length }} 篇</template>
</div>
<!-- 骨架屏:加载时的占位卡片 -->
<div v-if="loading" class="grid">
<div v-for="n in 6" :key="n" class="skeleton">
<div class="sk-line w70"></div>
<div class="sk-line w100"></div>
<div class="sk-line w45"></div>
</div>
</div>
<div v-else-if="errorMsg" class="state error-state">
<span class="state-icon">⚠️</span>
<p>{{ errorMsg }}</p>
<button class="btn-primary" @click="loadArticles(keyword)">重试</button>
</div>
<div v-else-if="articles.length === 0" class="state">
<span class="state-icon">📭</span>
<p>{{ keyword ? `没有匹配「${keyword}」的文章` : '暂无文章' }}</p>
</div>
<!-- v-for:列表渲染;:key是每个条目的身份证 -->
<div v-else class="grid">
<ArticleCard
v-for="a in articles"
:key="a.id"
v-bind="a"
@favorite="favorite"
>
<template #actions>
<a class="read-link" :href="a.url" target="_blank" rel="noopener">阅读原文 ↗</a>
</template>
</ArticleCard>
<PaginationBar :page="page" :total-pages="totalPages" @change="goPage" />
</div>
</main>
<footer class="site-footer">Python 早报 · 每日聚合社区动态</footer>
</div>
</template>
📌 对比旧版的变化
| # | 变化 | 说明 |
|---|---|---|
| ① | 数据源从data(裸列表)变成data.items |
信封解包的体现 |
| ② | 新增page/total/totalPages三兄弟 |
管理分页状态 |
| ③ | 搜索时先把页码重置回1 | 不然搜到第3页可能一无所获(第六节⑤) |
| ④ | 补充了debounceLoad |
防抖函数 |
三、第2步:PaginationBar------组件化的分页条
src/components/PaginationBar.vue(第10篇的props/emit正式上岗):
vue
<script setup>
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
})
const emit = defineEmits(['change'])
function jump(p) {
if (p < 1 || p > props.totalPages || p === props.page) return
emit('change', p) // 页码变化 → 上报父组件
}
</script>
<template>
<div class="pager">
<button :disabled="page <= 1" @click="jump(page - 1)">上一页</button>
<span>第 {{ page }} / {{ totalPages }} 页</span>
<button :disabled="page >= totalPages" @click="jump(page + 1)">下一页</button>
</div>
</template>
<style scoped>
.pager {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin: 28px 0 4px;
}
/* 上一页 / 下一页:与 .chip / 登录按钮同一套胶囊语言 */
.pager button {
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
padding: 6px 18px;
border-radius: 999px;
box-shadow: var(--shadow-sm);
transition: color .15s ease, background .15s ease, border-color .15s ease,
transform .15s ease, box-shadow .15s ease;
}
.pager button:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-soft);
border-color: #c9d6f5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, .14);
}
.pager button:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.pager button:disabled {
opacity: .4;
cursor: not-allowed;
box-shadow: none;
}
/* 页码计数:弱化为辅助信息 */
.page-info {
font-size: 13px;
color: var(--text-muted);
padding: 0 6px;
white-space: nowrap;
}
@media (max-width: 640px) {
.pager { gap: 8px; }
.pager button { padding: 6px 14px; }
}
</style>
🎯 组件即函数 的又一实例:分页逻辑封装一次,任何列表页
<PaginationBar :page :total-pages @change="goPage" />直接复用。
四、第3步:登录态完整化 + 收藏页
🔧 stores/auth.js的login升级
登录成功后调/auth/me拿真实资料(OAuth用户也能拿到gh_开头的真名):
javascript
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import api from '../lib/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const userId = ref(Number(localStorage.getItem('userId')) || 0) // 新增
const isLoggedIn = computed(() => Boolean(token.value))
watch(token, (t) => {
if (t) {
localStorage.setItem('token', t)
} else {
localStorage.removeItem('token')
}
})
watch(username, (u) => {
if (u) localStorage.setItem('username', u)
else localStorage.removeItem('username')
})
async function login(name, password) {
const form = new URLSearchParams({ username: name, password })
const data = await api.post('/auth/login', form)
token.value = data.access_token // 先写token,拦截器后续请求才带得上
// 用契约里的/auth/me拿用户资料(不再是"表单值凑合")
const me = await api.get('/auth/me')
username.value = me.username
userId.value = me.id
localStorage.setItem('userId', String(me.id))
}
function logout() {
token.value = ''
username.value = ''
userId.value = 0
localStorage.removeItem('userId')
}
return { token, username, userId, isLoggedIn, login, logout }
})
🆕 FavoritesView.vue------收藏页
受保护页面的第一个真实消费者:
vue
<script setup>
import { onMounted, ref } from 'vue'
import api from '../lib/api'
import ArticleCard from '../components/ArticleCard.vue'
const favorites = ref([])
const loading = ref(true)
const errorMsg = ref('')
onMounted(async () => {
try {
const data = await api.get('/favorites')
favorites.value = data.items
} catch (e) {
errorMsg.value = e.message
} finally {
loading.value = false
}
})
</script>
<template>
<div class="page">
<main class="container">
<!-- 页面标题区 -->
<header class="page-head">
<div class="page-title">
<span class="page-icon">⭐</span>
<div>
<h1>我的收藏</h1>
<p class="page-subtitle">你标记过的文章都在这里</p>
</div>
</div>
<span v-if="!loading && !error" class="count-chip">{{ articles.length }} 篇</span>
</header>
<!-- 骨架屏:加载时的占位卡片 -->
<div v-if="loading" class="grid">
<div v-for="n in 6" :key="n" class="skeleton">
<div class="sk-line w70"></div>
<div class="sk-line w100"></div>
<div class="sk-line w45"></div>
</div>
</div>
<!-- 错误态:可重试 -->
<div v-else-if="error" class="state error-state">
<span class="state-icon">⚠️</span>
<p>{{ errorMsg }}</p>
<button class="btn-primary" @click="load">重试</button>
</div>
<!-- 空态:引导去首页 -->
<div v-else-if="articles.length === 0" class="state">
<span class="state-icon">📭</span>
<p>还没有收藏任何文章。</p>
<RouterLink to="/" class="btn-primary">去看看今日早报 →</RouterLink>
</div>
<!-- 列表:复用首页同款卡片 -->
<div v-else class="grid">
<ArticleCard v-for="a in articles" :key="a.id" v-bind="a" />
</div>
</main>
<footer class="site-footer">Python 早报 · 每日聚合社区动态</footer>
</div>
</template>
📌 四态渲染是列表页的标配
| 状态 | 显示什么 |
|---|---|
| loading | "加载中..." |
| error | 错误信息(拦截器已转人话) |
| empty | "还没有收藏,去首页逛逛吧" |
| data | 正常列表 |
📌 每个真实页面都要想清楚这四种状态长什么样(第26篇测试策略里,"空状态"是单测最爱漏的边界)。
五、验收清单
bash
1. 首页恢复:真数据 + 上一页/下一页可用,页码显示正确
2. 搜索关键词 → 列表过滤正确且页码重置回1
3. 登录 → 跳回原页面;导航栏显示/auth/me返回的用户名
4. "我的收藏" → 列出收藏过的文章;无收藏时显示空状态文案
5. 手动把localStorage的token改成乱码 → 任意请求401 → 自动清理并跳登录页
6. 停掉后端 → 页面显示"网络异常"友好文案,不白屏
7. 全程无报错后提交Git
bash
git add .
git commit -m "前端对接新契约:信封解包 + 统一错误 + 分页 + 收藏页"
六、常见报错:这6个,契约对接的标配(重点!)
① 页面恢复了但列表是空的,控制台无报错
🔍 原因 :信封没解开------data.items写成了data(现在接口返回{items, total})。
✅ 解法 :检查所有api.get的取字段处;拦截器已解了一层axios的data,业务信封的items还要自己取。
② 401后跳登录页,登录成功又被踢回登录页(循环)
🔍 原因:响应拦截器在登录请求本身失败(密码错也是401)时也执行了"清token+跳转"。
✅ 解法 :登录页的请求要跳过401跳转(api.js导出第二个无拦截器实例,或登录请求前打标记)。
③ api.js import store报undefined / 循环引用
🔍 原因:api.js需要读登录态,store又import了api------两个模块互相引用,初始化时序爆炸。
✅ 解法 :api.js保持"零依赖"------登录态一律走localStorage(第4节的window.location方案同理)。
📌 跨模块共享状态,永远从"数据源"读,不从"消费者"倒着引。
④ 点下一页没反应 / 数据没变
🔍 原因 :goPage改了page.value但没调loadArticles(),或params里没传page。
✅ 解法 :翻页 = 改页码 + 重新拉取,两步缺一不可。
⑤ 搜索"vue"翻到第3页显示空
🔍 原因 :搜索时没有重置page=1------旧的页码带到新关键词上。
✅ 解法 :onSearch里先page.value = 1再加载(第2节写法)。
📌 关键词变了,页码归零------搜索与分页的耦合点。
⑥ 登录成功后导航栏用户名空白
🔍 原因 :login只存了token没拉/auth/me;或me请求在token写入前发出(拦截器读到空token)。
✅ 解法 :先token.value = data.access_token(watch持久化随之触发)再await api.get('/auth/me')------顺序不能反。
七、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | 取消收藏 :契约表增补DELETE /api/favorites/{id},后端实现,前端收藏页加"取消收藏"按钮 |
⭐⭐⭐ | 走一遍"改契约 → 改后端 → 改前端"的完整演进流程 |
| 2 | PaginationBar打磨:页码过多时显示省略号(1 ... 5 6 7 ... 20) | ⭐⭐⭐ | 真实分页条的隐藏学问 |
| 3 | 错误提示组件:做一个全局Toast(顶部滑入的错误横幅) | ⭐⭐ | 统一错误除了文案还能"看得见" |
| 4(选做) | keep-alive记忆:列表页切走再回来,记住滚动位置与页码 | ⭐⭐⭐ | 研究<KeepAlive> |
📦 配套代码
前端全部改动已上传Git(python_daily/):【gitee仓库地址】