# 后台管理独立前端工程技术方案 日期:`2026-04-30` 对应 PRD:[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md) 落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。`2026-05-01` 起,根构建与 Ubuntu 发布包会同步构建后台前端,并在发布包 Web 网关中以同域 `/admin/` 暴露。 ## 1. 结论 后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染;所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`。 本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,Rust `api-server` 直连时旧 `GET /admin` 不再挂载。部署态后台入口由发布包内 `web-server.mjs` 承接:`/admin/` 返回独立前端静态产物,`/admin/api/*` 继续反代到 `api-server`。 ## 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 │ └─ AdminInviteCodePage.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 | | 读取兑换码列表 | `GET /admin/api/profile/redeem-codes` | 管理员 Bearer | | 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer | | 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer | | 读取后台邀请码列表 | `GET /admin/api/profile/invite-codes` | 管理员 Bearer | | 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 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 AdminUpsertProfileInviteCodeRequest { inviteCode: string; startsAt?: string | null; expiresAt?: string | null; metadata?: Record; } export interface ProfileRedeemCodeAdminResponse { code: string; mode: ProfileRedeemCodeMode; rewardPoints: number; maxUses: number; globalUsedCount: number; enabled: boolean; allowedUserIds: string[]; createdBy: string; createdAt: string; updatedAt: string; } export interface ProfileRedeemCodeAdminListResponse { entries: ProfileRedeemCodeAdminResponse[]; } export interface ProfileInviteCodeAdminResponse { userId: string; inviteCode: string; startsAt: string | null; expiresAt: string | null; status: 'pending' | 'active' | 'expired'; metadata: Record; createdAt: string; updatedAt: string; } export interface ProfileInviteCodeAdminListResponse { entries: ProfileInviteCodeAdminResponse[]; } ``` ### 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`,后台页面只能展示读取异常,不能拿到真实表名。 `schemaTableNames` 与 `tableStats` 必须采用同一份 schema 表清单生成,不能再用硬编码关键表白名单补齐统计项。后台右上角显示的表数量必须等于统计表格实际行数;schema 读取失败时两者均为空,并通过 `fetchErrors` 暴露读取失败原因。 后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。 `tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。SpacetimeDB private 表或当前身份不可见的表在 `/sql` 下可能返回 `no such table` / `marked private`,后台服务必须将这类错误归一为“不可统计(private 或当前身份不可见)”,避免把预期的访问边界展示成原始 HTTP 400 故障。 线上如果大量表都显示“不可统计(private 或当前身份不可见)”,优先检查 `api-server` 启动环境中的 `GENARRATIVE_SPACETIME_TOKEN` 是否存在且属于目标库 owner。Jenkins 覆盖发布包时必须保留部署目录已有运行 token;只带迁移 token 不能让后台概览读取 private 表。 ### 4.6 API 调试 contract 请求: ```json { "method": "GET", "path": "/healthz", "headers": [], "body": "" } ``` 限制由后端执行: 1. `path` 只允许同源相对路径。 2. 禁止绝对 URL。 3. 禁止调试 `/admin/api/login`。 4. 禁止覆盖危险请求头。 5. 请求体大小和超时由后端收口。 ### 4.7 兑换码管理 contract 列表请求: `GET /admin/api/profile/redeem-codes` 成功返回: ```json { "entries": [ { "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" } ] } ``` 创建/更新请求: ```json { "code": "WELCOME2026", "mode": "public", "rewardPoints": 100, "maxUses": 1, "enabled": true, "allowedUserIds": [], "allowedPublicUserCodes": [] } ``` 停用请求: ```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" } ``` 兑换码管理页进入时必须通过 `GET /admin/api/profile/redeem-codes` 加载数据库已有记录。最近一次接口返回记录仍由 `AdminApp` 维护为管理端会话态,用于展示当前操作结果;历史列表不得依赖该会话态,刷新页面后必须从后端列表接口恢复。列表项点击后回填表单,继续通过同一个 `POST /admin/api/profile/redeem-codes` 修改原记录。 前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。 ### 4.8 邀请码管理 contract 列表请求: `GET /admin/api/profile/invite-codes` 成功返回: ```json { "entries": [ { "userId": "admin:root:SPRING2026", "inviteCode": "SPRING2026", "startsAt": "2026-05-01T00:00:00Z", "expiresAt": "2026-06-01T00:00:00Z", "status": "active", "metadata": { "batch": "spring" }, "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" } ] } ``` 后台邀请码列表只返回后台运营预置码。后端按 `profile_invite_code.user_id` 的 `admin:` 前缀过滤,普通用户在邀请中心生成的个人邀请码不得展示在后台列表中。 创建/更新请求: ```json { "inviteCode": "SPRING2026", "startsAt": "2026-05-01T00:00:00Z", "expiresAt": "2026-06-01T00:00:00Z", "metadata": { "batch": "spring" } } ``` 成功返回邀请码记录: ```json { "userId": "admin", "inviteCode": "SPRING2026", "startsAt": "2026-05-01T00:00:00Z", "expiresAt": "2026-06-01T00:00:00Z", "status": "active", "metadata": { "batch": "spring" }, "createdAt": "2026-04-30T00:00:00Z", "updatedAt": "2026-04-30T00:00:00Z" } ``` 邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。 #### 4.8.1 邀请码有效期语义 邀请码仍然是“用户稳定邀请身份码”,不做删除或软删除。本轮只增加时间窗字段,用于控制**新填写邀请码**是否允许绑定: 1. `startsAt` / 后端 `starts_at`:邀请码开始生效时间;为空表示立即生效。 2. `expiresAt` / 后端 `expires_at`:邀请码截止时间;为空表示长期有效。 3. 两个字段都为空时,邀请码视为长期有效。 4. `expiresAt` 采用左闭右开语义:当前时间 `>= expiresAt` 时视为已过期。 5. 时间字段在管理 API JSON 中统一使用 ISO 8601 UTC 字符串或 `null`;SpacetimeDB 内部仍按 `Timestamp` 存储,契约层负责转换,前端不得自行假设微秒/毫秒整数。 6. 有效期只影响用户之后调用填写邀请码接口建立新邀请关系;已绑定的邀请关系、历史奖励、统计和审计记录不回溯修改。 字段合法性要求: 1. `startsAt` 和 `expiresAt` 均允许为空。 2. 若两者都存在,必须满足 `startsAt < expiresAt`;相等或开始晚于截止应由后端拒绝,前端可提前提示但不能替代后端校验。 3. 后台编辑已有邀请码时,空值代表清空该边界;不要用空字符串写入契约。 #### 4.8.2 用户填写邀请码的错误优先级与校验逻辑 填写邀请码时,后端是唯一业务真相。前端只展示后端错误,不复制完整业务规则。推荐校验优先级如下: 1. **请求身份与输入基础校验**:未登录、空邀请码、格式不合法等请求级错误优先返回。 2. **用户自身状态校验**:用户不存在、用户资料不可用、已绑定过邀请关系等与当前用户直接相关的错误优先于邀请码时间窗。 3. **邀请码查找**:按标准化后的邀请码查找记录;不存在时返回“邀请码不存在或不可用”。 4. **自邀请校验**:邀请码归属用户等于当前用户时,返回“不能填写自己的邀请码”。 5. **时间窗校验**: - `starts_at` 存在且当前时间 `< starts_at`,返回“邀请码未生效”。 - `expires_at` 存在且当前时间 `>= expires_at`,返回“邀请码已过期”。 6. **绑定写入与奖励发放**:只有以上校验全部通过,才写入邀请绑定、奖励或相关流水。 该顺序的目标是避免用“未生效/已过期”泄露不该暴露的用户状态,同时保证用户看到的错误与实际阻断原因一致。若后续新增风控、封禁、黑名单等规则,应在写入前补入,并在本节同步明确优先级。 #### 4.8.3 后台邀请码列表状态展示规则 后台列表状态可由后端返回 `status`,也可在前端用同一规则从 `startsAt` / `expiresAt` 派生;如果两者同时存在,列表展示以后端 `status` 为准,并仅把前端派生结果用于兜底。 | 条件 | 状态值 | 中文标签 | 展示建议 | | --- | --- | --- | --- | | `startsAt` 存在且当前时间 `< startsAt` | `pending` | 未生效 | 展示开始时间,提示尚不能被新用户填写 | | `expiresAt` 存在且当前时间 `>= expiresAt` | `expired` | 已过期 | 展示截止时间,提示不再允许新绑定 | | 其他情况 | `active` | 有效 | 正常高亮展示 | 补充展示规则: 1. 两个字段都为空时状态为 `active`,中文可展示为“长期有效”。 2. `startsAt` 为空、`expiresAt` 未来存在时状态为 `active`,中文可展示为“有效至 YYYY-MM-DD HH:mm”。 3. `startsAt` 未来、`expiresAt` 为空时状态为 `pending`,中文可展示为“YYYY-MM-DD HH:mm 生效”。 4. 列表至少展示邀请码、状态、开始时间、截止时间、更新时间;metadata 可保留折叠/摘要展示,避免挤占移动端宽度。 5. 列表状态只用于运营理解,不作为安全边界;真正是否可填写仍以后端 redeem 校验为准。 ### 4.9 后台写操作二次确认规范 后台所有会修改线上数据的操作,在真正调用 API 前必须二次确认;取消确认时不得发送任何请求。该规范覆盖当前和未来新增的管理写入口,不限于 profile 模块。 必须二次确认的操作包括但不限于: 1. 创建/更新兑换码:`POST /admin/api/profile/redeem-codes`。 2. 停用兑换码:`POST /admin/api/profile/redeem-codes/disable`。 3. 创建/更新邀请码:`POST /admin/api/profile/invite-codes`。 4. 创建/更新个人任务配置:`POST /admin/api/profile/tasks`。 5. 停用个人任务配置:`POST /admin/api/profile/tasks/disable`。 6. 后续任何 `POST` / `PATCH` / `PUT` / `DELETE` 管理接口,只要会修改数据、触发任务、写审计或影响线上配置,均默认纳入确认。 交互要求: 1. 确认弹窗必须在 API 调用前出现,确认后才进入 loading 和提交状态。 2. 弹窗必须展示操作类型(新增、更新、停用、删除、发布等)、对象标识(如 `code`、`inviteCode`、`taskId`)和影响说明。 3. 默认按钮顺序为“取消 / 确认”,取消不应有危险色;危险操作(停用、删除、覆盖线上配置)确认按钮使用警示样式。 4. 弹窗文案统一提示“该操作会立即影响线上数据”,但不要在页面常驻展示大段规则说明。 5. 支持键盘和移动端:Esc 或取消按钮关闭;移动端弹窗宽度自适应,不遮挡关键对象信息。 6. loading 期间锁定确认按钮和原页面提交按钮,避免重复写入。 7. 成功后按现有页面规则刷新列表或合并返回记录;失败时展示后端错误,不能静默关闭为成功。 建议抽象通用确认能力,例如 `confirmAdminWriteAction({ actionLabel, targetLabel, riskLevel, onConfirm })` 或通用 `AdminConfirmDialog`,页面只传入对象与回调,避免每个页面重复实现不同交互。 #### 4.9.1 二次确认文案模板 ```text 标题:确认{操作类型}{对象类型} 正文:即将{操作类型}「{对象标识}」。该操作会立即影响线上数据。 取消按钮:取消 确认按钮:确认{操作类型} ``` 示例: 1. `确认更新邀请码`:即将更新「SPRING2026」的有效期与 metadata。该操作会立即影响线上数据。 2. `确认停用兑换码`:即将停用「WELCOME2026」。该操作会立即影响线上数据。 3. `确认更新任务配置`:即将更新「daily_login」。该操作会立即影响线上数据。 ### 4.10 邀请码有效期与二次确认改动范围 实现本设计时预期改动范围如下,未列出的层级不要擅自承接业务规则: 1. `server-rs/crates/spacetime-module/src/runtime/profile.rs`:邀请码表结构、upsert、redeem 时间窗校验与后台列表投影。 2. `server-rs/crates/spacetime-module/src/migration.rs`:旧邀请码记录迁移,默认 `starts_at = None`、`expires_at = None`。 3. `server-rs/crates/shared-contracts/src/**`:管理请求/响应 DTO 增加 `startsAt`、`expiresAt`、`status` 等字段。 4. `server-rs/crates/spacetime-client/src/module_bindings/**` 与 mapper:按表结构变更重新生成/补齐绑定字段。 5. `server-rs/crates/api-server/src/runtime_profile.rs`:接收、校验、转发并返回邀请码时间窗字段;保持错误 envelope 兼容后台读取逻辑。 6. `apps/admin-web/src/api/adminApiTypes.ts` 与 `adminApiClient.ts`:同步契约字段,不在 client 层写业务判断。 7. `apps/admin-web/src/pages/AdminInviteCodePage.tsx`:有效期表单、列表状态展示、保存前确认。 8. `apps/admin-web/src/pages/AdminRedeemCodePage.tsx`、`AdminTaskConfigPage.tsx` 及后续写页面:统一接入写操作二次确认。 9. `apps/admin-web/src/styles/admin.css`:状态标签、确认弹窗与移动端样式。 验证建议: 1. 服务端单测覆盖:未生效邀请码拒绝、已过期邀请码拒绝、有效时间窗可绑定、空时间窗长期有效、已绑定关系不受后续过期影响。 2. 管理 API 覆盖:upsert 能写入/清空 `startsAt`、`expiresAt`;列表返回状态正确;`startsAt >= expiresAt` 被拒绝。 3. 前端交互覆盖:点击保存/停用不会直接请求,取消确认不请求,确认后只请求一次,失败展示后端错误。 4. 回归兑换码与任务配置页面,确认所有写操作均有统一二次确认。 5. 修改后端时按项目约束运行对应 Rust 测试、`npm run api-server` 联调和 `/healthz`;修改前端时运行 `npm run admin-web:typecheck`、`npm run admin-web:build`;文档或中文改动后运行 `npm run check:encoding`。 ## 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. 兑换码页和邀请码页进入时加载数据库列表,保存后合并返回记录,点击列表项回填表单进入编辑态。 7. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata,不在前端复制后端邀请码规则。 8. 所有按钮的 loading 状态必须锁定重复提交。 9. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。 ## 7. 部署与联调 ### 7.1 本地联调 1. 完整本地栈直接在仓库根目录执行 `npm run dev`。 2. `npm run dev` 默认启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 和后台 Vite。 3. 主站默认地址为 `http://127.0.0.1:3000`,后台可从主站 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。 4. 主站 Vite 会把 `/admin/` 转发到后台 dev server,贴近生产同域 `/admin/` 入口。 5. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到当前 Rust API 地址;`--api-port` 改动时脚本会同步注入 `ADMIN_API_TARGET`。 6. 如需单独启动后台前端,可继续执行根脚本 `npm run admin-web:dev`,或在 `apps/admin-web` 执行 `npm run dev`;单独启动时未配置 `ADMIN_API_TARGET` 会默认代理到 `http://127.0.0.1:3100`。 7. 后台 dev 端口可用 `npm run dev -- --admin-web-port ` 覆盖。 ### 7.2 构建部署 当前发布形态固定为同域 `/admin/`: 1. 本地单独执行 `npm run admin-web:build` 时,后台构建产物默认输出到 `apps/admin-web/dist`。 2. 根工程执行 `npm run build` 时,会先构建主前端,再构建后台前端;任一构建失败或输出 warning 都会让构建门禁失败。 3. Ubuntu 发布包执行 `npm run deploy:rust:remote` 时,后台前端以 Vite `--base /admin/` 构建到发布包 `web/admin/`。 4. 发布包 `web-server.mjs` 对 `/admin` 返回 301 到 `/admin/`,对 `/admin/` 与 `/admin/*` 提供后台 SPA fallback,对 `/admin/api/*` 优先反代到 `api-server`。 该形态不新增后台静态端口和后台专用后端。`server-rs` 仍然是唯一管理 API 后端,后台前端不直连 SpacetimeDB。 ### 7.3 后台工程脚本 `apps/admin-web/package.json` 首版至少提供以下脚本: ```json { "scripts": { "dev": "vite --host 127.0.0.1", "build": "node ../../scripts/admin-web-build.mjs build", "typecheck": "node ../../scripts/admin-web-build.mjs typecheck", "preview": "vite preview --host 127.0.0.1" } } ``` 如果后续接入根 npm workspace,再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。 当前工程没有启用 npm workspace,因此后台构建脚本必须从仓库根目录调用 root toolchain。`scripts/admin-web-build.mjs` 统一执行 `tsc --noEmit -p apps/admin-web/tsconfig.json` 与 Vite 构建,避免 `npm --prefix apps/admin-web` 在子目录找不到 `tsc`。 当前根工程同步提供以下转发脚本: 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 表单提交和停用。 - 邀请码数据库列表加载、普通用户邀请码不展示、列表点击回填、metadata JSON 对象校验和结果展示。 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` 并手动验证 `/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 步。验证以实际命令输出为准。