【Web全栈进阶】前端对接新契约:信封解包 + 统一错误处理 后端契约立好前端按“合同”施工baseURL换/api、列表解信封、错误统一渲染、收藏页接真接口——前端断掉的页面今天整体接回来。本篇产出早报站前端全面恢复 分页组件 登录态完整打通 统一错误处理。含代码约140行。 太长不看版给想快速上手的你项目信息一句话说明本篇目标前端按新契约整体对接后端代码行数~140行含注释依赖无新增核心功能信封解包 统一错误 分页 收藏页跑起来的命令npm run dev→ 浏览器验证核心知识点响应拦截器、四态渲染、分页组件做完你能得到一个完整可用的前后端分离产品一、第0步换baseURL 响应拦截器信封解包与错误处理的“总开关”src/lib/api.js全面升级importaxiosfromaxiosconstapiaxios.create({baseURL:http://127.0.0.1:8000/api,// ① 换上新契约的前缀})// 请求拦截器自动带令牌不变api.interceptors.request.use((config){consttokenlocalStorage.getItem(token)if(token)config.headers.AuthorizationBearer${token}returnconfig})// 响应拦截器信封解包 统一错误处理本篇主角api.interceptors.response.use((resp)resp.data,// ② 自动解开axios的response页面拿到直接可用(error){// ③ 统一错误从响应里提取人话或给兜底文案constdetailerror.response?.data?.detail||网络异常请稍后重试// ④ 401全家桶令牌失效 → 清登录态 → 回登录页if(error.response?.status401){localStorage.removeItem(token)localStorage.removeItem(username)if(!window.location.pathname.startsWith(/login)){window.location.href/login}}returnPromise.reject(newError(detail))},)exportdefaultapi 四个设计的价值#设计收益①解包一次全站受益所有页面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.jsimport{ref}fromvueimport{defineStore}frompinia// 搜索状态顶栏搜索框App.vue与首页列表HomeView.vue共享exportconstuseSearchStoredefineStore(search,(){constkeywordref()return{keyword}})src/views/HomeView.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 classpage main classcontainer div classresult-bar template v-ifloading正在抓取今日早报…/template template v-else-iferrorMsg{{ errorMsg }}/template template v-else-ifarticles.length 0暂无文章/template template v-else共 {{ articles.length }} 篇/template /div !-- 骨架屏加载时的占位卡片 -- div v-ifloading classgrid div v-forn in 6 :keyn classskeleton div classsk-line w70/div div classsk-line w100/div div classsk-line w45/div /div /div div v-else-iferrorMsg classstate error-state span classstate-icon⚠️/span p{{ errorMsg }}/p button classbtn-primary clickloadArticles(keyword)重试/button /div div v-else-ifarticles.length 0 classstate span classstate-icon/span p{{ keyword ? 没有匹配「${keyword}」的文章 : 暂无文章 }}/p /div !-- v-for列表渲染:key是每个条目的身份证 -- div v-else classgrid ArticleCard v-fora in articles :keya.id v-binda favoritefavorite template #actions a classread-link :hrefa.url target_blank relnoopener阅读原文 ↗/a /template /ArticleCard PaginationBar :pagepage :total-pagestotalPages changegoPage / /div /main footer classsite-footerPython 早报 · 每日聚合社区动态/footer /div /template 对比旧版的变化#变化说明①数据源从data裸列表变成data.items信封解包的体现②新增page/total/totalPages三兄弟管理分页状态③搜索时先把页码重置回1不然搜到第3页可能一无所获第六节⑤④补充了debounceLoad防抖函数三、第2步PaginationBar——组件化的分页条src/components/PaginationBar.vue第10篇的props/emit正式上岗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 classpager button :disabledpage 1 clickjump(page - 1)上一页/button span第 {{ page }} / {{ totalPages }} 页/span button :disabledpage totalPages clickjump(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 changegoPage /直接复用。四、第3步登录态完整化 收藏页 stores/auth.js的login升级登录成功后调/auth/me拿真实资料OAuth用户也能拿到gh_开头的真名import{computed,ref,watch}fromvueimport{defineStore}frompiniaimportapifrom../lib/apiexportconstuseAuthStoredefineStore(auth,(){consttokenref(localStorage.getItem(token)||)constusernameref(localStorage.getItem(username)||)constuserIdref(Number(localStorage.getItem(userId))||0)// 新增constisLoggedIncomputed(()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)elselocalStorage.removeItem(username)})asyncfunctionlogin(name,password){constformnewURLSearchParams({username:name,password})constdataawaitapi.post(/auth/login,form)token.valuedata.access_token// 先写token拦截器后续请求才带得上// 用契约里的/auth/me拿用户资料不再是表单值凑合constmeawaitapi.get(/auth/me)username.valueme.username userId.valueme.id localStorage.setItem(userId,String(me.id))}functionlogout(){token.valueusername.valueuserId.value0localStorage.removeItem(userId)}return{token,username,userId,isLoggedIn,login,logout}}) FavoritesView.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 classpage main classcontainer !-- 页面标题区 -- header classpage-head div classpage-title span classpage-icon⭐/span div h1我的收藏/h1 p classpage-subtitle你标记过的文章都在这里/p /div /div span v-if!loading !error classcount-chip{{ articles.length }} 篇/span /header !-- 骨架屏加载时的占位卡片 -- div v-ifloading classgrid div v-forn in 6 :keyn classskeleton div classsk-line w70/div div classsk-line w100/div div classsk-line w45/div /div /div !-- 错误态可重试 -- div v-else-iferror classstate error-state span classstate-icon⚠️/span p{{ errorMsg }}/p button classbtn-primary clickload重试/button /div !-- 空态引导去首页 -- div v-else-ifarticles.length 0 classstate span classstate-icon/span p还没有收藏任何文章。/p RouterLink to/ classbtn-primary去看看今日早报 →/RouterLink /div !-- 列表复用首页同款卡片 -- div v-else classgrid ArticleCard v-fora in articles :keya.id v-binda / /div /main footer classsite-footerPython 早报 · 每日聚合社区动态/footer /div /template 四态渲染是列表页的标配状态显示什么loading“加载中…”error错误信息拦截器已转人话empty“还没有收藏去首页逛逛吧”data正常列表每个真实页面都要想清楚这四种状态长什么样第26篇测试策略里“空状态”是单测最爱漏的边界。五、验收清单1. 首页恢复真数据 上一页/下一页可用页码显示正确2. 搜索关键词 → 列表过滤正确且页码重置回13. 登录 → 跳回原页面导航栏显示/auth/me返回的用户名4. “我的收藏” → 列出收藏过的文章无收藏时显示空状态文案5. 手动把localStorage的token改成乱码 → 任意请求401 → 自动清理并跳登录页6. 停掉后端 → 页面显示“网络异常”友好文案不白屏7. 全程无报错后提交Gitgitadd.gitcommit-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页显示空 原因搜索时没有重置page1——旧的页码带到新关键词上。✅ 解法onSearch里先page.value 1再加载第2节写法。关键词变了页码归零——搜索与分页的耦合点。⑥ 登录成功后导航栏用户名空白 原因login只存了token没拉/auth/me或me请求在token写入前发出拦截器读到空token。✅ 解法先token.value data.access_tokenwatch持久化随之触发再await api.get(/auth/me)——顺序不能反。七、课后练习#练习难度提示1取消收藏契约表增补DELETE /api/favorites/{id}后端实现前端收藏页加“取消收藏”按钮⭐⭐⭐走一遍“改契约 → 改后端 → 改前端”的完整演进流程2PaginationBar打磨页码过多时显示省略号1 … 5 6 7 … 20⭐⭐⭐真实分页条的隐藏学问3错误提示组件做一个全局Toast顶部滑入的错误横幅⭐⭐统一错误除了文案还能“看得见”4选做keep-alive记忆列表页切走再回来记住滚动位置与页码⭐⭐⭐研究KeepAlive 配套代码前端全部改动已上传Gitpython_daily/【gitee仓库地址】