diff --git a/web-server/admin/.dockerignore b/web-server/admin/.dockerignore
new file mode 100644
index 0000000..9ed5fad
--- /dev/null
+++ b/web-server/admin/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+dist
+.git
+.gitignore
+*.log
+npm-debug.log*
+README.md
+Dockerfile
+.dockerignore
+.vscode
+.idea
diff --git a/web-server/admin/.gitignore b/web-server/admin/.gitignore
new file mode 100644
index 0000000..62f4d97
--- /dev/null
+++ b/web-server/admin/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+*.local
+*.log
+.DS_Store
+tsconfig.tsbuildinfo
+*.tsbuildinfo
diff --git a/web-server/admin/Dockerfile b/web-server/admin/Dockerfile
new file mode 100644
index 0000000..ea34e2c
--- /dev/null
+++ b/web-server/admin/Dockerfile
@@ -0,0 +1,17 @@
+# ---- 构建阶段 ----
+FROM node:22-alpine AS build
+WORKDIR /app
+
+COPY package.json package-lock.json* ./
+RUN npm ci || npm install
+
+COPY . .
+RUN npm run build
+
+# ---- 运行阶段 ----
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/web-server/admin/README.md b/web-server/admin/README.md
new file mode 100644
index 0000000..a14c061
--- /dev/null
+++ b/web-server/admin/README.md
@@ -0,0 +1,127 @@
+# OpenDance Studio 管理端(opendance-admin)
+
+基于 **React 18 + TypeScript + Vite** 的管理后台,对接 `web-server` 的 NestJS API。
+接口契约以 [`../contracts/rest-api.yaml`](../contracts/rest-api.yaml)(rest-api 0.1.0)为准,
+字段语义见 [`../contracts/data-models.md`](../contracts/data-models.md)。
+
+界面语言为简体中文;无任何 UI 组件框架,全部样式集中在 `src/styles.css`。
+
+## 快速开始
+
+```bash
+npm install # 或 pnpm install
+npm run dev # 开发服务器 http://localhost:5173
+npm run build # 生产构建(tsc -b && vite build)→ dist/
+npm run preview # 本地预览 dist/
+npm run typecheck # 只做类型检查(tsc --noEmit)
+```
+
+> 管理端请求的是**同源**的 `/api/**` 与 `/health`,不含任何硬编码域名。
+
+## 开发代理(dev proxy)
+
+`vite.config.ts` 已把 `/api` 与 `/health` 代理到 `http://localhost:3000`:
+
+```ts
+server: {
+ port: 5173,
+ proxy: {
+ '/api': { target: 'http://localhost:3000', changeOrigin: true },
+ '/health': { target: 'http://localhost:3000', changeOrigin: true },
+ },
+}
+```
+
+因此本地只需先启动服务端,再 `npm run dev` 即可,无需配置 CORS。
+
+## `VITE_API_BASE`
+
+`src/api/client.ts` 的基址取自:
+
+```ts
+const BASE_URL = import.meta.env.VITE_API_BASE ?? '';
+```
+
+- **不设置(默认,空串)**:同源请求 —— 开发期走上面的 vite 代理,生产期走 nginx 反代。**推荐**。
+- **设置**:例如服务端在另一台机器上:
+
+ ```bash
+ # .env.local(不要提交)
+ VITE_API_BASE=http://192.168.1.10:3000
+ ```
+
+ 此时浏览器会直接跨域请求该地址,需要服务端开启 CORS。
+ 构建期注入:`VITE_API_BASE=https://api.example.com npm run build`。
+
+## 生产构建与部署
+
+```bash
+npm run build # 产物在 dist/
+```
+
+`vite.config.ts` 使用 `base: './'`,产物使用相对路径引用,可挂在任意子路径下。
+
+Docker(多阶段构建,产物由 nginx 托管):
+
+```bash
+docker build -t opendance-admin .
+docker run --rm -p 8080:80 opendance-admin
+```
+
+- `Dockerfile`:stage 1 `node:22-alpine` 执行 `npm ci || npm install` + `npm run build`;
+ stage 2 `nginx:alpine` 拷贝 `dist` 到 `/usr/share/nginx/html` 与 `nginx.conf`。
+- `nginx.conf`:`try_files $uri $uri/ /index.html` 支持 SPA 路由,
+ 并把 `/api/` 与 `/health` 反向代理到 `http://api:3000`(Docker 网络内的服务名)。
+ 部署时请确保管理端容器与服务端容器在同一 network,或把 `api` 换成实际主机名。
+
+## 目录结构
+
+```
+src/
+ main.tsx 入口(BrowserRouter)
+ App.tsx 路由表
+ styles.css 全部样式
+ api/
+ client.ts fetch 封装(ApiError / 分页 / multipart)
+ types.ts 契约类型(与 rest-api.yaml 一一对应)
+ endpoints.ts 契约端点封装
+ components/
+ Layout.tsx 左侧导航骨架
+ ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单…)
+ VrmPreview.tsx three + @pixiv/three-vrm 的 3D 预览(懒加载分包)
+ lib/
+ labels.ts 枚举中文文案
+ format.ts 时间/字节/时长格式化
+ validation.ts 客户端校验(来源五元组等)
+ useAsync.ts 加载/错误/重试 hook
+ pages/
+ Dashboard.tsx 概览
+ Songs.tsx 歌曲管理 T-WEB-008
+ Models.tsx 模型管理 T-WEB-009
+ Motions.tsx 动作管理 T-WEB-004/006
+ Reviews.tsx 审核 T-WEB-007
+```
+
+## 页面与任务对照
+
+| 路由 | 说明 | 任务 |
+|---|---|---|
+| `/` | 概览:三类资产按状态计数、契约版本与目录指纹、健康检查、API 不可达告警 | — |
+| `/songs` | 歌曲管理:分页/搜索/筛选、来源五元组必填、音频上传、软删除 | T-WEB-008 |
+| `/models` | 模型管理:VRM 优先、来源五元组完整展示、设为默认、3D 预览 | T-WEB-009 |
+| `/motions` | 动作管理:上传、预处理、关键点 NDJSON 下载 | T-WEB-004/006 |
+| `/reviews` | 审核:待审列表 + 审核流水,通过/驳回(驳回必填意见) | T-WEB-007 |
+
+所有页面都显式处理**加载中 / 空数据 / 请求失败**三种状态,请求失败不会白屏。
+
+## 3D 预览说明
+
+`src/components/VrmPreview.tsx`:`GLTFLoader` + `VRMLoaderPlugin`(来自 `@pixiv/three-vrm`),
+
+- 依据 `vrm.meta.metaVersion` 区分 VRM 0.x / 1.0,0.x 自动执行 `VRMUtils.rotateVRM0`;
+- 自实现鼠标拖拽旋转与滚轮缩放(未引入 OrbitControls);
+- `requestAnimationFrame` 循环内调用 `vrm.update(delta)`,并按包围盒自动取景;
+- 卸载时取消 RAF、断开 ResizeObserver、释放 renderer / geometry / material / texture;
+- 无 WebGL 上下文时降级为错误提示,不会让页面崩溃。
+
+`three` + `@pixiv/three-vrm`(约 750 kB)通过 `React.lazy` 单独分包,仅进入模型页时加载。
diff --git a/web-server/admin/index.html b/web-server/admin/index.html
new file mode 100644
index 0000000..7d0f2b9
--- /dev/null
+++ b/web-server/admin/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OpenDance Studio 管理端
+
+
+
+
+
+
diff --git a/web-server/admin/nginx.conf b/web-server/admin/nginx.conf
new file mode 100644
index 0000000..6d626da
--- /dev/null
+++ b/web-server/admin/nginx.conf
@@ -0,0 +1,39 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+ gzip_min_length 1024;
+
+ # SPA 路由回退
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # 上传大文件(模型 / 动作)时不要过早断开
+ client_max_body_size 512m;
+
+ location /api/ {
+ proxy_pass http://api:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ }
+
+ location /health {
+ proxy_pass http://api:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/web-server/admin/package-lock.json b/web-server/admin/package-lock.json
new file mode 100644
index 0000000..0a0dd04
--- /dev/null
+++ b/web-server/admin/package-lock.json
@@ -0,0 +1,1229 @@
+{
+ "name": "opendance-admin",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "opendance-admin",
+ "version": "0.1.0",
+ "dependencies": {
+ "@pixiv/three-vrm": "^3.5.5",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.6",
+ "three": "^0.186.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.1",
+ "@types/react": "^18.3.31",
+ "@types/react-dom": "^18.3.7",
+ "@types/three": "^0.186.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "typescript": "^5.9.3",
+ "vite": "^8.3.0"
+ }
+ },
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.149.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
+ "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@pixiv/three-vrm": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.5.tgz",
+ "integrity": "sha512-RPXy7jYAXs704NIpZlosB0U2ENu21G9DrqGWdQgRe8dShaCo1ugpj+6BVPRCy91nt+MPMA96j5rbsSzEl0HlQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/three-vrm-core": "3.5.5",
+ "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.5",
+ "@pixiv/three-vrm-materials-mtoon": "3.5.5",
+ "@pixiv/three-vrm-materials-v0compat": "3.5.5",
+ "@pixiv/three-vrm-node-constraint": "3.5.5",
+ "@pixiv/three-vrm-springbone": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-core": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.5.tgz",
+ "integrity": "sha512-jjaXZbMVRsk2HAGVxqDLmSJyGOJqbMcJpuTHR1TY/TN1FxiKdAzSqdr/ZUcczYipNf8IL9vDT9NX2XmWj+ybRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.5.tgz",
+ "integrity": "sha512-XnHEqpY4ctYMucpeRcptdphOe0RrW7V5fUvojasD8Ja4MTjjrjCNFxsAvp3+HBkPZbkTM2sDCjJBJNzxN1scrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-mtoon": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.5.tgz",
+ "integrity": "sha512-7lThHf4OKBOsVenD+R6L6HffNUlKO3Twnhr3+z77MnUsSzsOanjVKJtjSIly9QJ6hgl8YJmI6RnxCKqXcpplog==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-v0compat": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.5.tgz",
+ "integrity": "sha512-NjsTK4P4qdcr+RoL2gEUNgF2CY/aplNvyHHxI99N7D6CxDZ2H93FQ9aIuLCABj5b+4CIvldWMCcnS7YuLIr3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-node-constraint": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.5.tgz",
+ "integrity": "sha512-+ShEXioZ5Ctf0EZ5ZViZ4n0jhV/qtJRH0Juw8nRnMNODZXDSr+B3An2lrfRoMhowCzG63GiUG4lXWV9noMXosA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-node-constraint-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-springbone": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.5.tgz",
+ "integrity": "sha512-GWAWrIegGH/u1ZA+Es03Qzc2yafrQOrhtal9jzUIAgU8Wm2DYuqAABOY+du/9RpBRK8D4T8DBPvRGQqvfrQ/uA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-springbone-1.0": "3.5.5",
+ "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/types-vrm-0.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.5.tgz",
+ "integrity": "sha512-2S/0jswMjJTwL9ky8YzDR0CIaP6ZmE+HFuyW/0h+SA+awELf+J7WGgC8lTLM1MXzokO7Z2YVljU2F0fEhlKDIQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.5.tgz",
+ "integrity": "sha512-IuHw9ehXt2bymqfdFV+f2cCXo8kvJUfh0Zuut8qmKXoEb7HVyLGIQyS4TcQHG5yY22UTWHI1Axo5tQv35lPTgQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.5.tgz",
+ "integrity": "sha512-GFVuksAJVhrWTXYmI/ucNsE611/d8N6BoZrzhUAAINndt98loyzapW2+DJu+GsUBO5yxd+vLsL0FB16XD9+TGg==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-node-constraint-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.5.tgz",
+ "integrity": "sha512-8EbJ1uzkn9yJQarCChmpG12+mc2c7PimHvQ8H6/tMh/hqp5NtJND0z2lR2KeVxw9A3M+TpF+xgImhLBxGjqwzQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-springbone-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.5.tgz",
+ "integrity": "sha512-gRQ5Y7PaGiMefvOrJ3hvoSPqpA67jiwyhwh8ItjsPi3yri/Hn3CeXplW3Cbx3ZPbfu+C3Q2MCRa2TqAUOjeMRA==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.5.tgz",
+ "integrity": "sha512-+6RxLaAvVguP7upjS/OT4viNPWvac1HBRQY1nmdvfYNQuCMdrMsCZqvy2af0Fcjnda4V9JbKFIpZDC0lrw1+uQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-vrm-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.5.tgz",
+ "integrity": "sha512-WnemHHo2375BcBdumEhvrupZzsMSzTfdWlokmV9sUX2oC+OAQqJFfBjLF8ppkOhzHpl4MAPJqEfuhBJtNPm9ww==",
+ "license": "MIT"
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.4",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
+ "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
+ "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
+ "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
+ "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
+ "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
+ "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
+ "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
+ "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
+ "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
+ "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
+ "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
+ "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
+ "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
+ "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
+ "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
+ "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
+ "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.186.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
+ "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.3",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
+ "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
+ "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4",
+ "react-router": "6.30.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
+ "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.149.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.8",
+ "@rolldown/binding-android-arm64": "1.2.8",
+ "@rolldown/binding-darwin-arm64": "1.2.8",
+ "@rolldown/binding-darwin-x64": "1.2.8",
+ "@rolldown/binding-freebsd-x64": "1.2.8",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.8",
+ "@rolldown/binding-linux-arm64-musl": "1.2.8",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.8",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.8",
+ "@rolldown/binding-linux-x64-gnu": "1.2.8",
+ "@rolldown/binding-linux-x64-musl": "1.2.8",
+ "@rolldown/binding-openharmony-arm64": "1.2.8",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.8",
+ "@rolldown/binding-win32-x64-msvc": "1.2.8"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.186.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz",
+ "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
+ "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.6",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/web-server/admin/package.json b/web-server/admin/package.json
new file mode 100644
index 0000000..27d4b4e
--- /dev/null
+++ b/web-server/admin/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "opendance-admin",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@pixiv/three-vrm": "^3.5.5",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.6",
+ "three": "^0.186.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.1",
+ "@types/react": "^18.3.31",
+ "@types/react-dom": "^18.3.7",
+ "@types/three": "^0.186.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "typescript": "^5.9.3",
+ "vite": "^8.3.0"
+ }
+}
diff --git a/web-server/admin/src/App.tsx b/web-server/admin/src/App.tsx
new file mode 100644
index 0000000..7099513
--- /dev/null
+++ b/web-server/admin/src/App.tsx
@@ -0,0 +1,22 @@
+import { Navigate, Route, Routes } from 'react-router-dom';
+import { Layout } from './components/Layout';
+import { DashboardPage } from './pages/Dashboard';
+import { ModelsPage } from './pages/Models';
+import { MotionsPage } from './pages/Motions';
+import { ReviewsPage } from './pages/Reviews';
+import { SongsPage } from './pages/Songs';
+
+export default function App() {
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/web-server/admin/src/api/client.ts b/web-server/admin/src/api/client.ts
new file mode 100644
index 0000000..faef3d3
--- /dev/null
+++ b/web-server/admin/src/api/client.ts
@@ -0,0 +1,138 @@
+/**
+ * 极简 fetch 封装(不引入 axios / react-query)。
+ *
+ * - 基址来自 `import.meta.env.VITE_API_BASE ?? ''`:空串表示同源,
+ * 开发期走 vite proxy,生产期走 nginx 反代。
+ * - 非 2xx 统一抛 ApiError(statusCode, message)。
+ * Nest 校验失败的 message 可能是 string 或 string[],数组以 '; ' 连接。
+ * - 网络层失败(API 未启动)归一为 ApiError(0, …),便于页面展示「服务不可达」。
+ * - multipart 助手不手动设置 Content-Type,交给浏览器补 boundary。
+ */
+
+import type { ApiErrorBody } from './types';
+
+const BASE_URL: string = import.meta.env.VITE_API_BASE ?? '';
+
+/** 查询参数值;undefined / null / '' 会被跳过。 */
+export type QueryValue = string | number | boolean | null | undefined;
+export type Query = Record;
+
+export class ApiError extends Error {
+ /** HTTP 状态码;0 表示网络层失败(服务未启动 / 跨域 / 断网)。 */
+ readonly statusCode: number;
+
+ constructor(statusCode: number, message: string) {
+ super(message);
+ this.name = 'ApiError';
+ this.statusCode = statusCode;
+ }
+}
+
+/** 把任意错误归一为可展示的中文文案。 */
+export function errorMessage(error: unknown): string {
+ if (error instanceof ApiError) return error.message;
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
+
+function isApiErrorBody(value: unknown): value is ApiErrorBody {
+ return typeof value === 'object' && value !== null;
+}
+
+/** 从响应体里抽取可读信息,容忍 string / string[] / {message} 三种形态。 */
+function extractMessage(body: unknown, fallback: string): string {
+ if (typeof body === 'string') {
+ const text = body.trim();
+ return text === '' ? fallback : text;
+ }
+ if (isApiErrorBody(body)) {
+ const { message, error } = body;
+ if (typeof message === 'string' && message.trim() !== '') return message;
+ if (Array.isArray(message)) {
+ const parts = message.filter((part): part is string => typeof part === 'string');
+ if (parts.length > 0) return parts.join('; ');
+ }
+ if (typeof error === 'string' && error.trim() !== '') return error;
+ }
+ return fallback;
+}
+
+/** 拼出完整请求 URL(含查询串)。 */
+export function apiUrl(path: string, params?: Query): string {
+ const url = `${BASE_URL}${path}`;
+ if (!params) return url;
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null || value === '') continue;
+ search.append(key, String(value));
+ }
+ const qs = search.toString();
+ return qs === '' ? url : `${url}?${qs}`;
+}
+
+async function parseBody(response: Response): Promise {
+ const text = await response.text();
+ if (text === '') return null;
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ return text;
+ }
+}
+
+async function request(path: string, init: RequestInit, params?: Query): Promise {
+ let response: Response;
+ try {
+ response = await fetch(apiUrl(path, params), init);
+ } catch (cause) {
+ const detail = cause instanceof Error ? cause.message : String(cause);
+ throw new ApiError(0, `无法连接 API 服务:${detail}`);
+ }
+
+ const body = await parseBody(response);
+
+ if (!response.ok) {
+ const fallback = `请求失败(HTTP ${response.status} ${response.statusText})`;
+ throw new ApiError(response.status, extractMessage(body, fallback));
+ }
+
+ return body as T;
+}
+
+const JSON_HEADERS: Record = {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+};
+
+export function apiGet(path: string, params?: Query): Promise {
+ return request(path, { method: 'GET', headers: { Accept: 'application/json' } }, params);
+}
+
+export function apiPost(path: string, body?: unknown): Promise {
+ return request(path, {
+ method: 'POST',
+ headers: JSON_HEADERS,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+}
+
+export function apiPatch(path: string, body: unknown): Promise {
+ return request(path, {
+ method: 'PATCH',
+ headers: JSON_HEADERS,
+ body: JSON.stringify(body),
+ });
+}
+
+export function apiDelete(path: string): Promise {
+ return request(path, { method: 'DELETE', headers: { Accept: 'application/json' } });
+}
+
+/** multipart 上传:绝不手动设置 Content-Type。 */
+export function apiUpload(path: string, form: FormData): Promise {
+ return request(path, {
+ method: 'POST',
+ headers: { Accept: 'application/json' },
+ body: form,
+ });
+}
diff --git a/web-server/admin/src/api/endpoints.ts b/web-server/admin/src/api/endpoints.ts
new file mode 100644
index 0000000..5699efa
--- /dev/null
+++ b/web-server/admin/src/api/endpoints.ts
@@ -0,0 +1,230 @@
+/** 契约端点封装:页面只调用这些函数,不直接拼 URL。 */
+
+import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client';
+import type {
+ ContractVersion,
+ Health,
+ Model,
+ ModelPage,
+ ModelUpdate,
+ Motion,
+ MotionPage,
+ MotionUpdate,
+ PendingPage,
+ PublishStatus,
+ ReviewActionBody,
+ ReviewPage,
+ Song,
+ SongCreate,
+ SongPage,
+ SongUpdate,
+ TargetType,
+} from './types';
+
+/* ------------------------------------------------------------------ 列表 */
+
+export interface ListParams {
+ page: number;
+ pageSize: number;
+}
+
+export function listSongs(
+ params: ListParams & { q?: string; status?: PublishStatus | ''; artist?: string },
+): Promise {
+ return apiGet('/api/songs', {
+ page: params.page,
+ pageSize: params.pageSize,
+ q: params.q,
+ status: params.status,
+ artist: params.artist,
+ });
+}
+
+export function listMotions(
+ params: ListParams & {
+ q?: string;
+ songId?: number | '';
+ format?: string;
+ status?: PublishStatus | '';
+ },
+): Promise {
+ return apiGet('/api/motions', {
+ page: params.page,
+ pageSize: params.pageSize,
+ q: params.q,
+ songId: params.songId,
+ format: params.format,
+ status: params.status,
+ });
+}
+
+export function listModels(
+ params: ListParams & { q?: string; format?: string; status?: PublishStatus | '' },
+): Promise {
+ return apiGet('/api/models', {
+ page: params.page,
+ pageSize: params.pageSize,
+ q: params.q,
+ format: params.format,
+ status: params.status,
+ });
+}
+
+/* ------------------------------------------------------------------ 曲目 */
+
+export function createSong(payload: SongCreate): Promise {
+ return apiPost('/api/songs', payload);
+}
+
+export function updateSong(id: number, payload: SongUpdate): Promise {
+ return apiPatch(`/api/songs/${id}`, payload);
+}
+
+export function deleteSong(id: number): Promise {
+ return apiDelete(`/api/songs/${id}`);
+}
+
+/** multipart 字段名固定为 file。 */
+export function uploadSongAudio(id: number, file: File): Promise {
+ const form = new FormData();
+ form.append('file', file);
+ return apiUpload(`/api/songs/${id}/audio`, form);
+}
+
+/* ------------------------------------------------------------------ 动作 */
+
+export interface MotionUploadInput {
+ file: File;
+ name: string;
+ songId?: number;
+ fps?: number;
+ autoPreprocess: boolean;
+ status: PublishStatus;
+ sourceType: string;
+ sourceName: string;
+ sourceUrl: string;
+ license: string;
+ author: string;
+}
+
+export function uploadMotion(input: MotionUploadInput): Promise {
+ const form = new FormData();
+ form.append('file', input.file);
+ form.append('name', input.name);
+ if (input.songId !== undefined) form.append('songId', String(input.songId));
+ if (input.fps !== undefined) form.append('fps', String(input.fps));
+ form.append('autoPreprocess', input.autoPreprocess ? 'true' : 'false');
+ form.append('status', input.status);
+ form.append('sourceType', input.sourceType);
+ form.append('sourceName', input.sourceName);
+ form.append('sourceUrl', input.sourceUrl);
+ form.append('license', input.license);
+ form.append('author', input.author);
+ return apiUpload('/api/motions', form);
+}
+
+export function updateMotion(id: number, payload: MotionUpdate): Promise {
+ return apiPatch(`/api/motions/${id}`, payload);
+}
+
+export function deleteMotion(id: number): Promise {
+ return apiDelete(`/api/motions/${id}`);
+}
+
+export function preprocessMotion(id: number): Promise {
+ return apiPost(`/api/motions/${id}/preprocess`, {});
+}
+
+/* ------------------------------------------------------------------ 模型 */
+
+export interface ModelUploadInput {
+ file: File;
+ name: string;
+ isPrimary: boolean;
+ status: PublishStatus;
+ sourceType: string;
+ sourceName: string;
+ sourceUrl: string;
+ license: string;
+ author: string;
+}
+
+export function uploadModel(input: ModelUploadInput): Promise {
+ const form = new FormData();
+ form.append('file', input.file);
+ form.append('name', input.name);
+ form.append('isPrimary', input.isPrimary ? 'true' : 'false');
+ form.append('status', input.status);
+ form.append('sourceType', input.sourceType);
+ form.append('sourceName', input.sourceName);
+ form.append('sourceUrl', input.sourceUrl);
+ form.append('license', input.license);
+ form.append('author', input.author);
+ return apiUpload('/api/models', form);
+}
+
+export function updateModel(id: number, payload: ModelUpdate): Promise {
+ return apiPatch(`/api/models/${id}`, payload);
+}
+
+export function deleteModel(id: number): Promise {
+ return apiDelete(`/api/models/${id}`);
+}
+
+export function setPrimaryModel(id: number): Promise {
+ return apiPost(`/api/models/${id}/primary`, {});
+}
+
+/* ------------------------------------------------------------------ 审核 */
+
+export function listPending(params: ListParams & { status: PublishStatus }): Promise {
+ return apiGet('/api/reviews', {
+ status: params.status,
+ page: params.page,
+ pageSize: params.pageSize,
+ });
+}
+
+export function listReviewRecords(
+ params: ListParams & { targetType?: TargetType | '' },
+): Promise {
+ return apiGet('/api/reviews', {
+ targetType: params.targetType,
+ page: params.page,
+ pageSize: params.pageSize,
+ });
+}
+
+export function reviewApprove(
+ targetType: TargetType,
+ id: number,
+ body: ReviewActionBody,
+): Promise {
+ return apiPost(`/api/reviews/${targetType}/${id}/approve`, body);
+}
+
+export function reviewReject(
+ targetType: TargetType,
+ id: number,
+ body: ReviewActionBody,
+): Promise {
+ return apiPost(`/api/reviews/${targetType}/${id}/reject`, body);
+}
+
+export function reviewSubmit(
+ targetType: TargetType,
+ id: number,
+ body: ReviewActionBody,
+): Promise {
+ return apiPost(`/api/reviews/${targetType}/${id}/submit`, body);
+}
+
+/* -------------------------------------------------------------- 目录/健康 */
+
+export function getHealth(): Promise {
+ return apiGet('/health');
+}
+
+export function getCatalogVersion(): Promise {
+ return apiGet('/api/catalog/version');
+}
diff --git a/web-server/admin/src/api/types.ts b/web-server/admin/src/api/types.ts
new file mode 100644
index 0000000..353c35a
--- /dev/null
+++ b/web-server/admin/src/api/types.ts
@@ -0,0 +1,233 @@
+/**
+ * 与 contracts/rest-api.yaml (rest-api 0.1.0) 一一对应的类型定义。
+ * 任何字段级改动必须同步契约版本。
+ */
+
+/* ------------------------------------------------------------------ 枚举 */
+
+export const SOURCE_TYPES = [
+ 'original',
+ 'licensed',
+ 'cc',
+ 'public_domain',
+ 'user_upload',
+ 'other',
+] as const;
+export type SourceType = (typeof SOURCE_TYPES)[number];
+
+export const PUBLISH_STATUSES = ['draft', 'pending', 'published', 'rejected'] as const;
+export type PublishStatus = (typeof PUBLISH_STATUSES)[number];
+
+export const MOTION_FORMATS = ['bvh', 'fbx', 'gltf', 'glb', 'keypoints-json'] as const;
+export type MotionFormat = (typeof MOTION_FORMATS)[number];
+
+export const MODEL_FORMATS = ['vrm', 'glb', 'gltf', 'fbx'] as const;
+export type ModelFormat = (typeof MODEL_FORMATS)[number];
+
+export const PREPROCESS_STATES = ['none', 'running', 'done', 'failed'] as const;
+export type PreprocessState = (typeof PREPROCESS_STATES)[number];
+
+export const TARGET_TYPES = ['song', 'motion', 'model'] as const;
+export type TargetType = (typeof TARGET_TYPES)[number];
+
+export const REVIEW_ACTIONS = ['submit', 'approve', 'reject', 'withdraw'] as const;
+export type ReviewAction = (typeof REVIEW_ACTIONS)[number];
+
+export const SONG_LANGUAGES = ['zh', 'en', 'ja', 'ko', 'instrumental'] as const;
+export type SongLanguage = (typeof SONG_LANGUAGES)[number];
+
+/* ------------------------------------------------------------- 通用结构 */
+
+/** 来源五元组:songs / motions / models 三类资产均必填。 */
+export interface SourceFields {
+ sourceType: SourceType;
+ sourceName: string;
+ sourceUrl: string;
+ license: string;
+ author: string;
+}
+
+/**
+ * 表单态的来源五元组:sourceType 允许为空串,表示「未选择」,
+ * 以便客户端把「必填」真正校验出来。
+ */
+export interface SourceInput {
+ sourceType: SourceType | '';
+ sourceName: string;
+ sourceUrl: string;
+ license: string;
+ author: string;
+}
+
+/** 列表接口统一分页响应。 */
+export interface Page {
+ items: T[];
+ total: number;
+ page: number;
+ pageSize: number;
+}
+
+/* -------------------------------------------------------------- 资源体 */
+
+export interface Song extends SourceFields {
+ id: number;
+ title: string;
+ artist: string;
+ album: string | null;
+ language: string | null;
+ durationMs: number | null;
+ bpm: number | null;
+ difficulty: number | null;
+ tags: string[];
+ coverUrl: string | null;
+ audioUrl: string | null;
+ status: PublishStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Motion extends SourceFields {
+ id: number;
+ songId: number | null;
+ name: string;
+ format: MotionFormat;
+ fileUrl: string | null;
+ fileSize: number | null;
+ fps: number | null;
+ frameCount: number | null;
+ durationMs: number | null;
+ keypointFormat: string | null;
+ keypointUrl: string | null;
+ keypointFrames: number | null;
+ preprocessState: PreprocessState;
+ preprocessError: string | null;
+ status: PublishStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Model extends SourceFields {
+ id: number;
+ name: string;
+ format: ModelFormat;
+ vrmVersion: string | null;
+ isPrimary: boolean;
+ fileUrl: string | null;
+ fileSize: number | null;
+ thumbnailUrl: string | null;
+ status: PublishStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/* ---------------------------------------------------------- 请求载荷 */
+
+export type SongPage = Page;
+export type MotionPage = Page;
+export type ModelPage = Page;
+
+export interface SongCreate {
+ title: string;
+ artist: string;
+ album?: string;
+ language?: string;
+ durationMs?: number;
+ bpm?: number;
+ difficulty?: number;
+ tags?: string[];
+ status?: PublishStatus;
+ sourceType: SourceType;
+ sourceName: string;
+ sourceUrl: string;
+ license: string;
+ author: string;
+}
+
+/** PATCH /api/songs/:id —— 所有字段可选;提供 source* 时必须五元组同时提供。 */
+export type SongUpdate = Partial;
+
+/** PATCH /api/motions/:id —— 仅元数据可改。 */
+export interface MotionUpdate {
+ name?: string;
+ songId?: number | null;
+ fps?: number;
+ status?: PublishStatus;
+ sourceType?: SourceType;
+ sourceName?: string;
+ sourceUrl?: string;
+ license?: string;
+ author?: string;
+}
+
+/** PATCH /api/models/:id —— 仅元数据可改。 */
+export interface ModelUpdate {
+ name?: string;
+ status?: PublishStatus;
+ sourceType?: SourceType;
+ sourceName?: string;
+ sourceUrl?: string;
+ license?: string;
+ author?: string;
+}
+
+/* ------------------------------------------------------------ 审核相关 */
+
+export interface ReviewRecord {
+ id: number;
+ targetType: TargetType;
+ targetId: number;
+ action: ReviewAction;
+ fromStatus: PublishStatus;
+ toStatus: PublishStatus;
+ reviewer: string | null;
+ comment: string | null;
+ createdAt: string;
+}
+
+/** GET /api/reviews?status= 返回的待审资产条目。 */
+export interface PendingItem {
+ targetType: TargetType;
+ targetId: number;
+ name: string;
+ status: PublishStatus;
+ source: SourceFields;
+ updatedAt: string;
+}
+
+export type ReviewPage = Page;
+export type PendingPage = Page;
+
+export interface ReviewActionBody {
+ reviewer?: string;
+ comment?: string;
+}
+
+/* ------------------------------------------------------------ 目录/健康 */
+
+export interface ContractVersion {
+ contractVersion: Record;
+ revision: string;
+}
+
+export interface HealthDatabase {
+ connected: boolean;
+ server?: string;
+ database?: string;
+ migrationsPending?: number;
+}
+
+export interface Health {
+ status: 'ok' | 'degraded';
+ uptimeSec: number;
+ version: string;
+ contractVersion: Record;
+ database: HealthDatabase;
+}
+
+/* ------------------------------------------------------------ 错误响应 */
+
+export interface ApiErrorBody {
+ statusCode?: number;
+ message?: string | string[];
+ error?: string;
+}
diff --git a/web-server/admin/src/components/Layout.tsx b/web-server/admin/src/components/Layout.tsx
new file mode 100644
index 0000000..df038fd
--- /dev/null
+++ b/web-server/admin/src/components/Layout.tsx
@@ -0,0 +1,57 @@
+import { NavLink, Outlet } from 'react-router-dom';
+
+interface NavItem {
+ to: string;
+ label: string;
+ hint?: string;
+ end?: boolean;
+}
+
+const NAV_ITEMS: NavItem[] = [
+ { to: '/', label: '概览', hint: 'Dashboard', end: true },
+ { to: '/songs', label: '歌曲管理', hint: 'T-WEB-008' },
+ { to: '/models', label: '模型管理', hint: 'T-WEB-009' },
+ { to: '/motions', label: '动作管理', hint: 'T-WEB-004/006' },
+ { to: '/reviews', label: '审核', hint: 'T-WEB-007' },
+];
+
+const API_BASE = import.meta.env.VITE_API_BASE ?? '';
+
+export function Layout() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/web-server/admin/src/components/VrmPreview.tsx b/web-server/admin/src/components/VrmPreview.tsx
new file mode 100644
index 0000000..943067c
--- /dev/null
+++ b/web-server/admin/src/components/VrmPreview.tsx
@@ -0,0 +1,310 @@
+import { useEffect, useRef, useState } from 'react';
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
+import { VRM, VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
+
+type PreviewState =
+ | { kind: 'idle' }
+ | { kind: 'loading'; ratio: number | null }
+ | { kind: 'ready'; metaVersion: string }
+ | { kind: 'error'; message: string };
+
+/** 从 GLTFLoader 的 userData 中安全取出 VRM 实例(three 的 userData 是 any)。 */
+function extractVrm(userData: Record): VRM | null {
+ const candidate = userData['vrm'];
+ return candidate instanceof VRM ? candidate : null;
+}
+
+/** 兜底释放:VRMUtils.deepDispose 之外的几何/材质/贴图。 */
+function disposeObject(root: THREE.Object3D): void {
+ root.traverse((object) => {
+ const mesh = object as THREE.Mesh;
+ if (mesh.geometry) mesh.geometry.dispose();
+ const material = mesh.material;
+ if (!material) return;
+ const list: THREE.Material[] = Array.isArray(material) ? material : [material];
+ for (const item of list) {
+ for (const value of Object.values(item)) {
+ if (value instanceof THREE.Texture) value.dispose();
+ }
+ item.dispose();
+ }
+ });
+}
+
+interface VrmPreviewProps {
+ fileUrl: string | null;
+ name: string;
+ /** 非 VRM 且无法用 GLTFLoader 解析时给出提示(如 fbx)。 */
+ unsupportedReason?: string | null;
+ height?: number;
+}
+
+/**
+ * VRM 3D 预览:GLTFLoader + VRMLoaderPlugin。
+ * - 处理 VRM 0.x / 1.0(vrm.meta.metaVersion)
+ * - 处理「未选择 / 加载中 / 加载失败」状态
+ * - 鼠标拖拽旋转 + 滚轮缩放(自实现,未引入 OrbitControls)
+ * - requestAnimationFrame 循环中调用 vrm.update(delta)
+ * - 卸载时释放 renderer / geometry / material / texture
+ */
+export function VrmPreview({ fileUrl, name, unsupportedReason = null, height = 420 }: VrmPreviewProps) {
+ const mountRef = useRef(null);
+ const [state, setState] = useState({ kind: 'idle' });
+ const [reloadKey, setReloadKey] = useState(0);
+
+ useEffect(() => {
+ const mount = mountRef.current;
+ if (!mount) return;
+
+ if (!fileUrl) {
+ setState({ kind: 'idle' });
+ return;
+ }
+ if (unsupportedReason) {
+ setState({ kind: 'error', message: unsupportedReason });
+ return;
+ }
+
+ setState({ kind: 'loading', ratio: null });
+
+ const width = Math.max(1, mount.clientWidth);
+ const height2 = Math.max(1, mount.clientHeight || height);
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x14181f);
+
+ const camera = new THREE.PerspectiveCamera(30, width / height2, 0.1, 1000);
+
+ let renderer: THREE.WebGLRenderer;
+ try {
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
+ } catch (cause) {
+ // 无 WebGL 支持时不要让整个页面崩溃
+ const detail = cause instanceof Error ? cause.message : String(cause);
+ setState({ kind: 'error', message: `当前浏览器无法创建 WebGL 上下文:${detail}` });
+ return;
+ }
+
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setSize(width, height2, false);
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ mount.appendChild(renderer.domElement);
+
+ scene.add(new THREE.AmbientLight(0xffffff, 1.6));
+ const keyLight = new THREE.DirectionalLight(0xffffff, 2.2);
+ keyLight.position.set(1, 2, 1.5);
+ scene.add(keyLight);
+ const rimLight = new THREE.DirectionalLight(0x88bbff, 1.0);
+ rimLight.position.set(-1.2, 1.2, -1.5);
+ scene.add(rimLight);
+
+ const grid = new THREE.GridHelper(4, 8, 0x2a3340, 0x1e2530);
+ scene.add(grid);
+
+ // 轨道参数(自实现的 drag rotate / wheel zoom)
+ const orbit = { theta: 0, phi: Math.PI / 2.2, radius: 3, targetY: 1 };
+ const updateCamera = (): void => {
+ const sinPhi = Math.sin(orbit.phi);
+ camera.position.set(
+ orbit.radius * sinPhi * Math.sin(orbit.theta),
+ orbit.targetY + orbit.radius * Math.cos(orbit.phi),
+ orbit.radius * sinPhi * Math.cos(orbit.theta),
+ );
+ camera.lookAt(0, orbit.targetY, 0);
+ };
+ updateCamera();
+
+ let dragging = false;
+ let lastX = 0;
+ let lastY = 0;
+
+ const onPointerDown = (event: PointerEvent): void => {
+ dragging = true;
+ lastX = event.clientX;
+ lastY = event.clientY;
+ renderer.domElement.setPointerCapture(event.pointerId);
+ };
+ const onPointerMove = (event: PointerEvent): void => {
+ if (!dragging) return;
+ const dx = event.clientX - lastX;
+ const dy = event.clientY - lastY;
+ lastX = event.clientX;
+ lastY = event.clientY;
+ orbit.theta -= dx * 0.01;
+ orbit.phi = Math.min(Math.PI - 0.15, Math.max(0.15, orbit.phi - dy * 0.01));
+ updateCamera();
+ };
+ const onPointerUp = (event: PointerEvent): void => {
+ dragging = false;
+ if (renderer.domElement.hasPointerCapture(event.pointerId)) {
+ renderer.domElement.releasePointerCapture(event.pointerId);
+ }
+ };
+ const onWheel = (event: WheelEvent): void => {
+ event.preventDefault();
+ orbit.radius = Math.min(20, Math.max(0.4, orbit.radius * (1 + event.deltaY * 0.001)));
+ updateCamera();
+ };
+
+ const canvas = renderer.domElement;
+ canvas.addEventListener('pointerdown', onPointerDown);
+ canvas.addEventListener('pointermove', onPointerMove);
+ canvas.addEventListener('pointerup', onPointerUp);
+ canvas.addEventListener('pointercancel', onPointerUp);
+ canvas.addEventListener('wheel', onWheel, { passive: false });
+
+ const resizeObserver = new ResizeObserver(() => {
+ const w = Math.max(1, mount.clientWidth);
+ const h = Math.max(1, mount.clientHeight || height);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ renderer.setSize(w, h, false);
+ });
+ resizeObserver.observe(mount);
+
+ const clock = new THREE.Clock();
+ let rafId = 0;
+ let vrm: VRM | null = null;
+ let disposed = false;
+ let loadedModel: THREE.Object3D | null = null;
+
+ const tick = (): void => {
+ rafId = requestAnimationFrame(tick);
+ const delta = clock.getDelta();
+ if (vrm) vrm.update(delta);
+ renderer.render(scene, camera);
+ };
+ rafId = requestAnimationFrame(tick);
+
+ const loader = new GLTFLoader();
+ loader.register((parser) => new VRMLoaderPlugin(parser));
+
+ loader.load(
+ fileUrl,
+ (gltf) => {
+ if (disposed) {
+ disposeObject(gltf.scene);
+ return;
+ }
+ const bag: Record = gltf.userData;
+ const loadedVrm = extractVrm(bag);
+ const root: THREE.Object3D = loadedVrm ? loadedVrm.scene : gltf.scene;
+ loadedModel = root;
+ vrm = loadedVrm;
+
+ if (loadedVrm) {
+ // VRM 0.x 面向 +Z,需要转 180°;1.0 不需要。
+ VRMUtils.rotateVRM0(loadedVrm);
+ }
+ scene.add(root);
+
+ // 按包围盒自动取景:兼容米制与厘米制模型
+ const box = new THREE.Box3().setFromObject(root);
+ if (!box.isEmpty()) {
+ const size = box.getSize(new THREE.Vector3());
+ const center = box.getCenter(new THREE.Vector3());
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
+ orbit.targetY = center.y;
+ orbit.radius = maxDim * 1.9;
+ grid.position.y = box.min.y;
+ updateCamera();
+ }
+
+ const meta = loadedVrm ? loadedVrm.meta.metaVersion : null;
+ setState({ kind: 'ready', metaVersion: meta === '1' ? '1.0' : meta === '0' ? '0.x' : '非 VRM(glTF)' });
+ },
+ (event) => {
+ if (disposed) return;
+ const ratio = event.total > 0 ? event.loaded / event.total : null;
+ setState({ kind: 'loading', ratio });
+ },
+ (error: unknown) => {
+ if (disposed) return;
+ const message = error instanceof Error ? error.message : String(error);
+ setState({ kind: 'error', message: `模型加载失败:${message}` });
+ },
+ );
+
+ return () => {
+ disposed = true;
+ cancelAnimationFrame(rafId);
+ resizeObserver.disconnect();
+ canvas.removeEventListener('pointerdown', onPointerDown);
+ canvas.removeEventListener('pointermove', onPointerMove);
+ canvas.removeEventListener('pointerup', onPointerUp);
+ canvas.removeEventListener('pointercancel', onPointerUp);
+ canvas.removeEventListener('wheel', onWheel);
+
+ if (loadedModel) {
+ scene.remove(loadedModel);
+ if (vrm) {
+ try {
+ VRMUtils.deepDispose(vrm.scene);
+ } catch {
+ disposeObject(loadedModel);
+ }
+ } else {
+ disposeObject(loadedModel);
+ }
+ }
+ scene.clear();
+ renderer.dispose();
+ if (canvas.parentNode === mount) mount.removeChild(canvas);
+ };
+ }, [fileUrl, unsupportedReason, reloadKey, height]);
+
+ const hasModel = fileUrl !== null && fileUrl !== '';
+
+ return (
+
+
+
+ 3D 预览
+ {name || '未选择模型'}
+
+ {hasModel ? (
+
+ ) : null}
+
+
+
+
+ {state.kind === 'idle' ? (
+
+
尚未选择模型
+
在左侧列表点击「预览」以加载 VRM。
+
+ ) : null}
+ {state.kind === 'loading' ? (
+
+
+
加载模型中… {state.ratio !== null ? `${Math.round(state.ratio * 100)}%` : ''}
+
+ ) : null}
+ {state.kind === 'error' ? (
+
+
⚠ {state.message}
+
+
+ ) : null}
+
+
+
+
+ 拖拽旋转 · 滚轮缩放
+ {state.kind === 'ready' ? ` · VRM 版本:${state.metaVersion}` : ''}
+
+ {fileUrl ? (
+
+ 模型文件
+
+ ) : null}
+
+
+ );
+}
diff --git a/web-server/admin/src/components/ui.tsx b/web-server/admin/src/components/ui.tsx
new file mode 100644
index 0000000..01d90ec
--- /dev/null
+++ b/web-server/admin/src/components/ui.tsx
@@ -0,0 +1,385 @@
+import { useEffect, useState, type ReactNode } from 'react';
+import type { ModelFormat, MotionFormat, PublishStatus, SourceFields, SourceInput, SourceType } from '../api/types';
+import { MODEL_FORMAT_LABELS, MOTION_FORMAT_LABELS, SOURCE_TYPE_OPTIONS, STATUS_LABELS, sourceTypeLabel } from '../lib/labels';
+
+/* ------------------------------------------------------------ 状态占位 */
+
+export function Loading({ text = '加载中…' }: { text?: string }) {
+ return (
+
+
+ {text}
+
+ );
+}
+
+export function ErrorBox({ message, onRetry }: { message: string; onRetry?: () => void }) {
+ return (
+
+
+ 加载失败
+ {message}
+
+ {onRetry ? (
+
+ ) : null}
+
+ );
+}
+
+export function Empty({ text = '暂无数据' }: { text?: string }) {
+ return {text}
;
+}
+
+export function Notice({
+ kind,
+ children,
+ onClose,
+}: {
+ kind: 'success' | 'error' | 'info';
+ children: ReactNode;
+ onClose?: () => void;
+}) {
+ return (
+
+ {children}
+ {onClose ? (
+
+ ) : null}
+
+ );
+}
+
+/* ---------------------------------------------------------------- 弹层 */
+
+interface ModalProps {
+ title: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+ size?: 'md' | 'lg' | 'xl';
+}
+
+export function Modal({ title, onClose, children, footer, size = 'md' }: ModalProps) {
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [onClose]);
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
{children}
+ {footer ?
: null}
+
+
+ );
+}
+
+export function ConfirmDialog({
+ title,
+ message,
+ confirmText = '确认',
+ danger = false,
+ busy = false,
+ onConfirm,
+ onCancel,
+}: {
+ title: string;
+ message: ReactNode;
+ confirmText?: string;
+ danger?: boolean;
+ busy?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+
+
+
+ >
+ }
+ >
+ {message}
+
+ );
+}
+
+/* ------------------------------------------------------------ 表单零件 */
+
+export function Field({
+ label,
+ required = false,
+ error,
+ hint,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ error?: string;
+ hint?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function StatusBadge({ status }: { status: PublishStatus | string }) {
+ const label = STATUS_LABELS[status as PublishStatus] ?? status;
+ return {label};
+}
+
+export function FormatBadge({ format, kind }: { format: ModelFormat | MotionFormat | string; kind: 'model' | 'motion' }) {
+ const label =
+ kind === 'model'
+ ? MODEL_FORMAT_LABELS[format as ModelFormat] ?? format
+ : MOTION_FORMAT_LABELS[format as MotionFormat] ?? format;
+ const highlight = format === 'vrm' ? ' badge-vrm' : '';
+ return {label}{format === 'vrm' ? ' ★' : ''};
+}
+
+/* -------------------------------------------------------------- 链接 */
+
+export function ExternalLink({ href, children }: { href: string; children?: ReactNode }) {
+ if (!/^https?:\/\//i.test(href)) {
+ return {href || '—'};
+ }
+ return (
+
+ {children ?? href}
+
+ );
+}
+
+/* ------------------------------------------------------- 来源五元组 */
+
+/** 表格内的来源五元组:摘要 + 可展开全部 5 项,sourceUrl 为外链。 */
+export function SourceCell({ source }: { source: SourceFields }) {
+ const [open, setOpen] = useState(false);
+ const tooltip =
+ `来源类型:${sourceTypeLabel(source.sourceType)}(${source.sourceType})\n` +
+ `来源名称:${source.sourceName}\n` +
+ `来源链接:${source.sourceUrl}\n` +
+ `许可:${source.license}\n` +
+ `作者:${source.author}`;
+
+ return (
+
+
+ {sourceTypeLabel(source.sourceType)}
+ {source.sourceName || '—'}
+
+
+ 作者:{source.author || '—'}
+ 许可:{source.license || '—'}
+
+
+
+
+
+ {open ? (
+
+
+
- sourceType
+ -
+ {SOURCE_TYPE_OPTIONS.find((option) => option.value === source.sourceType)?.label ?? source.sourceType}
+ ({source.sourceType})
+
+
+
+
- sourceName
+ - {source.sourceName || '—'}
+
+
+
- sourceUrl
+ -
+
+
+
+
+
- license
+ - {source.license || '—'}
+
+
+
- author
+ - {source.author || '—'}
+
+
+ ) : null}
+
+ );
+}
+
+/* -------------------------------------------------------------- 分页 */
+
+export function Pagination({
+ page,
+ pageSize,
+ total,
+ onChange,
+ disabled = false,
+}: {
+ page: number;
+ pageSize: number;
+ total: number;
+ onChange: (page: number) => void;
+ disabled?: boolean;
+}) {
+ const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
+ return (
+
+
+ 共 {total} 条 · 第 {page} / {totalPages} 页
+
+
+
+
+
+
+
+
+ );
+}
+
+/* ------------------------------------------------------ 来源五元组表单 */
+
+export type SourceFormValue = SourceInput;
+
+export function SourceFieldsForm({
+ value,
+ errors,
+ onChange,
+ disabled = false,
+}: {
+ value: SourceFormValue;
+ errors: Record;
+ onChange: (patch: Partial) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
diff --git a/web-server/admin/src/lib/format.ts b/web-server/admin/src/lib/format.ts
new file mode 100644
index 0000000..042875c
--- /dev/null
+++ b/web-server/admin/src/lib/format.ts
@@ -0,0 +1,60 @@
+/** 展示层格式化助手,全部对 null / 非法值做兜底。 */
+
+export function formatDateTime(value: string | null | undefined): string {
+ if (!value) return '—';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ const pad = (n: number): string => String(n).padStart(2, '0');
+ return (
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`
+ );
+}
+
+export function formatBytes(value: number | null | undefined): string {
+ if (value === null || value === undefined || !Number.isFinite(value)) return '—';
+ const units = ['B', 'KiB', 'MiB', 'GiB'];
+ let size = value;
+ let unit = 0;
+ while (size >= 1024 && unit < units.length - 1) {
+ size /= 1024;
+ unit += 1;
+ }
+ return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
+}
+
+/** 毫秒 → mm:ss(.ms) */
+export function formatDuration(ms: number | null | undefined): string {
+ if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) return '—';
+ const totalSeconds = ms / 1000;
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds - minutes * 60;
+ const secText = seconds < 10 ? `0${seconds.toFixed(1)}` : seconds.toFixed(1);
+ return `${minutes}:${secText}`;
+}
+
+export function formatNumber(value: number | null | undefined, digits = 2): string {
+ if (value === null || value === undefined || !Number.isFinite(value)) return '—';
+ return String(Number(value.toFixed(digits)));
+}
+
+export function tagsToText(tags: readonly string[] | null | undefined): string {
+ if (!tags || tags.length === 0) return '';
+ return tags.join(', ');
+}
+
+export function parseTags(text: string): string[] {
+ return text
+ .split(/[,,]/)
+ .map((part) => part.trim())
+ .filter((part) => part !== '');
+}
+
+export function truncateMiddle(text: string, head = 10, tail = 8): string {
+ if (text.length <= head + tail + 1) return text;
+ return `${text.slice(0, head)}…${text.slice(-tail)}`;
+}
+
+export function formatPercent(value: number): string {
+ return `${Math.round(value * 100)}%`;
+}
diff --git a/web-server/admin/src/lib/labels.ts b/web-server/admin/src/lib/labels.ts
new file mode 100644
index 0000000..e5c03b0
--- /dev/null
+++ b/web-server/admin/src/lib/labels.ts
@@ -0,0 +1,100 @@
+/** 枚举 → 中文展示文案。 */
+
+import type {
+ ModelFormat,
+ MotionFormat,
+ PreprocessState,
+ PublishStatus,
+ ReviewAction,
+ SourceType,
+ TargetType,
+} from '../api/types';
+
+export const SOURCE_TYPE_LABELS: Record = {
+ original: '原创',
+ licensed: '已获授权',
+ cc: 'Creative Commons',
+ public_domain: '公共领域',
+ user_upload: '用户上传',
+ other: '其他',
+};
+
+/** 与 contracts/rest-api.yaml 的 SourceType 枚举一一对应(顺序即下拉顺序)。 */
+export const SOURCE_TYPE_OPTIONS: ReadonlyArray<{ value: SourceType; label: string }> = [
+ { value: 'original', label: '原创' },
+ { value: 'licensed', label: '已获授权' },
+ { value: 'cc', label: 'Creative Commons' },
+ { value: 'public_domain', label: '公共领域' },
+ { value: 'user_upload', label: '用户上传(需审核)' },
+ { value: 'other', label: '其他(须在来源名称说明)' },
+];
+
+export const STATUS_LABELS: Record = {
+ draft: '草稿',
+ pending: '待审核',
+ published: '已发布',
+ rejected: '已驳回',
+};
+
+export const STATUS_OPTIONS: ReadonlyArray<{ value: PublishStatus; label: string }> = [
+ { value: 'draft', label: '草稿' },
+ { value: 'pending', label: '待审核' },
+ { value: 'published', label: '已发布' },
+ { value: 'rejected', label: '已驳回' },
+];
+
+export const PREPROCESS_LABELS: Record = {
+ none: '未处理',
+ running: '处理中',
+ done: '已完成',
+ failed: '失败',
+};
+
+export const MOTION_FORMAT_LABELS: Record = {
+ bvh: 'BVH',
+ fbx: 'FBX',
+ gltf: 'glTF',
+ glb: 'GLB',
+ 'keypoints-json': '关键点 JSON',
+};
+
+export const MODEL_FORMAT_LABELS: Record = {
+ vrm: 'VRM',
+ glb: 'GLB',
+ gltf: 'glTF',
+ fbx: 'FBX',
+};
+
+export const TARGET_TYPE_LABELS: Record = {
+ song: '曲目',
+ motion: '动作',
+ model: '模型',
+};
+
+export const REVIEW_ACTION_LABELS: Record = {
+ submit: '提交审核',
+ approve: '通过',
+ reject: '驳回',
+ withdraw: '撤回',
+};
+
+export const LANGUAGE_LABELS: Record = {
+ zh: '中文',
+ en: '英文',
+ ja: '日文',
+ ko: '韩文',
+ instrumental: '纯音乐',
+};
+
+export function sourceTypeLabel(value: string): string {
+ return SOURCE_TYPE_LABELS[value as SourceType] ?? value;
+}
+
+export function statusLabel(value: string): string {
+ return STATUS_LABELS[value as PublishStatus] ?? value;
+}
+
+export function languageLabel(value: string | null | undefined): string {
+ if (!value) return '—';
+ return LANGUAGE_LABELS[value] ?? value;
+}
diff --git a/web-server/admin/src/lib/useAsync.ts b/web-server/admin/src/lib/useAsync.ts
new file mode 100644
index 0000000..1bc3cce
--- /dev/null
+++ b/web-server/admin/src/lib/useAsync.ts
@@ -0,0 +1,70 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { errorMessage } from '../api/client';
+
+export interface AsyncState {
+ data: T | null;
+ loading: boolean;
+ error: string | null;
+ reload: () => void;
+}
+
+/** 已决结果包装:用于「部分请求失败也要出画面」的场景。 */
+export interface Settled {
+ ok: boolean;
+ value: T | null;
+ error: string | null;
+}
+
+export async function settle(promise: Promise): Promise> {
+ try {
+ return { ok: true, value: await promise, error: null };
+ } catch (cause) {
+ return { ok: false, value: null, error: errorMessage(cause) };
+ }
+}
+
+/**
+ * 通用异步加载 hook:保证 loading / error 一定被渲染,
+ * 请求失败绝不会让页面白屏(error 由页面展示 + 重试按钮)。
+ */
+export function useAsync(loader: () => Promise, deps: readonly unknown[]): AsyncState {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [nonce, setNonce] = useState(0);
+
+ const loaderRef = useRef(loader);
+ loaderRef.current = loader;
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ loaderRef
+ .current()
+ .then((result) => {
+ if (cancelled) return;
+ setData(result);
+ setError(null);
+ })
+ .catch((cause: unknown) => {
+ if (cancelled) return;
+ setData(null);
+ setError(errorMessage(cause));
+ })
+ .finally(() => {
+ if (cancelled) return;
+ setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ // deps 由调用方声明;nonce 用于手动 reload。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [...deps, nonce]);
+
+ const reload = useCallback(() => {
+ setNonce((value) => value + 1);
+ }, []);
+
+ return { data, loading, error, reload };
+}
diff --git a/web-server/admin/src/lib/validation.ts b/web-server/admin/src/lib/validation.ts
new file mode 100644
index 0000000..1cb20a8
--- /dev/null
+++ b/web-server/admin/src/lib/validation.ts
@@ -0,0 +1,57 @@
+/** 与 contracts/rest-api.yaml 对齐的客户端校验。 */
+
+import type { SourceInput } from '../api/types';
+
+export type FieldErrors = Record;
+
+export const HTTP_URL_PATTERN = /^https?:\/\/.+/i;
+
+export function isHttpUrl(value: string): boolean {
+ return HTTP_URL_PATTERN.test(value.trim());
+}
+
+export function hasErrors(errors: FieldErrors): boolean {
+ return Object.keys(errors).length > 0;
+}
+
+/** 来源五元组:五项均必填,sourceUrl 必须 http(s)。 */
+export function validateSourceFields(source: SourceInput, errors: FieldErrors): void {
+ if (!source.sourceType) errors.sourceType = '来源类型必填'; if (!source.sourceName.trim()) errors.sourceName = '来源名称必填';
+ else if (source.sourceName.trim().length > 200) errors.sourceName = '来源名称不超过 200 字';
+
+ const url = source.sourceUrl.trim();
+ if (!url) errors.sourceUrl = '来源链接必填';
+ else if (!isHttpUrl(url)) errors.sourceUrl = '来源链接必须以 http:// 或 https:// 开头';
+ else if (url.length > 500) errors.sourceUrl = '来源链接不超过 500 字';
+
+ if (!source.license.trim()) errors.license = '许可必填';
+ else if (source.license.trim().length > 100) errors.license = '许可不超过 100 字';
+
+ if (!source.author.trim()) errors.author = '作者必填';
+ else if (source.author.trim().length > 200) errors.author = '作者不超过 200 字';
+}
+
+/** 校验必填文本并写入 errors,返回是否通过。 */
+export function requireText(
+ value: string,
+ field: string,
+ label: string,
+ errors: FieldErrors,
+ maxLength?: number,
+): void {
+ const text = value.trim();
+ if (!text) {
+ errors[field] = `${label}必填`;
+ return;
+ }
+ if (maxLength !== undefined && text.length > maxLength) {
+ errors[field] = `${label}不超过 ${maxLength} 字`;
+ }
+}
+
+/** 解析可选数值;空串返回 undefined,非法返回 NaN 以便调用方报错。 */
+export function parseOptionalNumber(value: string): number | undefined {
+ const text = value.trim();
+ if (text === '') return undefined;
+ return Number(text);
+}
diff --git a/web-server/admin/src/main.tsx b/web-server/admin/src/main.tsx
new file mode 100644
index 0000000..c8d42e8
--- /dev/null
+++ b/web-server/admin/src/main.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import App from './App';
+import './styles.css';
+
+const container = document.getElementById('root');
+if (!container) {
+ throw new Error('找不到 #root 挂载点,请检查 index.html');
+}
+
+createRoot(container).render(
+
+
+
+
+ ,
+);
diff --git a/web-server/admin/src/pages/Dashboard.tsx b/web-server/admin/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..825fc5d
--- /dev/null
+++ b/web-server/admin/src/pages/Dashboard.tsx
@@ -0,0 +1,209 @@
+import { Link } from 'react-router-dom';
+import { apiGet } from '../api/client';
+import { getCatalogVersion, getHealth } from '../api/endpoints';
+import {
+ PUBLISH_STATUSES,
+ type ContractVersion,
+ type Health,
+ type Page,
+ type PublishStatus,
+} from '../api/types';
+import { STATUS_LABELS } from '../lib/labels';
+import { formatDateTime } from '../lib/format';
+import { settle, useAsync, type Settled } from '../lib/useAsync';
+import { Empty, ErrorBox, Loading } from '../components/ui';
+
+type StatusCounts = Record;
+
+interface KindRow {
+ key: 'songs' | 'motions' | 'models';
+ label: string;
+ path: string;
+}
+
+const KINDS: KindRow[] = [
+ { key: 'songs', label: '曲目 songs', path: '/songs' },
+ { key: 'motions', label: '动作 motions', path: '/motions' },
+ { key: 'models', label: '模型 models', path: '/models' },
+];
+
+/** 每个状态各发一次 pageSize=1 的请求,用 total 求和;单个失败不影响其他。 */
+async function fetchStatusCounts(kind: string): Promise {
+ const results = await Promise.all(
+ PUBLISH_STATUSES.map(async (status) => {
+ try {
+ const page = await apiGet>(`/api/${kind}`, { page: 1, pageSize: 1, status });
+ const total = typeof page.total === 'number' ? page.total : 0;
+ return [status, total] as const;
+ } catch {
+ return [status, null] as const;
+ }
+ }),
+ );
+ const counts: StatusCounts = { draft: null, pending: null, published: null, rejected: null };
+ for (const [status, value] of results) counts[status] = value;
+ return counts;
+}
+
+function sumCounts(counts: StatusCounts): number | null {
+ let total = 0;
+ for (const status of PUBLISH_STATUSES) {
+ const value = counts[status];
+ if (value === null) return null;
+ total += value;
+ }
+ return total;
+}
+
+interface DashboardData {
+ health: Settled;
+ version: Settled;
+ counts: Record;
+}
+
+export function DashboardPage() {
+ const state = useAsync(async () => {
+ const [health, version, songs, motions, models] = await Promise.all([
+ settle(getHealth()),
+ settle(getCatalogVersion()),
+ fetchStatusCounts('songs'),
+ fetchStatusCounts('motions'),
+ fetchStatusCounts('models'),
+ ]);
+ return { health, version, counts: { songs, motions, models } };
+ }, []);
+
+ if (state.loading && !state.data) return ;
+ if (state.error && !state.data) return ;
+ if (!state.data) return ;
+
+ const { health, version, counts } = state.data;
+ const unreachable = !health.ok;
+ const healthValue = health.value;
+
+ return (
+
+
+
+ {unreachable ? (
+
+ ⚠ API 服务不可达
+ {health.error ?? '未知错误'}
+
+ 管理端默认请求同源 /api 与 /health:开发期由 vite 代理到
+ http://localhost:3000,生产期由 nginx 反代到 http://api:3000。
+ 请确认服务端已启动,或通过 VITE_API_BASE 指定其他基址。
+
+
+ ) : null}
+
+ {state.error ?
: null}
+
+
+ {KINDS.map((kind) => {
+ const kindCounts = counts[kind.key];
+ const total = sumCounts(kindCounts);
+ return (
+
+
+ {kind.label}
+
+ 进入管理 →
+
+
+
+ {total === null ? — : total}
+ 条记录
+
+
+ {PUBLISH_STATUSES.map((status) => (
+ -
+
+ {STATUS_LABELS[status]}
+ {kindCounts[status] === null ? '—' : kindCounts[status]}
+
+ ))}
+
+ {total === null ? 部分状态统计失败(服务不可达或鉴权失败)
: null}
+
+ );
+ })}
+
+
+
+
+ 契约版本 / 目录指纹
+ GET /api/catalog/version
+
+
+ {version.ok && version.value ? (
+ <>
+
+ {Object.entries(version.value.contractVersion ?? {}).map(([key, value]) => (
+
+ {key}
+ {value}
+
+ ))}
+
+
+ revision(目录 sha256)
+ {version.value.revision || '—'}
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {health.ok && healthValue ? (
+
+
+ status
+ {healthValue.status}
+
+
+ version
+ {healthValue.version}
+
+
+ uptime
+ {Math.round(healthValue.uptimeSec ?? 0)} s
+
+
+ database
+
+ {healthValue.database?.connected ? 'connected' : 'disconnected'}
+ {healthValue.database?.server ? ` · ${healthValue.database.server}` : ''}
+
+
+
+ 迁移待执行
+ {healthValue.database?.migrationsPending ?? 0}
+
+
+ ) : (
+
+ )}
+
+
+
+
最后刷新:{formatDateTime(new Date().toISOString())}
+
+ );
+}
diff --git a/web-server/admin/src/pages/Models.tsx b/web-server/admin/src/pages/Models.tsx
new file mode 100644
index 0000000..50d1c60
--- /dev/null
+++ b/web-server/admin/src/pages/Models.tsx
@@ -0,0 +1,609 @@
+import { lazy, Suspense, useState, type FormEvent } from 'react';
+import { errorMessage } from '../api/client';
+import { deleteModel, listModels, setPrimaryModel, updateModel, uploadModel } from '../api/endpoints';
+import type { Model, ModelUpdate, PublishStatus } from '../api/types';
+import { MODEL_FORMATS } from '../api/types';
+import { formatBytes, formatDateTime } from '../lib/format';
+import { MODEL_FORMAT_LABELS, STATUS_OPTIONS } from '../lib/labels';
+import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
+import { useAsync } from '../lib/useAsync';
+import {
+ ConfirmDialog,
+ Empty,
+ ErrorBox,
+ Field,
+ FormatBadge,
+ Loading,
+ Modal,
+ Notice,
+ Pagination,
+ SourceCell,
+ SourceFieldsForm,
+ StatusBadge,
+ type SourceFormValue,
+} from '../components/ui';
+
+// three + @pixiv/three-vrm 体积较大,按需懒加载,避免拖慢其他页面首屏。
+const VrmPreview = lazy(async () => {
+ const module = await import('../components/VrmPreview');
+ return { default: module.VrmPreview };
+});
+
+const PAGE_SIZE = 20;
+const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx';
+
+interface ModelFilters {
+ q: string;
+ format: string;
+ status: PublishStatus | '';
+ page: number;
+}
+
+interface ModelFormState extends SourceFormValue {
+ name: string;
+ status: PublishStatus;
+ isPrimary: boolean;
+}
+
+interface ModelEditState extends SourceFormValue {
+ name: string;
+ status: PublishStatus;
+}
+
+const EMPTY_MODEL_FORM: ModelFormState = {
+ name: '',
+ status: 'draft',
+ isPrimary: false,
+ sourceType: '',
+ sourceName: '',
+ sourceUrl: '',
+ license: '',
+ author: '',
+};
+
+function validateModelForm(form: ModelFormState | ModelEditState): FieldErrors {
+ const errors: FieldErrors = {};
+ requireText(form.name, 'name', '模型名称', errors, 200);
+ validateSourceFields(form, errors);
+ return errors;
+}
+
+/* ------------------------------------------------------------ 上传弹窗 */
+
+function ModelUploadModal({
+ onClose,
+ onSaved,
+}: {
+ onClose: () => void;
+ onSaved: (message: string) => void;
+}) {
+ const [form, setForm] = useState(EMPTY_MODEL_FORM);
+ const [file, setFile] = useState(null);
+ const [errors, setErrors] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [serverError, setServerError] = useState(null);
+
+ const patch = (part: Partial): void => {
+ setForm((previous) => ({ ...previous, ...part }));
+ };
+
+ const onSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ const nextErrors = validateModelForm(form);
+ if (!file) nextErrors.file = '请选择模型文件';
+ setErrors(nextErrors);
+ if (hasErrors(nextErrors) || !file) return;
+ if (form.sourceType === '') return;
+
+ setSaving(true);
+ setServerError(null);
+ try {
+ await uploadModel({
+ file,
+ name: form.name.trim(),
+ isPrimary: form.isPrimary,
+ status: form.status,
+ sourceType: form.sourceType,
+ sourceName: form.sourceName.trim(),
+ sourceUrl: form.sourceUrl.trim(),
+ license: form.license.trim(),
+ author: form.author.trim(),
+ });
+ onSaved('模型已上传');
+ } catch (cause) {
+ setServerError(errorMessage(cause));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+/* ------------------------------------------------------------ 编辑弹窗 */
+
+function ModelEditModal({
+ model,
+ onClose,
+ onSaved,
+}: {
+ model: Model;
+ onClose: () => void;
+ onSaved: (message: string) => void;
+}) {
+ const [form, setForm] = useState({
+ name: model.name,
+ status: model.status,
+ sourceType: model.sourceType,
+ sourceName: model.sourceName,
+ sourceUrl: model.sourceUrl,
+ license: model.license,
+ author: model.author,
+ });
+ const [errors, setErrors] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [serverError, setServerError] = useState(null);
+
+ const patch = (part: Partial): void => {
+ setForm((previous) => ({ ...previous, ...part }));
+ };
+
+ const onSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ const nextErrors = validateModelForm(form);
+ setErrors(nextErrors);
+ if (hasErrors(nextErrors) || form.sourceType === '') return;
+
+ setSaving(true);
+ setServerError(null);
+ try {
+ const payload: ModelUpdate = {
+ name: form.name.trim(),
+ status: form.status,
+ sourceType: form.sourceType,
+ sourceName: form.sourceName.trim(),
+ sourceUrl: form.sourceUrl.trim(),
+ license: form.license.trim(),
+ author: form.author.trim(),
+ };
+ await updateModel(model.id, payload);
+ onSaved('模型已更新');
+ } catch (cause) {
+ setServerError(errorMessage(cause));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ 页面 */
+
+export function ModelsPage() {
+ const [filters, setFilters] = useState({ q: '', format: '', status: '', page: 1 });
+ const [qInput, setQInput] = useState('');
+ const [previewId, setPreviewId] = useState(null);
+ const [uploadOpen, setUploadOpen] = useState(false);
+ const [editTarget, setEditTarget] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [busyId, setBusyId] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
+
+ const state = useAsync(
+ () =>
+ listModels({
+ page: filters.page,
+ pageSize: PAGE_SIZE,
+ q: filters.q,
+ format: filters.format,
+ status: filters.status,
+ }),
+ [filters.q, filters.format, filters.status, filters.page],
+ );
+
+ const items = state.data?.items ?? [];
+ const previewModel = items.find((model) => model.id === previewId) ?? null;
+
+ const onSaved = (message: string): void => {
+ setUploadOpen(false);
+ setEditTarget(null);
+ setNotice({ kind: 'success', text: message });
+ state.reload();
+ };
+
+ const makePrimary = async (model: Model): Promise => {
+ setBusyId(model.id);
+ try {
+ await setPrimaryModel(model.id);
+ setNotice({ kind: 'success', text: `已将「${model.name}」设为默认模型` });
+ state.reload();
+ } catch (cause) {
+ setNotice({ kind: 'error', text: errorMessage(cause) });
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ const confirmDelete = async (): Promise => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ await deleteModel(deleteTarget.id);
+ if (previewId === deleteTarget.id) setPreviewId(null);
+ setNotice({ kind: 'success', text: `已删除模型「${deleteTarget.name}」(软删除)` });
+ setDeleteTarget(null);
+ state.reload();
+ } catch (cause) {
+ setNotice({ kind: 'error', text: errorMessage(cause) });
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const previewUnsupported =
+ previewModel && previewModel.format === 'fbx'
+ ? 'FBX 无法用 GLTFLoader 预览(契约中 FBX 仅归档)。'
+ : null;
+
+ return (
+
+
+
+ {notice ? (
+
setNotice(null)}>
+ {notice.text}
+
+ ) : null}
+
+
+
+
+
+ {state.error ? : null}
+ {state.loading && !state.data ? : null}
+ {!state.loading && !state.error && items.length === 0 ? (
+
+ ) : null}
+
+ {items.length > 0 ? (
+
+
+
+
+ | 预览缩略图 |
+ 名称 |
+ 格式 |
+ VRM 版本 |
+ 是否默认 |
+ 来源 |
+ 许可 |
+ 状态 |
+ 操作 |
+
+
+
+ {items.map((model) => (
+
+ |
+
+ |
+
+ {model.name}
+
+ #{model.id} · {formatBytes(model.fileSize)} · {formatDateTime(model.updatedAt)}
+
+ |
+
+
+ |
+ {model.vrmVersion ?? '—'} |
+
+ {model.isPrimary ? (
+ 默认 ★
+ ) : (
+ —
+ )}
+ |
+
+
+ |
+
+ {model.license || '—'}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ ) : null}
+
+ {state.data ? (
+ setFilters((previous) => ({ ...previous, page }))}
+ />
+ ) : null}
+
+
+
+ }
+ >
+
+
+
+
+
+
+ 通过 GLTFLoader + VRMLoaderPlugin 加载 fileUrl,VRM 0.x 自动执行 180° 旋转;
+ 使用 requestAnimationFrame 循环调用 vrm.update(delta),
+ 卸载时释放渲染器、几何体、材质与贴图。
+
+ {previewModel ? (
+
+ -
+ 文件地址
+ {previewModel.fileUrl ?? '—'}
+
+ -
+ 格式 / VRM 版本
+
+ {MODEL_FORMAT_LABELS[previewModel.format]} / {previewModel.vrmVersion ?? '—'}
+
+
+ -
+ 是否默认
+ {previewModel.isPrimary ? '是' : '否'}
+
+
+ ) : (
+
尚未选择模型。
+ )}
+
+
+
+
+
+ {uploadOpen ? setUploadOpen(false)} onSaved={onSaved} /> : null}
+ {editTarget ? (
+ setEditTarget(null)} onSaved={onSaved} />
+ ) : null}
+
+ {deleteTarget ? (
+
+
+ 确认删除模型 {deleteTarget.name}(#{deleteTarget.id})?
+
+ DELETE /api/models/{deleteTarget.id} · 服务端执行软删除。
+ >
+ }
+ onConfirm={confirmDelete}
+ onCancel={() => setDeleteTarget(null)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/web-server/admin/src/pages/Motions.tsx b/web-server/admin/src/pages/Motions.tsx
new file mode 100644
index 0000000..1371e08
--- /dev/null
+++ b/web-server/admin/src/pages/Motions.tsx
@@ -0,0 +1,507 @@
+import { useState, type FormEvent } from 'react';
+import { apiUrl, errorMessage } from '../api/client';
+import {
+ deleteMotion,
+ listMotions,
+ listSongs,
+ preprocessMotion,
+ uploadMotion,
+} from '../api/endpoints';
+import type { Motion, PublishStatus, Song } from '../api/types';
+import { MOTION_FORMATS } from '../api/types';
+import { formatNumber } from '../lib/format';
+import { MOTION_FORMAT_LABELS, PREPROCESS_LABELS, STATUS_OPTIONS } from '../lib/labels';
+import { hasErrors, parseOptionalNumber, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
+import { useAsync } from '../lib/useAsync';
+import {
+ ConfirmDialog,
+ Empty,
+ ErrorBox,
+ Field,
+ FormatBadge,
+ Loading,
+ Modal,
+ Notice,
+ Pagination,
+ SourceCell,
+ SourceFieldsForm,
+ StatusBadge,
+ type SourceFormValue,
+} from '../components/ui';
+
+const PAGE_SIZE = 20;
+const ACCEPT_EXT = '.bvh,.fbx,.gltf,.glb,.json';
+
+interface MotionFilters {
+ q: string;
+ format: string;
+ status: PublishStatus | '';
+ page: number;
+}
+
+interface MotionFormState extends SourceFormValue {
+ name: string;
+ songId: string;
+ fps: string;
+ autoPreprocess: boolean;
+ status: PublishStatus;
+}
+
+const EMPTY_MOTION_FORM: MotionFormState = {
+ name: '',
+ songId: '',
+ fps: '',
+ autoPreprocess: true,
+ status: 'draft',
+ sourceType: '',
+ sourceName: '',
+ sourceUrl: '',
+ license: '',
+ author: '',
+};
+
+function validateMotionForm(form: MotionFormState): FieldErrors {
+ const errors: FieldErrors = {};
+ requireText(form.name, 'name', '动作名称', errors, 200);
+ const fps = parseOptionalNumber(form.fps);
+ if (fps !== undefined && (!Number.isFinite(fps) || fps <= 0 || fps > 240)) {
+ errors.fps = '帧率需为 0–240 之间的数字';
+ }
+ validateSourceFields(form, errors);
+ return errors;
+}
+
+function PreprocessBadge({ motion }: { motion: Motion }) {
+ const label = PREPROCESS_LABELS[motion.preprocessState] ?? motion.preprocessState;
+ const title =
+ motion.preprocessState === 'failed'
+ ? motion.preprocessError ?? '预处理失败(无错误详情)'
+ : motion.preprocessState === 'done'
+ ? `关键点帧数:${motion.keypointFrames ?? '—'} · 格式:${motion.keypointFormat ?? '—'}`
+ : `状态:${motion.preprocessState}`;
+ return (
+
+ {label}
+ {motion.preprocessState === 'running' ? : null}
+
+ );
+}
+
+/* ------------------------------------------------------------ 上传弹窗 */
+
+function MotionUploadModal({
+ songs,
+ songsError,
+ onClose,
+ onSaved,
+}: {
+ songs: Song[];
+ songsError: string | null;
+ onClose: () => void;
+ onSaved: (message: string) => void;
+}) {
+ const [form, setForm] = useState(EMPTY_MOTION_FORM);
+ const [file, setFile] = useState(null);
+ const [errors, setErrors] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [serverError, setServerError] = useState(null);
+
+ const patch = (part: Partial): void => {
+ setForm((previous) => ({ ...previous, ...part }));
+ };
+
+ const onSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ const nextErrors = validateMotionForm(form);
+ if (!file) nextErrors.file = '请选择动作文件';
+ setErrors(nextErrors);
+ if (hasErrors(nextErrors) || !file || form.sourceType === '') return;
+
+ setSaving(true);
+ setServerError(null);
+ try {
+ await uploadMotion({
+ file,
+ name: form.name.trim(),
+ songId: form.songId === '' ? undefined : Number(form.songId),
+ fps: parseOptionalNumber(form.fps),
+ autoPreprocess: form.autoPreprocess,
+ status: form.status,
+ sourceType: form.sourceType,
+ sourceName: form.sourceName.trim(),
+ sourceUrl: form.sourceUrl.trim(),
+ license: form.license.trim(),
+ author: form.author.trim(),
+ });
+ onSaved('动作已上传');
+ } catch (cause) {
+ setServerError(errorMessage(cause));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ 页面 */
+
+export function MotionsPage() {
+ const [filters, setFilters] = useState({ q: '', format: '', status: '', page: 1 });
+ const [qInput, setQInput] = useState('');
+ const [uploadOpen, setUploadOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [busyId, setBusyId] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
+
+ const state = useAsync(
+ () =>
+ listMotions({
+ page: filters.page,
+ pageSize: PAGE_SIZE,
+ q: filters.q,
+ format: filters.format,
+ status: filters.status,
+ }),
+ [filters.q, filters.format, filters.status, filters.page],
+ );
+
+ // 关联曲目下拉 & 表格内的曲名映射
+ const songsState = useAsync(() => listSongs({ page: 1, pageSize: 200 }), []);
+ const songs = songsState.data?.items ?? [];
+ const songTitle = (songId: number | null): string => {
+ if (songId === null || songId === undefined) return '—';
+ const song = songs.find((item) => item.id === songId);
+ return song ? `#${song.id} ${song.title}` : `#${songId}`;
+ };
+
+ const items = state.data?.items ?? [];
+
+ const onSaved = (message: string): void => {
+ setUploadOpen(false);
+ setNotice({ kind: 'success', text: message });
+ state.reload();
+ };
+
+ const runPreprocess = async (motion: Motion): Promise => {
+ setBusyId(motion.id);
+ try {
+ await preprocessMotion(motion.id);
+ setNotice({ kind: 'success', text: `已触发预处理:「${motion.name}」` });
+ state.reload();
+ } catch (cause) {
+ setNotice({ kind: 'error', text: errorMessage(cause) });
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ const confirmDelete = async (): Promise => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ await deleteMotion(deleteTarget.id);
+ setNotice({ kind: 'success', text: `已删除动作「${deleteTarget.name}」(软删除)` });
+ setDeleteTarget(null);
+ state.reload();
+ } catch (cause) {
+ setNotice({ kind: 'error', text: errorMessage(cause) });
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+ {notice ? (
+
setNotice(null)}>
+ {notice.text}
+
+ ) : null}
+
+
+
+
+ {state.error ? : null}
+ {state.loading && !state.data ? : null}
+ {!state.loading && !state.error && items.length === 0 ? (
+
+ ) : null}
+
+ {items.length > 0 ? (
+
+
+
+
+ | 名称 |
+ 关联曲目 |
+ 格式 |
+ 帧率 / 帧数 |
+ 预处理状态 |
+ 来源五元组 |
+ 状态 |
+ 操作 |
+
+
+
+ {items.map((motion) => {
+ const keypointsReady = motion.preprocessState === 'done';
+ return (
+
+ |
+ {motion.name}
+ #{motion.id}
+ |
+ {songTitle(motion.songId)} |
+
+
+ |
+
+ {formatNumber(motion.fps)} fps
+ {motion.frameCount ?? '—'} 帧
+ |
+
+
+ {motion.preprocessState === 'failed' && motion.preprocessError ? (
+
+ {motion.preprocessError}
+
+ ) : null}
+ |
+
+
+ |
+
+
+ |
+
+
+ {keypointsReady ? (
+
+ 下载关键点
+
+ ) : (
+
+ 下载关键点
+
+ )}
+
+ |
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ {state.data ? (
+ setFilters((previous) => ({ ...previous, page }))}
+ />
+ ) : null}
+
+
+ {uploadOpen ? (
+
setUploadOpen(false)}
+ onSaved={onSaved}
+ />
+ ) : null}
+
+ {deleteTarget ? (
+
+
+ 确认删除动作 {deleteTarget.name}(#{deleteTarget.id})?
+
+ DELETE /api/motions/{deleteTarget.id} · 服务端执行软删除。
+ >
+ }
+ onConfirm={confirmDelete}
+ onCancel={() => setDeleteTarget(null)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/web-server/admin/src/pages/Reviews.tsx b/web-server/admin/src/pages/Reviews.tsx
new file mode 100644
index 0000000..240115d
--- /dev/null
+++ b/web-server/admin/src/pages/Reviews.tsx
@@ -0,0 +1,359 @@
+import { useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { errorMessage } from '../api/client';
+import { listPending, listReviewRecords, reviewApprove, reviewReject } from '../api/endpoints';
+import type { PendingItem, ReviewActionBody } from '../api/types';
+import { formatDateTime } from '../lib/format';
+import { REVIEW_ACTION_LABELS, STATUS_LABELS, TARGET_TYPE_LABELS } from '../lib/labels';
+import { useAsync } from '../lib/useAsync';
+import {
+ Empty,
+ ErrorBox,
+ Field,
+ Loading,
+ Modal,
+ Notice,
+ Pagination,
+ SourceCell,
+ StatusBadge,
+} from '../components/ui';
+
+const PAGE_SIZE = 20;
+const REVIEWER_STORAGE_KEY = 'opendance-admin.reviewer';
+
+type TabKey = 'pending' | 'history';
+
+interface ActionTarget {
+ item: PendingItem;
+ action: 'approve' | 'reject';
+}
+
+/* ------------------------------------------------------------ 审核弹窗 */
+
+function ReviewActionModal({
+ target,
+ reviewer,
+ onClose,
+ onDone,
+}: {
+ target: ActionTarget;
+ reviewer: string;
+ onClose: () => void;
+ onDone: (message: string) => void;
+}) {
+ const [comment, setComment] = useState('');
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const isReject = target.action === 'reject';
+
+ const submit = async (): Promise => {
+ if (isReject && comment.trim() === '') {
+ setError('驳回必须填写理由,否则服务端返回 400。');
+ return;
+ }
+ setSaving(true);
+ setError(null);
+ try {
+ const body: ReviewActionBody = { reviewer: reviewer.trim(), comment: comment.trim() };
+ if (isReject) {
+ await reviewReject(target.item.targetType, target.item.targetId, body);
+ } else {
+ await reviewApprove(target.item.targetType, target.item.targetId, body);
+ }
+ onDone(
+ `${isReject ? '已驳回' : '已通过'}「${target.item.name}」(${TARGET_TYPE_LABELS[target.item.targetType]} #${target.item.targetId})`,
+ );
+ } catch (cause) {
+ setError(errorMessage(cause));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ {error ?
{error} : null}
+
+
+ 目标
+
+ {TARGET_TYPE_LABELS[target.item.targetType]} #{target.item.targetId} · {target.item.name}
+
+
+
+ 审核人 reviewer
+ {reviewer.trim() === '' ? '(空)' : reviewer}
+
+
+
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ 页面 */
+
+export function ReviewsPage() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const tab: TabKey = searchParams.get('tab') === 'history' ? 'history' : 'pending';
+
+ const [page, setPage] = useState(1);
+ const [reviewer, setReviewer] = useState(() => {
+ try {
+ return window.localStorage.getItem(REVIEWER_STORAGE_KEY) ?? 'admin';
+ } catch {
+ return 'admin';
+ }
+ });
+ const [actionTarget, setActionTarget] = useState(null);
+ const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(REVIEWER_STORAGE_KEY, reviewer);
+ } catch {
+ /* 忽略隐私模式下的写入失败 */
+ }
+ }, [reviewer]);
+
+ const pendingState = useAsync(
+ () => listPending({ status: 'pending', page, pageSize: PAGE_SIZE }),
+ [page],
+ );
+ const historyState = useAsync(() => listReviewRecords({ page, pageSize: PAGE_SIZE }), [page]);
+
+ const switchTab = (next: TabKey): void => {
+ setPage(1);
+ setSearchParams(next === 'pending' ? {} : { tab: next });
+ };
+
+ const reloadAll = (): void => {
+ pendingState.reload();
+ historyState.reload();
+ };
+
+ const pendingItems = pendingState.data?.items ?? [];
+ const historyItems = historyState.data?.items ?? [];
+ const activeState = tab === 'pending' ? pendingState : historyState;
+
+ return (
+
+
+
+ {notice ? (
+
setNotice(null)}>
+ {notice.text}
+
+ ) : null}
+
+
+
+
+
+
+ {tab === 'pending' ? (
+
+ {pendingState.error ? : null}
+ {pendingState.loading && !pendingState.data ? : null}
+ {!pendingState.loading && !pendingState.error && pendingItems.length === 0 ? (
+
+ ) : null}
+
+ {pendingItems.length > 0 ? (
+
+
+
+
+ | 类型 |
+ 名称 |
+ 状态 |
+ 来源五元组 |
+ 更新时间 |
+ 操作 |
+
+
+
+ {pendingItems.map((item) => (
+
+ | {TARGET_TYPE_LABELS[item.targetType] ?? item.targetType} |
+
+ {item.name}
+
+ #{item.targetId} · {item.targetType}
+
+ |
+
+
+ |
+
+ {item.source ? (
+
+ ) : (
+ —
+ )}
+ |
+ {formatDateTime(item.updatedAt)} |
+
+
+
+ |
+
+ ))}
+
+
+
+ ) : null}
+
+ {pendingState.data ? (
+
+ ) : null}
+
+ ) : (
+
+ {historyState.error ? : null}
+ {historyState.loading && !historyState.data ? : null}
+ {!historyState.loading && !historyState.error && historyItems.length === 0 ? (
+
+ ) : null}
+
+ {historyItems.length > 0 ? (
+
+
+
+
+ | 时间 |
+ 目标 |
+ 动作 |
+ 状态流转 |
+ 审核人 |
+ 意见 |
+
+
+
+ {historyItems.map((record) => (
+
+ | {formatDateTime(record.createdAt)} |
+
+ {TARGET_TYPE_LABELS[record.targetType] ?? record.targetType} #{record.targetId}
+ |
+ {REVIEW_ACTION_LABELS[record.action] ?? record.action} |
+
+ {STATUS_LABELS[record.fromStatus] ?? record.fromStatus} →{' '}
+ {STATUS_LABELS[record.toStatus] ?? record.toStatus}
+ |
+ {record.reviewer ?? '—'} |
+ {record.comment ?? '—'} |
+
+ ))}
+
+
+
+ ) : null}
+
+ {historyState.data ? (
+
+ ) : null}
+
+ )}
+
+ {actionTarget ? (
+
setActionTarget(null)}
+ onDone={(message) => {
+ setActionTarget(null);
+ setNotice({ kind: 'success', text: message });
+ reloadAll();
+ }}
+ />
+ ) : null}
+
+ );
+}
diff --git a/web-server/admin/src/pages/Songs.tsx b/web-server/admin/src/pages/Songs.tsx
new file mode 100644
index 0000000..31d6aec
--- /dev/null
+++ b/web-server/admin/src/pages/Songs.tsx
@@ -0,0 +1,605 @@
+import { useState, type FormEvent } from 'react';
+import { errorMessage } from '../api/client';
+import { createSong, deleteSong, listSongs, updateSong, uploadSongAudio } from '../api/endpoints';
+import type { PublishStatus, Song, SongCreate, SourceType } from '../api/types';
+import { SONG_LANGUAGES } from '../api/types';
+import { formatDuration, formatNumber, parseTags, tagsToText } from '../lib/format';
+import { LANGUAGE_LABELS, STATUS_OPTIONS } from '../lib/labels';
+import { hasErrors, parseOptionalNumber, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
+import { useAsync } from '../lib/useAsync';
+import {
+ ConfirmDialog,
+ Empty,
+ ErrorBox,
+ Field,
+ Loading,
+ Modal,
+ Notice,
+ Pagination,
+ SourceCell,
+ SourceFieldsForm,
+ StatusBadge,
+ type SourceFormValue,
+} from '../components/ui';
+
+const PAGE_SIZE = 20;
+
+interface SongFilters {
+ q: string;
+ status: PublishStatus | '';
+ artist: string;
+ page: number;
+}
+
+interface SongFormState extends SourceFormValue {
+ title: string;
+ artist: string;
+ album: string;
+ language: string;
+ durationSec: string;
+ bpm: string;
+ difficulty: string;
+ tags: string;
+ status: PublishStatus;
+}
+
+const EMPTY_SONG_FORM: SongFormState = {
+ title: '',
+ artist: '',
+ album: '',
+ language: '',
+ durationSec: '',
+ bpm: '',
+ difficulty: '',
+ tags: '',
+ status: 'draft',
+ sourceType: '',
+ sourceName: '',
+ sourceUrl: '',
+ license: '',
+ author: '',
+};
+
+function songToForm(song: Song): SongFormState {
+ return {
+ title: song.title,
+ artist: song.artist,
+ album: song.album ?? '',
+ language: song.language ?? '',
+ durationSec:
+ song.durationMs === null || song.durationMs === undefined ? '' : String(song.durationMs / 1000),
+ bpm: song.bpm === null || song.bpm === undefined ? '' : String(song.bpm),
+ difficulty:
+ song.difficulty === null || song.difficulty === undefined ? '' : String(song.difficulty),
+ tags: tagsToText(song.tags),
+ status: song.status,
+ sourceType: song.sourceType,
+ sourceName: song.sourceName,
+ sourceUrl: song.sourceUrl,
+ license: song.license,
+ author: song.author,
+ };
+}
+
+function validateSongForm(form: SongFormState): FieldErrors {
+ const errors: FieldErrors = {};
+ requireText(form.title, 'title', '曲名', errors, 200);
+ requireText(form.artist, 'artist', '演唱者', errors, 200);
+ if (form.album.trim().length > 200) errors.album = '专辑不超过 200 字';
+
+ const seconds = parseOptionalNumber(form.durationSec);
+ if (seconds !== undefined && (!Number.isFinite(seconds) || seconds <= 0)) {
+ errors.durationSec = '时长需为正数(秒)';
+ }
+ const bpm = parseOptionalNumber(form.bpm);
+ if (bpm !== undefined && (!Number.isFinite(bpm) || bpm <= 0 || bpm > 400)) {
+ errors.bpm = 'BPM 需为 0–400 之间的数字';
+ }
+ if (form.difficulty !== '') {
+ const difficulty = Number(form.difficulty);
+ if (!Number.isInteger(difficulty) || difficulty < 1 || difficulty > 5) {
+ errors.difficulty = '难度为 1–5';
+ }
+ }
+ validateSourceFields(form, errors);
+ return errors;
+}
+
+function toSongPayload(form: SongFormState): SongCreate {
+ if (form.sourceType === '') {
+ // 已被 validateSongForm 拦截;此处仅为类型收窄。
+ throw new Error('请选择来源类型 sourceType');
+ }
+ const sourceType: SourceType = form.sourceType;
+ const payload: SongCreate = {
+ title: form.title.trim(),
+ artist: form.artist.trim(),
+ status: form.status,
+ sourceType,
+ sourceName: form.sourceName.trim(),
+ sourceUrl: form.sourceUrl.trim(),
+ license: form.license.trim(),
+ author: form.author.trim(),
+ };
+ if (form.album.trim() !== '') payload.album = form.album.trim();
+ if (form.language !== '') payload.language = form.language;
+
+ const seconds = parseOptionalNumber(form.durationSec);
+ if (seconds !== undefined && Number.isFinite(seconds) && seconds > 0) {
+ payload.durationMs = Math.round(seconds * 1000);
+ }
+ const bpm = parseOptionalNumber(form.bpm);
+ if (bpm !== undefined && Number.isFinite(bpm)) payload.bpm = bpm;
+ if (form.difficulty !== '') payload.difficulty = Number(form.difficulty);
+
+ const tags = parseTags(form.tags);
+ if (tags.length > 0) payload.tags = tags;
+ return payload;
+}
+
+/* --------------------------------------------------------------- 表单弹窗 */
+
+function SongFormModal({
+ song,
+ onClose,
+ onSaved,
+}: {
+ song: Song | null;
+ onClose: () => void;
+ onSaved: (message: string) => void;
+}) {
+ const [form, setForm] = useState(song ? songToForm(song) : EMPTY_SONG_FORM);
+ const [errors, setErrors] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [serverError, setServerError] = useState(null);
+
+ const patch = (part: Partial): void => {
+ setForm((previous) => ({ ...previous, ...part }));
+ };
+
+ const onSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ const nextErrors = validateSongForm(form);
+ setErrors(nextErrors);
+ if (hasErrors(nextErrors)) return;
+
+ setSaving(true);
+ setServerError(null);
+ try {
+ const payload = toSongPayload(form);
+ if (song) {
+ await updateSong(song.id, payload);
+ } else {
+ await createSong(payload);
+ }
+ onSaved(song ? '曲目已更新' : '曲目已创建');
+ } catch (cause) {
+ setServerError(errorMessage(cause));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+/* --------------------------------------------------------------- 音频弹窗 */
+
+function AudioUploadModal({
+ song,
+ onClose,
+ onSaved,
+}: {
+ song: Song;
+ onClose: () => void;
+ onSaved: (message: string) => void;
+}) {
+ const [file, setFile] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const submit = async (): Promise => {
+ if (!file) {
+ setError('请选择音频文件');
+ return;
+ }
+ setSaving(true);
+ setError(null);
+ try {
+ await uploadSongAudio(song.id, file);
+ onSaved('音频已上传');
+ } catch (cause) {
+ setError(errorMessage(cause));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ {error ?
{error} : null}
+
+ setFile(event.target.files?.[0] ?? null)} />
+
+ {song.audioUrl ? (
+
+
当前音频
+
+
{song.audioUrl}
+
+ ) : null}
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ 页面 */
+
+export function SongsPage() {
+ const [filters, setFilters] = useState({ q: '', status: '', artist: '', page: 1 });
+ const [qInput, setQInput] = useState('');
+ const [statusInput, setStatusInput] = useState('');
+ const [artistInput, setArtistInput] = useState('');
+
+ const [editingSong, setEditingSong] = useState(undefined);
+ const [audioTarget, setAudioTarget] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
+
+ const state = useAsync(
+ () => listSongs({ page: filters.page, pageSize: PAGE_SIZE, q: filters.q, status: filters.status, artist: filters.artist }),
+ [filters.q, filters.status, filters.artist, filters.page],
+ );
+
+ const applyFilters = (next?: { status?: PublishStatus | '' }): void => {
+ setFilters({
+ q: qInput.trim(),
+ status: next?.status ?? statusInput,
+ artist: artistInput.trim(),
+ page: 1,
+ });
+ };
+
+ const resetFilters = (): void => {
+ setQInput('');
+ setStatusInput('');
+ setArtistInput('');
+ setFilters({ q: '', status: '', artist: '', page: 1 });
+ };
+
+ const onSaved = (message: string): void => {
+ setEditingSong(undefined);
+ setAudioTarget(null);
+ setNotice({ kind: 'success', text: message });
+ state.reload();
+ };
+
+ const confirmDelete = async (): Promise => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ await deleteSong(deleteTarget.id);
+ setDeleteTarget(null);
+ setNotice({ kind: 'success', text: `已删除曲目「${deleteTarget.title}」(软删除)` });
+ state.reload();
+ } catch (cause) {
+ setNotice({ kind: 'error', text: errorMessage(cause) });
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const items = state.data?.items ?? [];
+
+ return (
+
+
+
+ {notice ? (
+
setNotice(null)}>
+ {notice.text}
+
+ ) : null}
+
+
+
+
+ {state.error ? : null}
+ {state.loading && !state.data ? : null}
+ {!state.loading && !state.error && items.length === 0 ? (
+
+ ) : null}
+
+ {items.length > 0 ? (
+
+
+
+
+ | ID |
+ 曲名 / 演唱者 |
+ 专辑 |
+ 语言 |
+ 时长 |
+ BPM |
+ 难度 |
+ 标签 |
+ 来源五元组 |
+ 状态 |
+ 音频 |
+ 操作 |
+
+
+
+ {items.map((song) => (
+
+ | {song.id} |
+
+ {song.title}
+ {song.artist}
+ |
+ {song.album || '—'} |
+ {LANGUAGE_LABELS[song.language ?? ''] ?? song.language ?? '—'} |
+ {formatDuration(song.durationMs)} |
+ {formatNumber(song.bpm)} |
+ {song.difficulty ?? '—'} |
+ {tagsToText(song.tags) || '—'} |
+
+
+ |
+
+
+ |
+
+ {song.audioUrl ? (
+ <>
+
+
+ 音频地址
+
+ >
+ ) : (
+ 未上传
+ )}
+
+ |
+
+
+
+
+ |
+
+ ))}
+
+
+
+ ) : null}
+
+ {state.data ? (
+ setFilters((previous) => ({ ...previous, page }))}
+ />
+ ) : null}
+
+
+ {editingSong !== undefined ? (
+
setEditingSong(undefined)} onSaved={onSaved} />
+ ) : null}
+
+ {audioTarget ? (
+ setAudioTarget(null)} onSaved={onSaved} />
+ ) : null}
+
+ {deleteTarget ? (
+
+
+ 确认删除曲目 {deleteTarget.title}(#{deleteTarget.id})?
+
+ DELETE /api/songs/{deleteTarget.id} · 服务端执行软删除。
+ >
+ }
+ onConfirm={confirmDelete}
+ onCancel={() => setDeleteTarget(null)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/web-server/admin/src/styles.css b/web-server/admin/src/styles.css
new file mode 100644
index 0000000..9b23483
--- /dev/null
+++ b/web-server/admin/src/styles.css
@@ -0,0 +1,1177 @@
+/* ==========================================================================
+ OpenDance Studio 管理端 —— 单一样式表(不引入任何 UI 框架)
+ ========================================================================== */
+
+:root {
+ --bg: #0e1116;
+ --bg-elev: #161b22;
+ --bg-elev-2: #1c232c;
+ --border: #262e39;
+ --border-strong: #38424f;
+ --text: #e6edf3;
+ --text-dim: #9aa7b4;
+ --text-mute: #6e7d8c;
+ --accent: #4c9aff;
+ --accent-strong: #2f7ce0;
+ --ok: #3fb950;
+ --warn: #d29922;
+ --danger: #f85149;
+ --purple: #a371f7;
+ --radius: 8px;
+ --radius-sm: 5px;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
+ 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+code {
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
+h1,
+h2,
+h3 {
+ margin: 0;
+ font-weight: 600;
+}
+
+h1 {
+ font-size: 20px;
+}
+
+h2 {
+ font-size: 15px;
+}
+
+h3 {
+ font-size: 14px;
+}
+
+/* ------------------------------------------------------------ 布局骨架 */
+
+.app-shell {
+ display: flex;
+ min-height: 100%;
+}
+
+.sidebar {
+ width: 224px;
+ flex: 0 0 224px;
+ background: var(--bg-elev);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 16px 12px;
+ gap: 16px;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 6px;
+}
+
+.brand-logo {
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-sm);
+ background: linear-gradient(135deg, var(--accent), var(--purple));
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ font-size: 13px;
+ color: #fff;
+}
+
+.brand-text {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.3;
+}
+
+.brand-text span {
+ color: var(--text-mute);
+ font-size: 12px;
+}
+
+.nav {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ color: var(--text-dim);
+ text-decoration: none;
+}
+
+.nav-item:hover {
+ background: var(--bg-elev-2);
+ color: var(--text);
+ text-decoration: none;
+}
+
+.nav-item em {
+ font-style: normal;
+ font-size: 11px;
+ color: var(--text-mute);
+}
+
+.nav-item-active {
+ background: rgba(76, 154, 255, 0.14);
+ color: #cfe3ff;
+ box-shadow: inset 2px 0 0 var(--accent);
+}
+
+.sidebar-foot {
+ margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 11px;
+ color: var(--text-mute);
+}
+
+.sidebar-foot code {
+ color: var(--text-dim);
+ word-break: break-all;
+}
+
+.content {
+ flex: 1;
+ min-width: 0;
+ padding: 20px 24px 48px;
+}
+
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 1680px;
+}
+
+.page-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.page-head p {
+ margin: 2px 0 0;
+}
+
+.page-head-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ------------------------------------------------------------ 文本工具 */
+
+.muted {
+ color: var(--text-dim);
+}
+
+.small {
+ font-size: 12px;
+}
+
+.mono {
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
+.break {
+ word-break: break-all;
+}
+
+/* -------------------------------------------------------------- 卡片 */
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 12px;
+}
+
+.stat-card {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.stat-card header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.stat-total {
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1.1;
+}
+
+.stat-total .muted {
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.stat-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.stat-list li {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+}
+
+.stat-list li strong {
+ margin-left: auto;
+ font-family: var(--mono);
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-mute);
+}
+
+.dot-draft {
+ background: var(--text-mute);
+}
+
+.dot-pending {
+ background: var(--warn);
+}
+
+.dot-published {
+ background: var(--ok);
+}
+
+.dot-rejected {
+ background: var(--danger);
+}
+
+/* -------------------------------------------------------------- 面板 */
+
+.panel {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.panel-body {
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.kv-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px 18px;
+}
+
+.kv {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 13px;
+}
+
+.kv code {
+ color: var(--text);
+}
+
+.kv-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.kv-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+ border-bottom: 1px dashed var(--border);
+ padding-bottom: 4px;
+}
+
+.kv-list li:last-child {
+ border-bottom: none;
+}
+
+/* -------------------------------------------------------- 提示 / 横幅 */
+
+.banner {
+ border-radius: var(--radius);
+ padding: 12px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ border: 1px solid;
+}
+
+.banner-warn {
+ background: rgba(210, 153, 34, 0.12);
+ border-color: rgba(210, 153, 34, 0.5);
+ color: #f0d08a;
+}
+
+.notice {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 12px;
+ border-radius: var(--radius-sm);
+ border: 1px solid;
+ font-size: 13px;
+}
+
+.notice span {
+ flex: 1;
+}
+
+.notice-success {
+ background: rgba(63, 185, 80, 0.12);
+ border-color: rgba(63, 185, 80, 0.45);
+ color: #8fe0a0;
+}
+
+.notice-error {
+ background: rgba(248, 81, 73, 0.12);
+ border-color: rgba(248, 81, 73, 0.45);
+ color: #ffb3ae;
+}
+
+.notice-info {
+ background: rgba(76, 154, 255, 0.12);
+ border-color: rgba(76, 154, 255, 0.4);
+ color: #b9d6ff;
+}
+
+/* -------------------------------------------------------------- 筛选栏 */
+
+.filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.filter-bar input[type='search'],
+.filter-bar input[type='text'] {
+ min-width: 220px;
+ flex: 1 1 260px;
+ width: auto;
+}
+
+.filter-bar select {
+ flex: 0 1 210px;
+ min-width: 150px;
+ width: auto;
+}
+
+.filter-bar .btn {
+ flex: 0 0 auto;
+}
+
+/* -------------------------------------------------------------- 表单 */
+
+input,
+select,
+textarea {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ padding: 6px 9px;
+ font: inherit;
+ font-size: 13px;
+ width: 100%;
+ min-width: 0;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(76, 154, 255, 0.16);
+}
+
+input[type='checkbox'] {
+ width: auto;
+ min-width: 0;
+ height: 16px;
+ accent-color: var(--accent);
+}
+
+input[type='file'] {
+ padding: 5px;
+}
+
+textarea {
+ resize: vertical;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 12px 14px;
+}
+
+.fieldset {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px 14px;
+ margin: 0;
+}
+
+.fieldset legend {
+ padding: 0 6px;
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.field-label {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.req {
+ color: var(--danger);
+ font-style: normal;
+ margin-left: 3px;
+}
+
+.field-hint {
+ font-size: 11px;
+ color: var(--text-mute);
+}
+
+.field-error {
+ font-size: 11px;
+ color: #ff9d96;
+}
+
+.field-invalid input,
+.field-invalid select,
+.field-invalid textarea {
+ border-color: var(--danger);
+}
+
+.inline-field {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.inline-field input {
+ width: 140px;
+}
+
+/* -------------------------------------------------------------- 按钮 */
+
+.btn {
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ padding: 6px 12px;
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+ background: var(--bg-elev-2);
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-strong);
+ border-color: var(--accent-strong);
+ color: #fff;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent);
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: var(--border-strong);
+ color: var(--text-dim);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ color: var(--text);
+ border-color: var(--accent);
+}
+
+.btn-danger {
+ background: var(--danger);
+ border-color: var(--danger);
+ color: #fff;
+}
+
+.btn-sm {
+ padding: 3px 9px;
+ font-size: 12px;
+}
+
+.icon-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+}
+
+.icon-btn:hover {
+ color: var(--text);
+ background: var(--bg-elev-2);
+}
+
+.link-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--accent);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ text-align: left;
+}
+
+.link-btn:hover:not(:disabled) {
+ text-decoration: underline;
+}
+
+.link-btn:disabled {
+ color: var(--text-mute);
+ cursor: not-allowed;
+}
+
+.link-danger {
+ color: #ff9d96;
+}
+
+.link-disabled {
+ color: var(--text-mute);
+ cursor: not-allowed;
+}
+
+.ext-link {
+ word-break: break-all;
+}
+
+/* -------------------------------------------------------------- 表格 */
+
+.table-wrap {
+ overflow-x: auto;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.table th,
+.table td {
+ padding: 9px 12px;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: top;
+}
+
+.table th {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-dim);
+ background: var(--bg-elev-2);
+ white-space: nowrap;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.table tbody tr:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.row-active {
+ background: rgba(76, 154, 255, 0.08);
+}
+
+.cell-title {
+ font-weight: 600;
+ word-break: break-word;
+}
+
+.col-source {
+ min-width: 240px;
+ max-width: 320px;
+}
+
+.col-name {
+ min-width: 170px;
+}
+
+.col-actions {
+ min-width: 150px;
+ white-space: nowrap;
+}
+
+.col-actions .link-btn,
+.col-actions .link-disabled {
+ display: block;
+ margin-bottom: 3px;
+}
+
+.col-audio {
+ min-width: 190px;
+}
+
+.mini-audio {
+ width: 170px;
+ height: 30px;
+ display: block;
+ margin-bottom: 4px;
+}
+
+/* -------------------------------------------------------------- 徽标 */
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ line-height: 18px;
+ border: 1px solid var(--border-strong);
+ color: var(--text-dim);
+ white-space: nowrap;
+}
+
+.badge-draft {
+ border-color: var(--border-strong);
+ color: var(--text-dim);
+}
+
+.badge-pending {
+ border-color: rgba(210, 153, 34, 0.6);
+ color: #e8c26a;
+ background: rgba(210, 153, 34, 0.1);
+}
+
+.badge-published {
+ border-color: rgba(63, 185, 80, 0.6);
+ color: #7ddc8d;
+ background: rgba(63, 185, 80, 0.1);
+}
+
+.badge-rejected {
+ border-color: rgba(248, 81, 73, 0.6);
+ color: #ff9d96;
+ background: rgba(248, 81, 73, 0.1);
+}
+
+.badge-vrm {
+ border-color: rgba(163, 113, 247, 0.7);
+ color: #cbb2ff;
+ background: rgba(163, 113, 247, 0.14);
+ font-weight: 600;
+}
+
+.badge-primary {
+ border-color: rgba(76, 154, 255, 0.7);
+ color: #a8ccff;
+ background: rgba(76, 154, 255, 0.14);
+}
+
+.badge-pre-none {
+ color: var(--text-dim);
+}
+
+.badge-pre-running {
+ border-color: rgba(76, 154, 255, 0.6);
+ color: #a8ccff;
+}
+
+.badge-pre-done {
+ border-color: rgba(63, 185, 80, 0.6);
+ color: #7ddc8d;
+}
+
+.badge-pre-failed {
+ border-color: rgba(248, 81, 73, 0.6);
+ color: #ff9d96;
+}
+
+.chip {
+ display: inline-block;
+ padding: 0 6px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+/* --------------------------------------------------------- 来源五元组 */
+
+.source-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 12px;
+}
+
+.source-line {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.source-name {
+ font-weight: 600;
+ color: var(--text);
+ word-break: break-word;
+}
+
+.source-sub {
+ color: var(--text-mute);
+ gap: 10px;
+}
+
+.source-detail {
+ margin: 6px 0 0;
+ padding: 8px 10px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.source-detail > div {
+ display: flex;
+ gap: 8px;
+}
+
+.source-detail dt {
+ flex: 0 0 84px;
+ color: var(--text-mute);
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.source-detail dd {
+ margin: 0;
+ word-break: break-all;
+}
+
+/* -------------------------------------------------------------- 缩略图 */
+
+.thumb {
+ width: 74px;
+ height: 74px;
+ padding: 0;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ cursor: pointer;
+ overflow: hidden;
+ display: block;
+}
+
+.thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.thumb-placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.thumb-placeholder em {
+ font-style: normal;
+ font-size: 10px;
+ color: var(--text-mute);
+}
+
+/* -------------------------------------------------------------- 分页 */
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 16px;
+ border-top: 1px solid var(--border);
+ flex-wrap: wrap;
+}
+
+.pagination-info {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.pagination-actions {
+ display: flex;
+ gap: 6px;
+}
+
+/* -------------------------------------------------------------- 弹层 */
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(4, 7, 11, 0.68);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 40px 16px;
+ overflow-y: auto;
+ z-index: 50;
+}
+
+.modal {
+ background: var(--bg-elev);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ width: 100%;
+ max-width: 560px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-lg {
+ max-width: 760px;
+}
+
+.modal-xl {
+ max-width: 940px;
+}
+
+.modal-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-body {
+ padding: 16px;
+ max-height: 68vh;
+ overflow-y: auto;
+}
+
+.modal-foot {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+}
+
+.confirm-body p {
+ margin: 0 0 6px;
+}
+
+/* --------------------------------------------------------- 状态占位块 */
+
+.state-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 36px 16px;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.state-error {
+ justify-content: space-between;
+ background: rgba(248, 81, 73, 0.08);
+ border-bottom: 1px solid rgba(248, 81, 73, 0.35);
+ color: #ffb3ae;
+}
+
+.state-error-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.state-empty {
+ color: var(--text-mute);
+}
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--border-strong);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ display: inline-block;
+ animation: spin 0.7s linear infinite;
+}
+
+.spinner-xs {
+ width: 9px;
+ height: 9px;
+ border-width: 1.5px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ------------------------------------------------------- 模型预览布局 */
+
+.split-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 380px;
+ gap: 14px;
+ align-items: start;
+}
+
+@media (max-width: 1280px) {
+ .split-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.preview-column {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ position: sticky;
+ top: 16px;
+}
+
+.vrm-panel {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.vrm-panel-head,
+.vrm-panel-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 14px;
+}
+
+.vrm-panel-head {
+ border-bottom: 1px solid var(--border);
+}
+
+.vrm-panel-head strong {
+ margin-right: 8px;
+}
+
+.vrm-panel-foot {
+ border-top: 1px solid var(--border);
+ font-size: 12px;
+}
+
+.vrm-stage {
+ position: relative;
+ background: #14181f;
+}
+
+.vrm-canvas {
+ position: absolute;
+ inset: 0;
+}
+
+.vrm-canvas canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+}
+
+.vrm-canvas canvas:active {
+ cursor: grabbing;
+}
+
+.vrm-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ text-align: center;
+ padding: 16px;
+ background: rgba(14, 17, 22, 0.72);
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.vrm-overlay p {
+ margin: 0;
+}
+
+.vrm-overlay-error {
+ color: #ffb3ae;
+}
+
+/* -------------------------------------------------------------- 标签页 */
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--border);
+}
+
+.tab {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-dim);
+ font: inherit;
+ font-size: 13px;
+ padding: 8px 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.tab:hover {
+ color: var(--text);
+}
+
+.tab-active {
+ color: var(--text);
+ border-bottom-color: var(--accent);
+}
+
+.tab-count {
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0 7px;
+ font-size: 11px;
+ color: var(--text-dim);
+}
diff --git a/web-server/admin/src/vite-env.d.ts b/web-server/admin/src/vite-env.d.ts
new file mode 100644
index 0000000..7a7f1cd
--- /dev/null
+++ b/web-server/admin/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/web-server/admin/tsconfig.json b/web-server/admin/tsconfig.json
new file mode 100644
index 0000000..0efca81
--- /dev/null
+++ b/web-server/admin/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "useDefineForClassFields": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+
+ "strict": true,
+ "noImplicitAny": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "exactOptionalPropertyTypes": false,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/web-server/admin/tsconfig.node.json b/web-server/admin/tsconfig.node.json
new file mode 100644
index 0000000..b3810e0
--- /dev/null
+++ b/web-server/admin/tsconfig.node.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "moduleDetection": "force",
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "outDir": "./node_modules/.tmp/tsconfig.node",
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web-server/admin/vite.config.ts b/web-server/admin/vite.config.ts
new file mode 100644
index 0000000..fec90c7
--- /dev/null
+++ b/web-server/admin/vite.config.ts
@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+const API_TARGET = 'http://localhost:3000';
+
+// base './' 让产物可挂在任意子路径;开发期 /api 与 /health 代理到 NestJS 服务。
+export default defineConfig({
+ base: './',
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: true,
+ proxy: {
+ '/api': {
+ target: API_TARGET,
+ changeOrigin: true,
+ },
+ '/health': {
+ target: API_TARGET,
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: false,
+ // VRM 预览分包内是 three.js 本体(约 750 kB),属于预期体积。
+ chunkSizeWarningLimit: 900,
+ },
+});