16 KiB
后台管理独立前端工程技术方案
日期:2026-04-30
对应 PRD:后台管理独立前端工程 PRD
落地状态: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. 工程结构
建议首版结构:
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. 技术栈
- React + TypeScript + Vite。
- 图标使用
lucide-react。 - 样式首版使用普通 CSS 或 CSS Modules,不引入新的大型 UI 组件库。
- 请求使用浏览器
fetch封装,不新增状态管理库。 - 不引入 SpacetimeDB TypeScript SDK;管理端不直连 SpacetimeDB。
4. API 边界
4.1 基础约定
所有管理端请求使用同一个 adminApiClient:
- base URL 由
VITE_ADMIN_API_BASE_URL配置。 - 未配置时默认同源空前缀。
- 有 token 时附加
Authorization: Bearer <token>。 - 后端统一响应 envelope 时,前端读取
data;错误优先读取error.details.message,再读error.message,最后回退到 HTTP 状态。
前端统一按以下响应形状解析,不在页面组件里重复拆 envelope:
export interface ApiSuccessEnvelope<T> {
data: T;
meta?: unknown;
}
export interface ApiErrorEnvelope {
error?: {
code?: string;
message?: string;
details?: {
message?: string;
[key: string]: unknown;
} | null;
};
meta?: unknown;
}
adminApiClient 暴露 request<T>()、get<T>()、post<T>() 三层即可。页面只拿到成功数据或抛出的中文错误消息,不直接处理 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 |
| 创建/更新注册邀请码 | POST /admin/api/profile/invite-codes |
管理员 Bearer |
4.3 前端类型命名
后台前端首版不引入自动生成 contract。为了避免字段漂移,apps/admin-web/src/api/adminApiTypes.ts 必须按 shared-contracts 的 camelCase JSON 字段命名:
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:
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;
metadata?: Record<string, unknown>;
}
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 ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
4.4 登录 contract
请求:
{
"username": "root",
"password": "secret123"
}
成功数据:
{
"token": "<admin bearer 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 返回:
service:bindHost、bindPort、jwtIssuer、adminEnabled、spacetimeServerUrl、spacetimeDatabase。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
请求:
{
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
}
限制由后端执行:
path只允许同源相对路径。- 禁止绝对 URL。
- 禁止调试
/admin/api/login。 - 禁止覆盖危险请求头。
- 请求体大小和超时由后端收口。
4.7 兑换码管理 contract
创建/更新请求:
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"enabled": true,
"allowedUserIds": [],
"allowedPublicUserCodes": []
}
停用请求:
兑换码管理页的最近一次接口返回记录由 AdminApp 维护为管理端会话态,并传入 AdminRedeemCodePage 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 useState 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
{
"code": "WELCOME2026"
}
成功返回兑换码记录:
{
"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 为准。
4.8 邀请码管理 contract
创建/更新请求:
{
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
}
}
成功返回邀请码记录:
{
"userId": "admin",
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 {} 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 server-rs 为准。
5. 鉴权与会话
- token key 固定为
genarrative_admin_token。 - token 首版存 localStorage。
- 应用启动时如果存在 token,先调用
GET /admin/api/me。 401时清空 token 并回到登录页。403时展示无权限状态,不自动重试。- 退出登录只清理本地 token;首版没有后台 refresh session 和服务端会话吊销。
6. 页面实现要点
AdminShell承载导航、当前管理员、退出按钮和页面容器。- 登录页不进入
AdminShell,避免未登录时展示后台导航。 - 总览页加载失败时展示后端错误,不吞掉
fetchErrors。 - API 调试页的 headers 使用键值行编辑,提交前转为
[{ name, value }]。 - 兑换码页的
mode=private时展示允许用户输入区;其他模式提交空数组。 - 邀请码页只提交
inviteCode与 JSON 对象 metadata,不在前端复制后端邀请码规则。 - 所有按钮的 loading 状态必须锁定重复提交。
- 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
7. 部署与联调
7.1 本地联调
- 启动后端:
npm run api-server。 - 启动后台前端:在
apps/admin-web执行npm run dev。 - 后台 dev server 通过 Vite proxy 转发
/admin/api到ADMIN_API_TARGET;未配置时默认http://127.0.0.1:3100。 - 若使用非 3100 端口,在仓库根目录
.env.local设置ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>,并重启后台前端 dev server。 GENARRATIVE_API_PORT控制 Rustapi-server监听端口;ADMIN_API_TARGET只控制后台前端 dev proxy 目标,二者需要指向同一个端口。
7.2 构建部署
当前发布形态固定为同域 /admin/:
- 本地单独执行
npm run admin-web:build时,后台构建产物默认输出到apps/admin-web/dist。 - 根工程执行
npm run build时,会先构建主前端,再构建后台前端;任一构建失败或输出 warning 都会让构建门禁失败。 - Ubuntu 发布包执行
npm run deploy:rust:remote时,后台前端以 Vite--base /admin/构建到发布包web/admin/。 - 发布包
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 首版至少提供以下脚本:
{
"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。
当前根工程同步提供以下转发脚本:
npm run admin-web:devnpm run admin-web:typechecknpm run admin-web:buildnpm run admin-web:preview
8. 测试计划
apps/admin-web:- 登录成功和失败。
- token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问
/healthz,绝对 URL 被后端拒绝。 - 兑换码 public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
- 根工程:
npm run check:encoding。- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
- 后端:
- 继续保留
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. 实施顺序
- 先创建
apps/admin-web工程骨架,确保空应用可dev/build。 - 再实现
adminApiTypes与adminApiClient,用/admin/api/login做第一条真实链路。 - 接入
adminAuthStore和启动恢复逻辑,确认401会清理本地 token。 - 完成
AdminShell与四页路由,再分别接入总览、API 调试、兑换码和邀请码接口。 - 最后补测试、运行
npm run check:encoding,并确认GET /admin仍由api-server返回404。
当前实现已完成第 1 至第 4 步。验证以实际命令输出为准。