# 后台管理独立前端工程技术方案 日期:`2026-04-30` 对应 PRD:[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md) 落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试和兑换码管理首版页面;根工程已补 `admin-web:*` 转发脚本。 ## 1. 结论 后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染;所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`。 本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接。 ## 2. 工程结构 建议首版结构: ```text apps/ └─ admin-web/ ├─ index.html ├─ package.json ├─ tsconfig.json ├─ vite.config.ts └─ src/ ├─ main.tsx ├─ app/ │ ├─ AdminApp.tsx │ ├─ AdminShell.tsx │ └─ adminRoutes.ts ├─ api/ │ ├─ adminApiClient.ts │ └─ adminApiTypes.ts ├─ auth/ │ └─ adminAuthStore.ts ├─ pages/ │ ├─ AdminLoginPage.tsx │ ├─ AdminOverviewPage.tsx │ ├─ AdminDebugHttpPage.tsx │ └─ AdminRedeemCodePage.tsx └─ styles/ └─ admin.css ``` 首版可使用独立 `package.json`,不要求立刻把根工程改成 npm workspace。后续如果根工程统一 workspace,再把 `apps/admin-web` 纳入统一脚本。 ## 3. 技术栈 1. React + TypeScript + Vite。 2. 图标使用 `lucide-react`。 3. 样式首版使用普通 CSS 或 CSS Modules,不引入新的大型 UI 组件库。 4. 请求使用浏览器 `fetch` 封装,不新增状态管理库。 5. 不引入 SpacetimeDB TypeScript SDK;管理端不直连 SpacetimeDB。 ## 4. API 边界 ### 4.1 基础约定 所有管理端请求使用同一个 `adminApiClient`: 1. base URL 由 `VITE_ADMIN_API_BASE_URL` 配置。 2. 未配置时默认同源空前缀。 3. 有 token 时附加 `Authorization: Bearer `。 4. 后端统一响应 envelope 时,前端读取 `data`;错误优先读取 `error.details.message`,再读 `error.message`,最后回退到 HTTP 状态。 前端统一按以下响应形状解析,不在页面组件里重复拆 envelope: ```ts export interface ApiSuccessEnvelope { data: T; meta?: unknown; } export interface ApiErrorEnvelope { error?: { code?: string; message?: string; details?: { message?: string; [key: string]: unknown; } | null; }; meta?: unknown; } ``` `adminApiClient` 暴露 `request()`、`get()`、`post()` 三层即可。页面只拿到成功数据或抛出的中文错误消息,不直接处理 `Response`。 ### 4.2 已有管理接口 | 功能 | 方法与路径 | 鉴权 | | --- | --- | --- | | 管理员登录 | `POST /admin/api/login` | 无 | | 当前管理员 | `GET /admin/api/me` | 管理员 Bearer | | 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer | | 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer | | 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer | | 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer | ### 4.3 前端类型命名 后台前端首版不引入自动生成 contract。为了避免字段漂移,`apps/admin-web/src/api/adminApiTypes.ts` 必须按 `shared-contracts` 的 camelCase JSON 字段命名: ```ts export interface AdminSessionPayload { subject: string; username: string; displayName: string; roles: string[]; issuedAt: string; expiresAt: string; } export interface AdminLoginResponse { token: string; admin: AdminSessionPayload; } export interface AdminOverviewResponse { service: AdminServiceOverviewPayload; database: AdminDatabaseOverviewPayload; } export interface AdminServiceOverviewPayload { bindHost: string; bindPort: number; jwtIssuer: string; adminEnabled: boolean; spacetimeServerUrl: string; spacetimeDatabase: string; } export interface AdminDatabaseOverviewPayload { databaseIdentity: string | null; ownerIdentity: string | null; hostType: string | null; schemaTableNames: string[]; tableStats: AdminDatabaseTableStatPayload[]; fetchErrors: string[]; } export interface AdminDatabaseTableStatPayload { tableName: string; rowCount: number | null; errorMessage: string | null; } export interface AdminDebugHeaderInput { name: string; value: string; } export interface AdminDebugHttpRequest { method: string; path: string; headers?: AdminDebugHeaderInput[]; body?: string; } export interface AdminDebugHttpResponse { status: number; statusText: string; headers: AdminDebugHeaderInput[]; bodyText: string; bodyJson: unknown | null; } ``` 兑换码类型同样保持 camelCase: ```ts export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export interface AdminUpsertProfileRedeemCodeRequest { code: string; mode: ProfileRedeemCodeMode; rewardPoints: number; maxUses: number; enabled: boolean; allowedUserIds: string[]; allowedPublicUserCodes: string[]; } export interface AdminDisableProfileRedeemCodeRequest { code: string; } export interface ProfileRedeemCodeAdminResponse { code: string; mode: ProfileRedeemCodeMode; rewardPoints: number; maxUses: number; globalUsedCount: number; enabled: boolean; allowedUserIds: string[]; createdBy: string; createdAt: string; updatedAt: string; } ``` ### 4.4 登录 contract 请求: ```json { "username": "root", "password": "secret123" } ``` 成功数据: ```json { "token": "", "admin": { "subject": "admin:root", "username": "root", "displayName": "root", "roles": ["admin"], "issuedAt": "2026-04-30T00:00:00Z", "expiresAt": "2026-04-30T04:00:00Z" } } ``` `503` 表示后台未启用;`401` 表示用户名或密码错误。 ### 4.5 总览 contract `GET /admin/api/overview` 返回: 1. `service`:`bindHost`、`bindPort`、`jwtIssuer`、`adminEnabled`、`spacetimeServerUrl`、`spacetimeDatabase`。 2. `database`:`databaseIdentity`、`ownerIdentity`、`hostType`、`schemaTableNames`、`tableStats`、`fetchErrors`。 后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。 后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。 `tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。 ### 4.6 API 调试 contract 请求: ```json { "method": "GET", "path": "/healthz", "headers": [], "body": "" } ``` 限制由后端执行: 1. `path` 只允许同源相对路径。 2. 禁止绝对 URL。 3. 禁止调试 `/admin/api/login`。 4. 禁止覆盖危险请求头。 5. 请求体大小和超时由后端收口。 ### 4.7 兑换码管理 contract 创建/更新请求: ```json { "code": "WELCOME2026", "mode": "public", "rewardPoints": 100, "maxUses": 1, "enabled": true, "allowedUserIds": [], "allowedPublicUserCodes": [] } ``` 停用请求: 兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。 ```json { "code": "WELCOME2026" } ``` 成功返回兑换码记录: ```json { "code": "WELCOME2026", "mode": "public", "rewardPoints": 100, "maxUses": 1, "globalUsedCount": 0, "enabled": true, "allowedUserIds": [], "createdBy": "admin:root", "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" } ``` 前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。 ## 5. 鉴权与会话 1. token key 固定为 `genarrative_admin_token`。 2. token 首版存 localStorage。 3. 应用启动时如果存在 token,先调用 `GET /admin/api/me`。 4. `401` 时清空 token 并回到登录页。 5. `403` 时展示无权限状态,不自动重试。 6. 退出登录只清理本地 token;首版没有后台 refresh session 和服务端会话吊销。 ## 6. 页面实现要点 1. `AdminShell` 承载导航、当前管理员、退出按钮和页面容器。 2. 登录页不进入 `AdminShell`,避免未登录时展示后台导航。 3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`。 4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`。 5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。 6. 所有按钮的 loading 状态必须锁定重复提交。 7. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。 ## 7. 部署与联调 ### 7.1 本地联调 1. 启动后端:`npm run api-server:maincloud`。 2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`。 3. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到 `ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`。 4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:`,并重启后台前端 dev server。 5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口。 ### 7.2 构建部署 首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择: 1. 独立静态站点域名,例如 `https://admin.example.com`。 2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标。 无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。 ### 7.3 后台工程脚本 `apps/admin-web/package.json` 首版至少提供以下脚本: ```json { "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "preview": "vite preview --host 127.0.0.1" } } ``` 如果后续接入根 npm workspace,再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。 当前根工程同步提供以下转发脚本: 1. `npm run admin-web:dev` 2. `npm run admin-web:typecheck` 3. `npm run admin-web:build` 4. `npm run admin-web:preview` ## 8. 测试计划 1. `apps/admin-web`: - 登录成功和失败。 - token 恢复、过期清理、退出登录。 - 总览页正常数据、部分表统计失败、整体请求失败。 - API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。 - 兑换码 public/unique/private 表单提交和停用。 2. 根工程: - `npm run check:encoding`。 - 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。 3. 后端: - 继续保留 `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`。 - 修改后端管理 API 后必须运行 `npm run api-server:maincloud` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。 ## 9. 后续扩展边界 后续新增用户管理、作品审核、资产审核、订单/充值管理时,必须先补对应 PRD 和技术方案,并在 `server-rs` 增加受保护管理 API。不要让 `apps/admin-web` 直接读取 SpacetimeDB 或复制业务规则。 ## 10. 实施顺序 1. 先创建 `apps/admin-web` 工程骨架,确保空应用可 `dev/build`。 2. 再实现 `adminApiTypes` 与 `adminApiClient`,用 `/admin/api/login` 做第一条真实链路。 3. 接入 `adminAuthStore` 和启动恢复逻辑,确认 `401` 会清理本地 token。 4. 完成 `AdminShell` 与三页路由,再分别接入总览、API 调试和兑换码接口。 5. 最后补测试、运行 `npm run check:encoding`,并确认 `GET /admin` 仍由 `api-server` 返回 `404`。 当前实现已完成第 1 至第 4 步。验证以实际命令输出为准。