搜索 K
Appearance
Appearance
本篇集中讲三个横切关注点:网络层(axios 统一处理)、状态管理(Pinia 三级缓存)、以及贯穿始终的三条约定——雪花 ID 全程字符串、统一错误处理、URL 单一事实源。这些机制一次写好、所有页面共享,是前端「写得薄」的底气。
所有请求都经过 api/http.ts 的同一个 axios 实例,它在四个环节做统一处理:
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。
后端主键是雪花算法生成的 64 位长整型,超出 JS Number 的安全整数范围(2^53),直接 JSON.parse 会精度丢失(末几位变 0)。解法是在反序列化的最早环节用 json-bigint 把长整型转成字符串:
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 块因此刻意留空——错误已经弹过了,再弹一次就是重复:
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)
},
)业务侧因此长这样(注释明确写着「已统一弹错」):
try {
const page = await queryPage(model, req)
// ...
} catch {
/* 已统一弹错 */ // ← 故意空:错误拦截器已经 toast 过了
}| 设计取舍 | 选项 | 选择 | 理由 |
|---|---|---|---|
| 错误提示在哪弹 | A. 每个 catch 各弹各的;B. 拦截器集中弹一次 | B | 杜绝重复弹窗,业务代码更薄 |
| 401 在哪处理 | A. 每处判断跳登录;B. 拦截器统一清 token + 回调 | B | 鉴权失效只有一个处理点 |
http.ts 需要在 401 时跳登录页,但它若直接 import router 会与 router 守卫形成循环依赖。解法是依赖倒置:http.ts 暴露一个注入点,由 main.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.fetchModules | GET /v1/api/meta/modules |
| 菜单树 | meta.fetchMenus | GET /v1/api/meta/modules/{code}/menus |
| 页面 schema | meta.fetchPageSchema | GET /v1/api/meta/pages/{menuCode} |
| 模型视图(调试) | meta.fetchViewSchema | GET /v1/api/meta/views/{model path} |
| 分页查询 | data.queryPage | POST /v1/data/{model}/queryPage |
| 详情 | data.getById | GET /v1/data/{model}/{id} |
| 新建/更新 | data.create / update | POST / PUT /v1/data/{model}[/{id}] |
| 删除/批量删 | data.remove / removeBatch | DELETE /v1/data/{model}[/{id}] |
| 动作端点 | data.callByEndpoint | 解析下发字符串,去 /v1、替 {id} |
| 登录/我/登出 | auth.* | /v1/api/auth/{login,me,logout} |
toModelPath 把 biz.product 转成 URL 路径 biz/product(点号转斜杠 + 转小写)。注意:路径由前端用 model 现算,并不消费 schema 里下发的 modelPath(见协议消费映射)。
stores/app.ts 维护三层元数据缓存,越靠上越「全局」、越少变化:
| 缓存 | 键 | 何时加载 | 命中策略 |
|---|---|---|---|
modules | 全局 | 首次进站 | 有值即返回(除非 force) |
menusByModule | 模块编码 | 进某模块 / loadAllMenus | 按模块命中 |
pageCache | menuCode | 进某列表/表单/详情 | 按菜单命中 |
loadAllMenus(命令面板/树/图标栏需要全量菜单)对单模块失败做 catch 降级为空树,不让一个模块拖垮整棵。navMode(导航范式)、treeOpen(树展开态)、recent(最近访问)持久化到 localStorage。stores/auth.ts 则管鉴权态:token 存 localStorage(刷新保持登录),user 只存内存(刷新后需 loadMe 重拉)。
列表页的全部交互状态(翻页/关键词/排序/高级筛选/页签)都编码进 URL query,组件不另存。listQuery.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 query | B | 刷新/分享/前进后退天然可还原 |
| 默认值是否写 URL | A. 全写;B. 等于默认就省略 | B | URL 干净、可读、可手改 |
| 状态→请求 | A. 组件里手拼;B. buildQueryRequest 统一构建 | B | 操作符来自字段 schema,逻辑收敛一处 |
buildQueryRequest 用每个字段 schema 的 operator(如 name 用 like、status 用 eq)把搜索值转成后端 FilterClause[]——操作符是后端推导、随 schema 下发的,前端不臆测。