Skip to content

网络层与状态

本篇集中讲三个横切关注点:网络层(axios 统一处理)、状态管理(Pinia 三级缓存)、以及贯穿始终的三条约定——雪花 ID 全程字符串、统一错误处理、URL 单一事实源。这些机制一次写好、所有页面共享,是前端「写得薄」的底气。

网络层:一个 axios 实例兜住所有约定

所有请求都经过 api/http.ts 的同一个 axios 实例,它在四个环节做统一处理:

ts
const instance = axios.create({
  baseURL: '/v1',
  timeout: 20000,
  transformResponse: [parseBigJson],   // 用 json-bigint 解析,长整型转字符串
})

// 请求:自动带 token
instance.interceptors.request.use((config) => {
  const token = getToken()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// 业务调用统一解包:Result<T> → T
export async function request<T>(config): Promise<T> {
  const resp = await instance.request<Result<T>>(config)
  return resp.data.data
}

request<T> 把后端统一响应 Result<T>{success, code, message, data})解包成 data,业务侧拿到的就是纯净的 T,不必每次 resp.data.data

雪花 ID 全程字符串

后端主键是雪花算法生成的 64 位长整型,超出 JS Number 的安全整数范围(2^53),直接 JSON.parse 会精度丢失(末几位变 0)。解法是在反序列化的最早环节json-bigint 把长整型转成字符串:

ts
const jsonBig = JSONbig({ storeAsString: true })
function parseBigJson(raw) {
  if (typeof raw !== 'string' || raw.length === 0) return raw
  try { return jsonBig.parse(raw) } catch { return raw }
}

切勿对 ID 做 Number()

ID 从反序列化那一刻起就是字符串,路由参数、请求体、行 key 全程保持字符串。任何 Number(id) / +id / parseInt(id) 都可能在末位精度上翻车。这是最隐蔽、最该牢记的一条约定。

统一错误处理

响应拦截器集中处理所有错误,业务侧的 catch 块因此刻意留空——错误已经弹过了,再弹一次就是重复:

ts
instance.interceptors.response.use(
  (resp) => {
    const body = resp.data as Result<unknown>
    if (body?.success === false) {
      if (body.code === 40100 || body.code === 40101) { clearToken(); onUnauthorized?.() }
      toast.error(body.message || '请求失败')          // 集中弹错
      return Promise.reject(new Error(body.message))
    }
    return resp
  },
  (error) => {
    const status = error?.response?.status
    if (status === 401 || ...) { clearToken(); onUnauthorized?.() }
    toast.error(...)                                    // 集中弹错
    return Promise.reject(error)
  },
)

业务侧因此长这样(注释明确写着「已统一弹错」):

ts
try {
  const page = await queryPage(model, req)
  // ...
} catch {
  /* 已统一弹错 */     // ← 故意空:错误拦截器已经 toast 过了
}
设计取舍选项选择理由
错误提示在哪弹A. 每个 catch 各弹各的;B. 拦截器集中弹一次B杜绝重复弹窗,业务代码更薄
401 在哪处理A. 每处判断跳登录;B. 拦截器统一清 token + 回调B鉴权失效只有一个处理点

鉴权与 401 处理

http.ts 需要在 401 时跳登录页,但它若直接 import router 会与 router 守卫形成循环依赖。解法是依赖倒置:http.ts 暴露一个注入点,由 main.ts 把「跳登录」回调注进来。

ts
// http.ts:只声明回调,不认识 router
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn) { onUnauthorized = fn }

// main.ts:注入实现,打破 http ↔ router 的环
setUnauthorizedHandler(() => {
  if (router.currentRoute.value.name !== 'login')
    router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
})

端点一览

用途函数方法 + 路径
模块列表meta.fetchModulesGET /v1/api/meta/modules
菜单树meta.fetchMenusGET /v1/api/meta/modules/{code}/menus
页面 schemameta.fetchPageSchemaGET /v1/api/meta/pages/{menuCode}
模型视图(调试)meta.fetchViewSchemaGET /v1/api/meta/views/{model path}
分页查询data.queryPagePOST /v1/data/{model}/queryPage
详情data.getByIdGET /v1/data/{model}/{id}
新建/更新data.create / updatePOST / PUT /v1/data/{model}[/{id}]
删除/批量删data.remove / removeBatchDELETE /v1/data/{model}[/{id}]
动作端点data.callByEndpoint解析下发字符串,去 /v1、替 {id}
登录/我/登出auth.*/v1/api/auth/{login,me,logout}

toModelPathbiz.product 转成 URL 路径 biz/product(点号转斜杠 + 转小写)。注意:路径由前端model 现算,并不消费 schema 里下发的 modelPath(见协议消费映射)。

状态管理:Pinia 三级缓存

stores/app.ts 维护三层元数据缓存,越靠上越「全局」、越少变化:

缓存何时加载命中策略
modules全局首次进站有值即返回(除非 force
menusByModule模块编码进某模块 / loadAllMenus按模块命中
pageCachemenuCode进某列表/表单/详情按菜单命中
  • 元数据与业务数据分离:这三级缓存只存元数据(结构),业务记录从不进缓存、每次按 URL 状态实时查。所以切页签、翻页只重发数据请求,不重发 schema。
  • 降级不阻塞loadAllMenus(命令面板/树/图标栏需要全量菜单)对单模块失败做 catch 降级为空树,不让一个模块拖垮整棵。
  • 壳偏好持久化navMode(导航范式)、treeOpen(树展开态)、recent(最近访问)持久化到 localStorage

stores/auth.ts 则管鉴权态:tokenlocalStorage(刷新保持登录),user 只存内存(刷新后需 loadMe 重拉)。

URL 单一事实源

列表页的全部交互状态(翻页/关键词/排序/高级筛选/页签)都编码进 URL query,组件不另存。listQuery.ts 提供一组对称的纯函数:

ts
// 解析:URL → 状态
export function parseListState(query): ListState { /* page/size/kw/sort/filters */ }
// 序列化:状态 → URL(仅写非默认项,URL 保持干净)
export function toRouteQuery(state): Record<string,string> {
  const q = {}
  if (state.page !== 1) q.page = String(state.page)   // 默认值不写进 URL
  if (state.keyword) q.kw = state.keyword
  if (state.sort) q.sort = `${state.sort.field}.${state.sort.order}`
  // filters 仅非空时写为 JSON 串
  return q
}
// 构建:状态 + 字段操作符 → 后端查询请求
export function buildQueryRequest(state, operators): QueryRequest { ... }
设计取舍选项选择理由
列表状态存哪A. 组件内部 ref;B. URL queryB刷新/分享/前进后退天然可还原
默认值是否写 URLA. 全写;B. 等于默认就省略BURL 干净、可读、可手改
状态→请求A. 组件里手拼;B. buildQueryRequest 统一构建B操作符来自字段 schema,逻辑收敛一处

buildQueryRequest 用每个字段 schema 的 operator(如 namelikestatuseq)把搜索值转成后端 FilterClause[]——操作符是后端推导、随 schema 下发的,前端不臆测。

下一步