【Web全栈进阶】前端对接新契约:信封解包 + 统一错误处理

后端契约立好,前端按"合同"施工: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仓库地址】

相关推荐
Python图像识别1 小时前
56-【2027毕设】YOLO11X光安检危险物品检测系统 - Python完整源码+PyQt5界面+训练模型+数据集
python·qt·课程设计
Q26433650231 小时前
【有源码】基于SpringBoot+Vue的居民健康体检与慢病干预管理系统-面向智慧医疗的体检预约与健康知识服务平台
java·vue.js·spring boot·mysql·毕业设计·javaweb·课程设计
国服第二切图仔1 小时前
Chrome 插件开发实战指南:从入门到发布
前端·chrome
winfredzhang2 小时前
从零实现“照片按地理位置显示在矢量地图上”:Flask + SQLite + Leaflet 全栈实战
python·sqlite·flask
鬼手点金2 小时前
ReAct 循环
linux·服务器·前端·javascript·react.js·前端框架
鬼手点金2 小时前
上下文:Agent 的眼睛
java·前端·人工智能·git·深度学习
雪芽蓝域zzs2 小时前
Vue导入方法三种方式对比
前端·javascript·vue.js
外收内放2 小时前
Python基础语法练习题(72)
开发语言·python
定栓2 小时前
Webpack迁到Vite第八节:错误排查实录
前端·webpack·devops