# Zerx Frontend

> gettopic 前端开发规约(React 19 + TanStack + connect-query + Zod4 + Tailwind v4)。当新增/修改路由页、CRUD 列表/表单、调用 connect-query hooks、做缓存失效、i18n、主题、按钮样式时使用。Keywords: React, TanStack Router, react-query, useQuery, useMutation, connect-query, createConnectQueryKey, invalidateQueries, react-form, Zod, Tailwind v4, bigint, uint64, table v8, i18n, 主题, theme, 语义 token, Can, permissions, gcTime, placeholderData, Button, outline, ghost, 按钮, 透明, 按钮背景, 前端, frontend, bun, pnpm

- Skill: `zerx-lab/zerx-frontend` (Agent Skill)
- Install (CLI): `npx skillmds@latest add zerx-lab/zerx-frontend`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zerx-lab/zerx-frontend/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: zerx-lab (https://skillmd.com/u/zerx-lab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zerx-lab/zerx-frontend

---

# gettopic 前端开发规约

> Claude 已熟悉 React 19 / TanStack / Zod;以下是 gettopic 特有规则。最全范式页:`web/src/routes/_authed/users.tsx`(次选 `params.tsx`)。

## 数据访问与失效
- hook 导入:`import { createConnectQueryKey, useMutation, useQuery } from "@connectrpc/connect-query"`。
- method 导入路径形状:`@/gen/zerx/v1/<entity>-<Service>_connectquery`,如 `@/gen/zerx/v1/user-UserService_connectquery`(导出 `listUsers/createUser/updateUser/deleteUser`)。注意 entity 前缀按文件名,如 `site-SiteSettingsService_connectquery`。
- 查询:`useQuery(listUsers, { page: { page, pageSize }, keyword })`;无入参 `useQuery(listRoles)`。
- 变更:`const m = useMutation(createUser); await m.mutateAsync(value)`;loading 用 `m.isPending`。
- 失效(**确切对象参数**):
  ```ts
  queryClient.invalidateQueries({
    queryKey: createConnectQueryKey({ schema: listUsers, cardinality: "finite" }),
  });
  ```

## 坑表
| 错误写法 | 正解 |
|---|---|
| `cacheTime` / `isLoading` | react-query v5:`gcTime` / `isPending` / `placeholderData` |
| `tailwindcss-animate` | Tailwind v4 动画用 `tw-animate-css` |
| 改 `tailwind.config.js` / `postcss.config.js` | 二者**不存在**;主题变量在 `web/src/styles.css` |
| `z.string().email()` | Zod4 顶层格式函数 `z.email()` |
| 期望 `onSubmit` 拿到 transform 后值 | Standard Schema **不 transform**,拿到的是输入值;object 级 `.refine/.check` 仅基础字段通过后运行 |
| 拦截器数组首项先执行 | connect `Interceptor` 数组**末尾先执行**(洋葱) |
| `id` 当 number | `uint64`→`bigint`;显示 `String(id)`,列/key 用 `String(info.getValue())` |
| 用 shadcn `form` / react-hook-form | 表单用 `@tanstack/react-form` |
| 依赖 `exactOptionalPropertyTypes` | 已**关闭**(与 shadcn/Radix 不兼容);其余严格 flag(`strict`/`noUncheckedIndexedAccess`/`noUnused*`)保留 |
| `pnpm` / `npm` 装前端依赖 | 包管理器是 bun(`web/packageManager: bun@1.4.0`)。进 `web/` 再 `bun install` / `bun run <script>`(`bun build` 是 bun 自带打包器,不是 vite) |

## 表格(react-table v8)
`const columnHelper = createColumnHelper<User>()` → `columnHelper.accessor("col", { header, cell })` / `columnHelper.display({ id, header, cell })` → `useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })` → `flexRender`。

## 表单(react-form)
`useForm({ defaultValues, validators: { onChange: zodSchema }, onSubmit: async ({ value }) => … })`;`<form.Field name="...">`;错误用 `firstErrorMessage(field.state.meta.errors)`(`web/src/lib/form.ts`);可复用字段组件用 `AnyFieldApi` 类型。

## i18n(`web/src/lib/i18n/index.tsx` + `locales/{en,zh}.ts`)
- 结构锁:`locales/en.ts` 导出 `en`,`locales/zh.ts` 为 `zh: typeof en`。新增文案 **en/zh 同步加同名 key**(缺 key 编译失败;勿用可选属性,否则结构锁失效)。
- 用法:`const { t, locale, setLocale, toggleLocale } = useI18n(); t(key, params?)`;缺失回退 en 再回退 key。

## 主题(`web/src/lib/theme.tsx`)
- `const { theme, setTheme, toggleTheme } = useTheme()`。
- 样式一律用**语义 token**(`bg-background` / `text-foreground` 等,定义于 `web/src/styles.css` 的 `:root`/`.dark`,`@theme inline` 映射),**勿写死颜色**。
- Toaster 主题由 `__root.tsx` 透传 `useTheme()`。

## 按钮(`web/src/components/ui/button.tsx`)
页级操作默认不透明白底;`ghost`/`link` 才是显式透明。

| 场景 | variant |
|---|---|
| 主操作(新建) | `default`(primary) |
| 次操作(导出/清理/搜索/分页/取消) | `outline`(`bg-card` 白底,禁 `bg-background`/`bg-transparent`) |
| 顶栏 icon、表格行内编辑删除 | `ghost`(唯一默认透明变体;须明确要透明才用) |
| 危险确认 | `destructive` |

禁止在 `Button` 上覆写 `bg-background` / `bg-transparent`(与页面灰底融为一体)。

## 权限显隐(纯 UX,非安全边界)
- `<Can code="user:create">…</Can>`(组件 `web/src/components/can.tsx`,props `{ code, children }`)。
- 判定源 `web/src/lib/permissions.tsx`:`usePermissions().can(code)` = `roles.includes("admin") || codes.has(code)`(`roles: string[]` 多角色;接口 `PermissionContextValue{ roles, can, isLoading }`,`isLoading` = `me` 或 `getUserButtons` 任一 `isPending`,页面据此渲染骨架而非误判无权限;`me` 返 `user.roles`);数据来自 `me` + `getUserButtons` query。
- 真正鉴权在同名 procedure 的 Casbin 策略上,见 `skill://zerx-authz`。

## lib 速查
- `transport.ts`:`transport`、`authedFetch(input, init?)`(401 single-flight 刷新→重试一次→失败清 token 跳登录)。
- `query-client.ts`:`queryClient`(staleTime 30s, gcTime 5min, retry 1)。
- `auth.ts`:`getAccessToken/setTokens/clearTokens/isAuthenticated/auth`。
- `menu-icons.ts`:`iconByName: Record<string, LucideIcon>`、`menuIcon(name)`(fallback `CircleIcon`)。
- 生成物命名:`web/src/gen/zerx/v1/<entity>_pb.ts`、`<entity>-<Service>_connectquery.ts`、`web/src/gen/buf/validate/validate_pb.ts`。
- 路由:`web/vite.config.ts` 用 `tanstackRouter({ target:"react", autoCodeSplitting:true })` 生成并提交 `src/routeTree.gen.ts`;404 / 错误边界在 `router.tsx`(`defaultNotFoundComponent` / `defaultErrorComponent` → `components/{not-found,error-view}.tsx`,`error-view` 按 `ConnectError.code` 区分 403 / 服务器错误)。
- CSP:后端对 SPA 下发 `script-src 'self'`,**禁止 inline `<script>`**;主题预加载脚本在 `web/public/theme-init.js`(`index.html` 以 `src` 引用)。
- 构建:Vite 8 底层是 rolldown,**不用 `manualChunks`**(兼容层会递归吞依赖);分包用 `rollupOptions.output.codeSplitting.groups`(first-match-wins):`vendor`(react/react-dom/scheduler/@tanstack)→ `connect`(@connectrpc/@bufbuild)→ `charts`(recharts + d3-*,`includeDependenciesRecursively:false`);`sourcemap:false`、`target:"es2022"`。
- 测试:vitest + jsdom + Testing Library(`web/vitest.config.ts`,`bun run test`,文件 `src/**/*.test.{ts,tsx}`,显式 `import { … } from "vitest"`);现有用例 `lib/transport.test.ts`(401 单飞刷新)、`lib/permissions.test.tsx`。

## 源码锚点
`web/src/routes/_authed/users.tsx`、`web/src/components/ui/button.tsx`、`web/src/components/can.tsx`、`web/src/lib/{permissions.tsx,i18n.tsx,transport.ts,theme.tsx,query-client.ts,form.ts,auth.ts,menu-icons.ts}`。

