From 640736c77bdf3ab00043eae419599a5c85dfba29 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 19:00:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20T-WEB-008/009=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E7=95=8C=E9=9D=A2=EF=BC=88=E6=AD=8C=E6=9B=B2/?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B/=E5=8A=A8=E4=BD=9C/=E5=AE=A1=E6=A0=B8=20+=20?= =?UTF-8?q?VRM=20=E9=A2=84=E8=A7=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-server/admin/.dockerignore | 11 + web-server/admin/.gitignore | 7 + web-server/admin/Dockerfile | 17 + web-server/admin/README.md | 127 ++ web-server/admin/index.html | 13 + web-server/admin/nginx.conf | 39 + web-server/admin/package-lock.json | 1229 +++++++++++++++++ web-server/admin/package.json | 29 + web-server/admin/src/App.tsx | 22 + web-server/admin/src/api/client.ts | 138 ++ web-server/admin/src/api/endpoints.ts | 230 +++ web-server/admin/src/api/types.ts | 233 ++++ web-server/admin/src/components/Layout.tsx | 57 + .../admin/src/components/VrmPreview.tsx | 310 +++++ web-server/admin/src/components/ui.tsx | 385 ++++++ web-server/admin/src/lib/format.ts | 60 + web-server/admin/src/lib/labels.ts | 100 ++ web-server/admin/src/lib/useAsync.ts | 70 + web-server/admin/src/lib/validation.ts | 57 + web-server/admin/src/main.tsx | 18 + web-server/admin/src/pages/Dashboard.tsx | 209 +++ web-server/admin/src/pages/Models.tsx | 609 ++++++++ web-server/admin/src/pages/Motions.tsx | 507 +++++++ web-server/admin/src/pages/Reviews.tsx | 359 +++++ web-server/admin/src/pages/Songs.tsx | 605 ++++++++ web-server/admin/src/styles.css | 1177 ++++++++++++++++ web-server/admin/src/vite-env.d.ts | 9 + web-server/admin/tsconfig.json | 29 + web-server/admin/tsconfig.node.json | 18 + web-server/admin/vite.config.ts | 30 + 30 files changed, 6704 insertions(+) create mode 100644 web-server/admin/.dockerignore create mode 100644 web-server/admin/.gitignore create mode 100644 web-server/admin/Dockerfile create mode 100644 web-server/admin/README.md create mode 100644 web-server/admin/index.html create mode 100644 web-server/admin/nginx.conf create mode 100644 web-server/admin/package-lock.json create mode 100644 web-server/admin/package.json create mode 100644 web-server/admin/src/App.tsx create mode 100644 web-server/admin/src/api/client.ts create mode 100644 web-server/admin/src/api/endpoints.ts create mode 100644 web-server/admin/src/api/types.ts create mode 100644 web-server/admin/src/components/Layout.tsx create mode 100644 web-server/admin/src/components/VrmPreview.tsx create mode 100644 web-server/admin/src/components/ui.tsx create mode 100644 web-server/admin/src/lib/format.ts create mode 100644 web-server/admin/src/lib/labels.ts create mode 100644 web-server/admin/src/lib/useAsync.ts create mode 100644 web-server/admin/src/lib/validation.ts create mode 100644 web-server/admin/src/main.tsx create mode 100644 web-server/admin/src/pages/Dashboard.tsx create mode 100644 web-server/admin/src/pages/Models.tsx create mode 100644 web-server/admin/src/pages/Motions.tsx create mode 100644 web-server/admin/src/pages/Reviews.tsx create mode 100644 web-server/admin/src/pages/Songs.tsx create mode 100644 web-server/admin/src/styles.css create mode 100644 web-server/admin/src/vite-env.d.ts create mode 100644 web-server/admin/tsconfig.json create mode 100644 web-server/admin/tsconfig.node.json create mode 100644 web-server/admin/vite.config.ts 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' ? ( +
+
+ ) : 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 ( +
+
+ ); +} + +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()} + > +
+

{title}

+ +
+
{children}
+ {footer ?
{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 ( +
+ 来源五元组(全部必填) +
+ + + + + onChange({ sourceName: event.target.value })} + /> + + + onChange({ sourceUrl: event.target.value })} + /> + + + onChange({ license: event.target.value })} + /> + + + onChange({ author: event.target.value })} + /> + +
+
+ ); +} 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 ( +
+
+
+

概览

+

OpenDance Studio 管理端 · 契约 rest-api 0.1.0

+
+ +
+ + {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) => ( +
  • +
  • + ))} +
+ {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 || '—'} +
+ + ) : ( + + )} +
+
+ +
+
+

服务健康

+ GET /health +
+
+ {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 ( + + + + + } + > +
+ {serverError ? {serverError} : null} + +
+ + setFile(event.target.files?.[0] ?? null)} + /> + + + patch({ name: event.target.value })} + /> + + + + + + patch({ isPrimary: event.target.checked })} + /> + +
+ + + +
+ ); +} + +/* ------------------------------------------------------------ 编辑弹窗 */ + +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 ( + + + + + } + > +
+ {serverError ? {serverError} : null} +
+ + patch({ name: event.target.value })} + /> + + + + +
+ + +
+ ); +} + +/* ------------------------------------------------------------------ 页面 */ + +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 ( +
+
+
+

模型管理

+

T-WEB-009 · VRM 优先,来源五元组必填,支持 3D 预览与全局默认模型

+
+
+ + +
+
+ + {notice ? ( + setNotice(null)}> + {notice.text} + + ) : null} + +
{ + event.preventDefault(); + setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 })); + }} + > + setQInput(event.target.value)} + /> + + + + +
+ +
+
+ {state.error ? : null} + {state.loading && !state.data ? : null} + {!state.loading && !state.error && items.length === 0 ? ( + + ) : null} + + {items.length > 0 ? ( +
+ + + + + + + + + + + + + + + + {items.map((model) => ( + + + + + + + + + + + + ))} + +
预览缩略图名称格式VRM 版本是否默认来源许可状态操作
+ + +
{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' ? + ); +} + +/* ------------------------------------------------------------ 上传弹窗 */ + +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 ( + + + + + } + > +
+ {serverError ? {serverError} : null} + {songsError ? ( + 曲目列表加载失败,关联曲目暂不可选:{songsError} + ) : null} + +
+ + setFile(event.target.files?.[0] ?? null)} + /> + + + patch({ name: event.target.value })} + /> + + + + + + patch({ fps: event.target.value })} + /> + + + + + + patch({ autoPreprocess: event.target.checked })} + /> + +
+ + + +
+ ); +} + +/* ------------------------------------------------------------------ 页面 */ + +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 ( +
+
+
+

动作管理

+

T-WEB-004/006 · 动作文件、MediaPipe33 预处理与关键点下载

+
+
+ + +
+
+ + {notice ? ( + setNotice(null)}> + {notice.text} + + ) : null} + +
{ + event.preventDefault(); + setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 })); + }} + > + setQInput(event.target.value)} + /> + + + + +
+ +
+ {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} +
  • +
    + +