切换主题
前端状态与模块设计
问题:切换详情后看到了上一条数据
用户打开任务 A,慢请求还没返回就切到 B。B 的请求先成功,A 的迟到响应随后覆盖页面。后端可能完全正确,前端却显示错误事实。
前端同样需要处理身份、并发和状态边界。URL 表达可分享的导航状态,页面保存交互状态,服务端拥有任务事实,缓存只是这些事实的副本。
原理:按状态所有者组织代码
路由与 feature 模块定义功能入口;共享 UI 只承载稳定展示能力;API 层处理协议、身份和错误;页面编排加载与操作。不要把某页的请求、筛选、错误和领域逻辑都塞进全局 Store。
加载、错误、空数据与成功是不同状态;多个独立请求应有独立状态,避免一个失败让其他已完成区域消失。写成功后重新查询或更新权威版本,不能仅靠改一个本地 badge 推断操作终结。
最小例子:拒绝陈旧响应
ts
// 教学片段:展示 request generation,不是项目当前页面的逐字实现。
let generation = 0;
async function load(id: string) {
const current = ++generation;
loading.value = true;
error.value = '';
try {
const result = await api.getTask(id);
if (current === generation) task.value = result;
} catch (cause) {
if (current === generation) error.value = describe(cause);
} finally {
if (current === generation) loading.value = false;
}
}AbortSignal 可取消等待,generation 防止已返回或不可取消请求的陈旧结果覆盖。卸载也应失效旧请求。不能只保护 data 却忘记旧请求的 finally 清掉新 loading。
方案比较
组件局部状态适合短生命周期表单;URL 适合筛选、页码与定位;全局 Store 适合主题、会话和跨页稳定状态;服务端查询缓存适合数据复用,但要定义失效、归属与版本。
普通 TypeScript 类型在编译期有效,服务端响应还可能缺字段或变版本。关键边界需要运行时验证或明确信任契约。乐观更新适合可回滚动作;执行取消等异步命令最好展示处理中而非立即假定完成。
真实案例:功能路由与统一客户端
管理前端按 feature 组合 routes,再放入统一 ConsoleLayout;路由守卫等待 authReady 后检查会话。API 客户端提供超时、CSRF、错误格式化、幂等键和 GET 的 AbortSignal 参数。
已核对的实现 · 本地代码快照
按功能组合路由 frontend · b0308f40
src/router/index.ts · 第 1–50 行
符号:router · 核对日期 2026-10-02
来源与提交版本一致
import { createRouter, createWebHistory } from "vue-router";
import { applicationRoutes } from "@/features/applications/routes";
import { auditRoutes } from "@/features/audit/routes";
import { authRoutes } from "@/features/auth/routes";
import { callbackRoutes } from "@/features/callbacks/routes";
import { definitionRoutes } from "@/features/definitions/routes";
import { fleetRoutes } from "@/features/fleet/routes";
import { overviewRoutes } from "@/features/overview/routes";
import { systemRoutes } from "@/features/system/routes";
import { taskRoutes } from "@/features/tasks/routes";
import { authReady, user } from "@/auth";
const router = createRouter({
history: createWebHistory("/"),
routes: [
...authRoutes,
{
path: "/",
component: () => import("@/layouts/ConsoleLayout.vue"),
children: [
{ path: "", redirect: "/overview" },
...overviewRoutes,
...systemRoutes,
...taskRoutes,
...fleetRoutes,
...applicationRoutes,
...definitionRoutes,
...callbackRoutes,
...auditRoutes,
],
},
{
path: "/:pathMatch(.*)*",
component: () => import("@/shared/pages/NotFound.vue"),
},
],
});
router.beforeEach(async (to) => {
if (to.meta.public) return;
await authReady;
if (
to.path !== "/login" &&
(!user.value || Date.parse(user.value.expires_at) <= Date.now())
)
return { path: "/login", query: { returnTo: to.fullPath } };
});
export default router;
片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
CSRF、错误与幂等请求 frontend · b0308f40
src/api/client.ts · 第 1–60 行
符号:http / write · 核对日期 2026-10-02
来源与提交版本一致
import axios from "axios";
import { user } from "@/auth";
import type { Resource, ResourcePage } from "./types";
export const http = axios.create({ baseURL: "/api/v1", timeout: 20000 });
http.interceptors.request.use((c) => {
if (user.value && !["get", "head", "options"].includes(c.method || "get"))
c.headers["X-CSRF-Token"] = user.value.csrf_token;
return c;
});
export function errorText(e: unknown): string {
if (axios.isAxiosError(e)) {
const p = e.response?.data;
return (
(p?.detail || p?.title || e.message) +
(p?.request_id ? `(请求 ${p.request_id})` : "")
);
}
return e instanceof Error ? e.message : String(e);
}
http.interceptors.response.use(
(r) => r,
async (e) => {
if (e.response?.status === 401) {
user.value = null;
if (!location.pathname.endsWith("/login")) location.assign("/login");
}
return Promise.reject(e);
},
);
export async function get<T>(
path: string,
params?: object,
signal?: AbortSignal,
) {
return (await http.get<T>(path, { params, signal })).data;
}
export async function write<T = Resource>(
path: string,
body: unknown,
key: string,
method: "post" | "put" | "patch" | "delete" = "post",
) {
return (
await http.request<T>({
url: path,
method,
data: body,
headers: { "Idempotency-Key": key },
})
).data;
}
export async function choices(path: string) {
const items: Resource[] = [];
for (let page = 1; ; page++) {
const p = await get<ResourcePage>(path, { page, page_size: 200 });
items.push(...p.items);
if (items.length >= p.total || p.items.length === 0) return items;
}
}
片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
会话与登录状态 frontend · b0308f40
src/auth.ts · 第 2–46 行
符号:authReady · 核对日期 2026-10-02
来源与提交版本一致
export interface AdminSession {
principal_id: string;
email: string;
csrf_token: string;
expires_at: string;
}
export const user = ref<AdminSession | null>(null);
export const authError = ref("");
export const isAuthenticated = computed(
() => !!user.value && Date.parse(user.value.expires_at) > Date.now(),
);
export const authReady = fetch("/api/v1/auth/session", {
credentials: "same-origin",
cache: "no-store",
})
.then(async (r) => {
if (r.status === 401) {
user.value = null;
return;
}
if (!r.ok) throw new Error("无法读取登录会话,请检查服务状态");
user.value = await r.json();
})
.catch((e) => {
authError.value = e instanceof Error ? e.message : String(e);
user.value = null;
});
export function login(returnTo = "/overview") {
const target = new URL("/api/v1/auth/login", location.origin);
target.searchParams.set("return_to", returnTo);
location.assign(target.pathname + target.search);
}
export async function logout() {
if (user.value) {
const r = await fetch("/api/v1/auth/logout", {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRF-Token": user.value.csrf_token },
});
if (!r.ok) throw new Error("退出失败,请重试");
}
user.value = null;
location.assign("/login");
}
片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
这些证据说明公共协议协作集中在客户端层,不证明每一页面都已处理全部请求竞争;案例分析应逐页核实,而不能从客户端支持 signal 推断全站具备取消控制。
失败与边界:桌面布局不能直接缩到手机
密集管理表、图编辑器与学习长文的交互目标不同。手机应使用单栏、抽屉目录、局部滚动与触控目标;导航、搜索和图解不能仅支持 hover。保持阅读位置与章节锚点,避免固定栏遮住正文。
接口错误需说明是状态冲突、权限不足还是网络问题;保留用户输入并提供安全重试。取消 HTTP 请求不意味着后台业务动作已取消。
迁移练习与参考答案
练习:订单详情包含订单、发货记录与支付状态,支付 API 失败。怎样展示?
参考答案:三块数据分别管理 loading/error,已成功块继续展示。支付区域明确不可用并可重试;路由 ID 改变时整体失效旧请求。写操作附稳定幂等键,冲突刷新服务端事实,不能把旧支付结果套在新订单上。