Skip to content

前端状态与模块设计 ​

问题:切换详情后看到了上一条数据 ​

用户打开任务 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 改变时整体失效旧请求。写操作附稳定幂等键,冲突刷新服务端事实,不能把旧支付结果套在新订单上。

继续阅读:契约与错误、业务投影。

理解原理 · 分析取舍 · 用真实代码检验