feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
npm-debug.log*
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.vscode
|
||||
.idea
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
tsconfig.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
@@ -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;"]
|
||||
@@ -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` 单独分包,仅进入模型页时加载。
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>OpenDance Studio 管理端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="songs" element={<SongsPage />} />
|
||||
<Route path="models" element={<ModelsPage />} />
|
||||
<Route path="motions" element={<MotionsPage />} />
|
||||
<Route path="reviews" element={<ReviewsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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<string, QueryValue>;
|
||||
|
||||
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<unknown> {
|
||||
const text = await response.text();
|
||||
if (text === '') return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit, params?: Query): Promise<T> {
|
||||
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<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export function apiGet<T>(path: string, params?: Query): Promise<T> {
|
||||
return request<T>(path, { method: 'GET', headers: { Accept: 'application/json' } }, params);
|
||||
}
|
||||
|
||||
export function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'PATCH',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function apiDelete(path: string): Promise<void> {
|
||||
return request<void>(path, { method: 'DELETE', headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
/** multipart 上传:绝不手动设置 Content-Type。 */
|
||||
export function apiUpload<T>(path: string, form: FormData): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
@@ -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<SongPage> {
|
||||
return apiGet<SongPage>('/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<MotionPage> {
|
||||
return apiGet<MotionPage>('/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<ModelPage> {
|
||||
return apiGet<ModelPage>('/api/models', {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
format: params.format,
|
||||
status: params.status,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 曲目 */
|
||||
|
||||
export function createSong(payload: SongCreate): Promise<Song> {
|
||||
return apiPost<Song>('/api/songs', payload);
|
||||
}
|
||||
|
||||
export function updateSong(id: number, payload: SongUpdate): Promise<Song> {
|
||||
return apiPatch<Song>(`/api/songs/${id}`, payload);
|
||||
}
|
||||
|
||||
export function deleteSong(id: number): Promise<void> {
|
||||
return apiDelete(`/api/songs/${id}`);
|
||||
}
|
||||
|
||||
/** multipart 字段名固定为 file。 */
|
||||
export function uploadSongAudio(id: number, file: File): Promise<Song> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return apiUpload<Song>(`/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<Motion> {
|
||||
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<Motion>('/api/motions', form);
|
||||
}
|
||||
|
||||
export function updateMotion(id: number, payload: MotionUpdate): Promise<Motion> {
|
||||
return apiPatch<Motion>(`/api/motions/${id}`, payload);
|
||||
}
|
||||
|
||||
export function deleteMotion(id: number): Promise<void> {
|
||||
return apiDelete(`/api/motions/${id}`);
|
||||
}
|
||||
|
||||
export function preprocessMotion(id: number): Promise<Motion> {
|
||||
return apiPost<Motion>(`/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<Model> {
|
||||
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<Model>('/api/models', form);
|
||||
}
|
||||
|
||||
export function updateModel(id: number, payload: ModelUpdate): Promise<Model> {
|
||||
return apiPatch<Model>(`/api/models/${id}`, payload);
|
||||
}
|
||||
|
||||
export function deleteModel(id: number): Promise<void> {
|
||||
return apiDelete(`/api/models/${id}`);
|
||||
}
|
||||
|
||||
export function setPrimaryModel(id: number): Promise<Model> {
|
||||
return apiPost<Model>(`/api/models/${id}/primary`, {});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 审核 */
|
||||
|
||||
export function listPending(params: ListParams & { status: PublishStatus }): Promise<PendingPage> {
|
||||
return apiGet<PendingPage>('/api/reviews', {
|
||||
status: params.status,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export function listReviewRecords(
|
||||
params: ListParams & { targetType?: TargetType | '' },
|
||||
): Promise<ReviewPage> {
|
||||
return apiGet<ReviewPage>('/api/reviews', {
|
||||
targetType: params.targetType,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewApprove(
|
||||
targetType: TargetType,
|
||||
id: number,
|
||||
body: ReviewActionBody,
|
||||
): Promise<void> {
|
||||
return apiPost<void>(`/api/reviews/${targetType}/${id}/approve`, body);
|
||||
}
|
||||
|
||||
export function reviewReject(
|
||||
targetType: TargetType,
|
||||
id: number,
|
||||
body: ReviewActionBody,
|
||||
): Promise<void> {
|
||||
return apiPost<void>(`/api/reviews/${targetType}/${id}/reject`, body);
|
||||
}
|
||||
|
||||
export function reviewSubmit(
|
||||
targetType: TargetType,
|
||||
id: number,
|
||||
body: ReviewActionBody,
|
||||
): Promise<void> {
|
||||
return apiPost<void>(`/api/reviews/${targetType}/${id}/submit`, body);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 目录/健康 */
|
||||
|
||||
export function getHealth(): Promise<Health> {
|
||||
return apiGet<Health>('/health');
|
||||
}
|
||||
|
||||
export function getCatalogVersion(): Promise<ContractVersion> {
|
||||
return apiGet<ContractVersion>('/api/catalog/version');
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<Song>;
|
||||
export type MotionPage = Page<Motion>;
|
||||
export type ModelPage = Page<Model>;
|
||||
|
||||
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<SongCreate>;
|
||||
|
||||
/** 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=<PublishStatus> 返回的待审资产条目。 */
|
||||
export interface PendingItem {
|
||||
targetType: TargetType;
|
||||
targetId: number;
|
||||
name: string;
|
||||
status: PublishStatus;
|
||||
source: SourceFields;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ReviewPage = Page<ReviewRecord>;
|
||||
export type PendingPage = Page<PendingItem>;
|
||||
|
||||
export interface ReviewActionBody {
|
||||
reviewer?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 目录/健康 */
|
||||
|
||||
export interface ContractVersion {
|
||||
contractVersion: Record<string, string>;
|
||||
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<string, string>;
|
||||
database: HealthDatabase;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 错误响应 */
|
||||
|
||||
export interface ApiErrorBody {
|
||||
statusCode?: number;
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-logo" aria-hidden="true">
|
||||
OD
|
||||
</span>
|
||||
<div className="brand-text">
|
||||
<strong>OpenDance Studio</strong>
|
||||
<span>管理端 Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end ?? false}
|
||||
className={({ isActive }) => `nav-item${isActive ? ' nav-item-active' : ''}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.hint ? <em>{item.hint}</em> : null}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<span>API 基址</span>
|
||||
<code>{API_BASE === '' ? '同源(dev proxy / nginx)' : API_BASE}</code>
|
||||
<span className="muted">契约 rest-api 0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>): 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<HTMLDivElement | null>(null);
|
||||
const [state, setState] = useState<PreviewState>({ 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<string, unknown> = 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 (
|
||||
<div className="vrm-panel">
|
||||
<div className="vrm-panel-head">
|
||||
<div>
|
||||
<strong>3D 预览</strong>
|
||||
<span className="muted">{name || '未选择模型'}</span>
|
||||
</div>
|
||||
{hasModel ? (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReloadKey((v) => v + 1)}>
|
||||
重新加载
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vrm-stage" style={{ height }}>
|
||||
<div className="vrm-canvas" ref={mountRef} />
|
||||
{state.kind === 'idle' ? (
|
||||
<div className="vrm-overlay">
|
||||
<p>尚未选择模型</p>
|
||||
<p className="muted">在左侧列表点击「预览」以加载 VRM。</p>
|
||||
</div>
|
||||
) : null}
|
||||
{state.kind === 'loading' ? (
|
||||
<div className="vrm-overlay">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<p>加载模型中… {state.ratio !== null ? `${Math.round(state.ratio * 100)}%` : ''}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{state.kind === 'error' ? (
|
||||
<div className="vrm-overlay vrm-overlay-error">
|
||||
<p>⚠ {state.message}</p>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setReloadKey((v) => v + 1)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="vrm-panel-foot">
|
||||
<span className="muted">
|
||||
拖拽旋转 · 滚轮缩放
|
||||
{state.kind === 'ready' ? ` · VRM 版本:${state.metaVersion}` : ''}
|
||||
</span>
|
||||
{fileUrl ? (
|
||||
<a className="ext-link" href={fileUrl} target="_blank" rel="noreferrer noopener">
|
||||
模型文件
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="state-box state-loading">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBox({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="state-box state-error" role="alert">
|
||||
<div className="state-error-text">
|
||||
<strong>加载失败</strong>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn btn-ghost" onClick={onRetry}>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ text = '暂无数据' }: { text?: string }) {
|
||||
return <div className="state-box state-empty">{text}</div>;
|
||||
}
|
||||
|
||||
export function Notice({
|
||||
kind,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
kind: 'success' | 'error' | 'info';
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`notice notice-${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
|
||||
<span>{children}</span>
|
||||
{onClose ? (
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭提示">
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 弹层 */
|
||||
|
||||
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 (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className={`modal modal-${size}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="modal-head">
|
||||
<h3>{title}</h3>
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer ? <footer className="modal-foot">{footer}</footer> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '处理中…' : confirmText}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="confirm-body">{message}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 表单零件 */
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className={`field${error ? ' field-invalid' : ''}`}>
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{required ? <em className="req">*</em> : null}
|
||||
</span>
|
||||
{children}
|
||||
{hint && !error ? <span className="field-hint">{hint}</span> : null}
|
||||
{error ? <span className="field-error">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: PublishStatus | string }) {
|
||||
const label = STATUS_LABELS[status as PublishStatus] ?? status;
|
||||
return <span className={`badge badge-status badge-${status}`}>{label}</span>;
|
||||
}
|
||||
|
||||
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 <span className={`badge badge-format${highlight}`}>{label}{format === 'vrm' ? ' ★' : ''}</span>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 链接 */
|
||||
|
||||
export function ExternalLink({ href, children }: { href: string; children?: ReactNode }) {
|
||||
if (!/^https?:\/\//i.test(href)) {
|
||||
return <span className="muted">{href || '—'}</span>;
|
||||
}
|
||||
return (
|
||||
<a className="ext-link" href={href} target="_blank" rel="noreferrer noopener" title={href}>
|
||||
{children ?? href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 来源五元组 */
|
||||
|
||||
/** 表格内的来源五元组:摘要 + 可展开全部 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 (
|
||||
<div className="source-cell" title={tooltip}>
|
||||
<div className="source-line">
|
||||
<span className="chip">{sourceTypeLabel(source.sourceType)}</span>
|
||||
<span className="source-name">{source.sourceName || '—'}</span>
|
||||
</div>
|
||||
<div className="source-line source-sub">
|
||||
<span>作者:{source.author || '—'}</span>
|
||||
<span>许可:{source.license || '—'}</span>
|
||||
</div>
|
||||
<div className="source-line source-sub">
|
||||
<ExternalLink href={source.sourceUrl} />
|
||||
</div>
|
||||
<button type="button" className="link-btn" onClick={() => setOpen((value) => !value)}>
|
||||
{open ? '收起来源详情' : '展开来源详情'}
|
||||
</button>
|
||||
{open ? (
|
||||
<dl className="source-detail">
|
||||
<div>
|
||||
<dt>sourceType</dt>
|
||||
<dd>
|
||||
{SOURCE_TYPE_OPTIONS.find((option) => option.value === source.sourceType)?.label ?? source.sourceType}
|
||||
<span className="muted">({source.sourceType})</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>sourceName</dt>
|
||||
<dd>{source.sourceName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>sourceUrl</dt>
|
||||
<dd>
|
||||
<ExternalLink href={source.sourceUrl} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>license</dt>
|
||||
<dd>{source.license || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>author</dt>
|
||||
<dd>{source.author || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 分页 */
|
||||
|
||||
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 (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">
|
||||
共 {total} 条 · 第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="pagination-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(1)}
|
||||
disabled={disabled || page <= 1}
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={disabled || page <= 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange(totalPages)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ 来源五元组表单 */
|
||||
|
||||
export type SourceFormValue = SourceInput;
|
||||
|
||||
export function SourceFieldsForm({
|
||||
value,
|
||||
errors,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: SourceFormValue;
|
||||
errors: Record<string, string>;
|
||||
onChange: (patch: Partial<SourceFormValue>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="fieldset" disabled={disabled}>
|
||||
<legend>来源五元组(全部必填)</legend>
|
||||
<div className="form-grid">
|
||||
<Field label="来源类型 sourceType" required error={errors.sourceType}>
|
||||
<select
|
||||
value={value.sourceType}
|
||||
onChange={(event) => onChange({ sourceType: event.target.value as SourceType | '' })}
|
||||
>
|
||||
<option value="">请选择来源类型…</option>
|
||||
{SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}({option.value})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="来源名称 sourceName" required error={errors.sourceName}>
|
||||
<input
|
||||
type="text"
|
||||
value={value.sourceName}
|
||||
maxLength={200}
|
||||
placeholder="如:官方曲库 / 作者名"
|
||||
onChange={(event) => onChange({ sourceName: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="来源链接 sourceUrl" required error={errors.sourceUrl} hint="必须为 http(s) URL">
|
||||
<input
|
||||
type="url"
|
||||
value={value.sourceUrl}
|
||||
maxLength={500}
|
||||
placeholder="https://example.com/source"
|
||||
onChange={(event) => onChange({ sourceUrl: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="许可 license" required error={errors.license}>
|
||||
<input
|
||||
type="text"
|
||||
value={value.license}
|
||||
maxLength={100}
|
||||
placeholder="CC-BY-4.0 / MIT / proprietary / unknown"
|
||||
onChange={(event) => onChange({ license: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="作者 author" required error={errors.author}>
|
||||
<input
|
||||
type="text"
|
||||
value={value.author}
|
||||
maxLength={200}
|
||||
onChange={(event) => onChange({ author: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** 枚举 → 中文展示文案。 */
|
||||
|
||||
import type {
|
||||
ModelFormat,
|
||||
MotionFormat,
|
||||
PreprocessState,
|
||||
PublishStatus,
|
||||
ReviewAction,
|
||||
SourceType,
|
||||
TargetType,
|
||||
} from '../api/types';
|
||||
|
||||
export const SOURCE_TYPE_LABELS: Record<SourceType, string> = {
|
||||
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<PublishStatus, string> = {
|
||||
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<PreprocessState, string> = {
|
||||
none: '未处理',
|
||||
running: '处理中',
|
||||
done: '已完成',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
export const MOTION_FORMAT_LABELS: Record<MotionFormat, string> = {
|
||||
bvh: 'BVH',
|
||||
fbx: 'FBX',
|
||||
gltf: 'glTF',
|
||||
glb: 'GLB',
|
||||
'keypoints-json': '关键点 JSON',
|
||||
};
|
||||
|
||||
export const MODEL_FORMAT_LABELS: Record<ModelFormat, string> = {
|
||||
vrm: 'VRM',
|
||||
glb: 'GLB',
|
||||
gltf: 'glTF',
|
||||
fbx: 'FBX',
|
||||
};
|
||||
|
||||
export const TARGET_TYPE_LABELS: Record<TargetType, string> = {
|
||||
song: '曲目',
|
||||
motion: '动作',
|
||||
model: '模型',
|
||||
};
|
||||
|
||||
export const REVIEW_ACTION_LABELS: Record<ReviewAction, string> = {
|
||||
submit: '提交审核',
|
||||
approve: '通过',
|
||||
reject: '驳回',
|
||||
withdraw: '撤回',
|
||||
};
|
||||
|
||||
export const LANGUAGE_LABELS: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { errorMessage } from '../api/client';
|
||||
|
||||
export interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/** 已决结果包装:用于「部分请求失败也要出画面」的场景。 */
|
||||
export interface Settled<T> {
|
||||
ok: boolean;
|
||||
value: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function settle<T>(promise: Promise<T>): Promise<Settled<T>> {
|
||||
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<T>(loader: () => Promise<T>, deps: readonly unknown[]): AsyncState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** 与 contracts/rest-api.yaml 对齐的客户端校验。 */
|
||||
|
||||
import type { SourceInput } from '../api/types';
|
||||
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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<PublishStatus, number | null>;
|
||||
|
||||
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<StatusCounts> {
|
||||
const results = await Promise.all(
|
||||
PUBLISH_STATUSES.map(async (status) => {
|
||||
try {
|
||||
const page = await apiGet<Page<unknown>>(`/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<Health>;
|
||||
version: Settled<ContractVersion>;
|
||||
counts: Record<string, StatusCounts>;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const state = useAsync<DashboardData>(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 <Loading text="正在加载概览…" />;
|
||||
if (state.error && !state.data) return <ErrorBox message={state.error} onRetry={state.reload} />;
|
||||
if (!state.data) return <Empty text="暂无概览数据" />;
|
||||
|
||||
const { health, version, counts } = state.data;
|
||||
const unreachable = !health.ok;
|
||||
const healthValue = health.value;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>概览</h1>
|
||||
<p className="muted">OpenDance Studio 管理端 · 契约 rest-api 0.1.0</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
|
||||
{state.loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{unreachable ? (
|
||||
<div className="banner banner-warn" role="alert">
|
||||
<strong>⚠ API 服务不可达</strong>
|
||||
<span>{health.error ?? '未知错误'}</span>
|
||||
<span className="muted">
|
||||
管理端默认请求同源 <code>/api</code> 与 <code>/health</code>:开发期由 vite 代理到
|
||||
<code> http://localhost:3000</code>,生产期由 nginx 反代到 <code>http://api:3000</code>。
|
||||
请确认服务端已启动,或通过 <code>VITE_API_BASE</code> 指定其他基址。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||
|
||||
<section className="card-grid">
|
||||
{KINDS.map((kind) => {
|
||||
const kindCounts = counts[kind.key];
|
||||
const total = sumCounts(kindCounts);
|
||||
return (
|
||||
<article className="stat-card" key={kind.key}>
|
||||
<header>
|
||||
<h3>{kind.label}</h3>
|
||||
<Link className="link-btn" to={kind.path}>
|
||||
进入管理 →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="stat-total">
|
||||
{total === null ? <span className="muted">—</span> : total}
|
||||
<span className="muted"> 条记录</span>
|
||||
</div>
|
||||
<ul className="stat-list">
|
||||
{PUBLISH_STATUSES.map((status) => (
|
||||
<li key={status}>
|
||||
<span className={`dot dot-${status}`} aria-hidden="true" />
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
<strong>{kindCounts[status] === null ? '—' : kindCounts[status]}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{total === null ? <p className="muted small">部分状态统计失败(服务不可达或鉴权失败)</p> : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>契约版本 / 目录指纹</h2>
|
||||
<span className="muted">GET /api/catalog/version</span>
|
||||
</header>
|
||||
<div className="panel-body">
|
||||
{version.ok && version.value ? (
|
||||
<>
|
||||
<div className="kv-grid">
|
||||
{Object.entries(version.value.contractVersion ?? {}).map(([key, value]) => (
|
||||
<div className="kv" key={key}>
|
||||
<span className="muted">{key}</span>
|
||||
<code>{value}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">revision(目录 sha256)</span>
|
||||
<code className="break">{version.value.revision || '—'}</code>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ErrorBox message={version.error ?? '无法获取契约版本'} onRetry={state.reload} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>服务健康</h2>
|
||||
<span className="muted">GET /health</span>
|
||||
</header>
|
||||
<div className="panel-body">
|
||||
{health.ok && healthValue ? (
|
||||
<div className="kv-grid">
|
||||
<div className="kv">
|
||||
<span className="muted">status</span>
|
||||
<code>{healthValue.status}</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">version</span>
|
||||
<code>{healthValue.version}</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">uptime</span>
|
||||
<code>{Math.round(healthValue.uptimeSec ?? 0)} s</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">database</span>
|
||||
<code>
|
||||
{healthValue.database?.connected ? 'connected' : 'disconnected'}
|
||||
{healthValue.database?.server ? ` · ${healthValue.database.server}` : ''}
|
||||
</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">迁移待执行</span>
|
||||
<code>{healthValue.database?.migrationsPending ?? 0}</code>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorBox message={health.error ?? '无法获取健康状态'} onRetry={state.reload} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="muted small">最后刷新:{formatDateTime(new Date().toISOString())}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ModelFormState>(EMPTY_MODEL_FORM);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const patch = (part: Partial<ModelFormState>): void => {
|
||||
setForm((previous) => ({ ...previous, ...part }));
|
||||
};
|
||||
|
||||
const onSubmit = async (event: FormEvent): Promise<void> => {
|
||||
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 (
|
||||
<Modal
|
||||
title="上传模型"
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" form="model-upload-form" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '上传中…' : '上传'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="model-upload-form" className="form" onSubmit={onSubmit}>
|
||||
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="模型文件 file" required error={errors.file} hint={`支持 ${ACCEPT_EXT},单文件上限 512 MiB`}>
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPT_EXT}
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="模型名称 name" required error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
maxLength={200}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="状态 status">
|
||||
<select value={form.status} onChange={(event) => patch({ status: event.target.value as PublishStatus })}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="设为全局默认模型 isPrimary" hint="同一时刻全库至多一个默认模型(VRM 优先)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrimary}
|
||||
onChange={(event) => patch({ isPrimary: event.target.checked })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 编辑弹窗 */
|
||||
|
||||
function ModelEditModal({
|
||||
model,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
model: Model;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<ModelEditState>({
|
||||
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<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const patch = (part: Partial<ModelEditState>): void => {
|
||||
setForm((previous) => ({ ...previous, ...part }));
|
||||
};
|
||||
|
||||
const onSubmit = async (event: FormEvent): Promise<void> => {
|
||||
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 (
|
||||
<Modal
|
||||
title={`编辑模型 #${model.id}`}
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" form="model-edit-form" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="model-edit-form" className="form" onSubmit={onSubmit}>
|
||||
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
|
||||
<div className="form-grid">
|
||||
<Field label="模型名称 name" required error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
maxLength={200}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="状态 status">
|
||||
<select value={form.status} onChange={(event) => patch({ status: event.target.value as PublishStatus })}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 页面 */
|
||||
|
||||
export function ModelsPage() {
|
||||
const [filters, setFilters] = useState<ModelFilters>({ q: '', format: '', status: '', page: 1 });
|
||||
const [qInput, setQInput] = useState('');
|
||||
const [previewId, setPreviewId] = useState<number | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Model | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>模型管理</h1>
|
||||
<p className="muted">T-WEB-009 · VRM 优先,来源五元组必填,支持 3D 预览与全局默认模型</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
|
||||
{state.loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen(true)}>
|
||||
+ 上传模型
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
|
||||
{notice.text}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="filter-bar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索模型名称(q)"
|
||||
value={qInput}
|
||||
onChange={(event) => setQInput(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={filters.format}
|
||||
onChange={(event) =>
|
||||
setFilters((previous) => ({ ...previous, format: event.target.value, page: 1 }))
|
||||
}
|
||||
>
|
||||
<option value="">全部格式</option>
|
||||
{MODEL_FORMATS.map((format) => (
|
||||
<option key={format} value={format}>
|
||||
{MODEL_FORMAT_LABELS[format]}({format})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(event) =>
|
||||
setFilters((previous) => ({
|
||||
...previous,
|
||||
status: event.target.value as PublishStatus | '',
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn btn-ghost">
|
||||
查询
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setQInput('');
|
||||
setFilters({ q: '', format: '', status: '', page: 1 });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="split-layout">
|
||||
<section className="panel">
|
||||
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||
{state.loading && !state.data ? <Loading text="正在加载模型…" /> : null}
|
||||
{!state.loading && !state.error && items.length === 0 ? (
|
||||
<Empty text="还没有模型,点击右上角上传第一个 VRM 模型。" />
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>预览缩略图</th>
|
||||
<th className="col-name">名称</th>
|
||||
<th>格式</th>
|
||||
<th>VRM 版本</th>
|
||||
<th>是否默认</th>
|
||||
<th className="col-source">来源</th>
|
||||
<th>许可</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((model) => (
|
||||
<tr key={model.id} className={model.id === previewId ? 'row-active' : undefined}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="thumb"
|
||||
onClick={() => setPreviewId(model.id)}
|
||||
title="点击预览"
|
||||
>
|
||||
{model.thumbnailUrl ? (
|
||||
<img src={model.thumbnailUrl} alt={`${model.name} 缩略图`} />
|
||||
) : (
|
||||
<span className="thumb-placeholder">
|
||||
{MODEL_FORMAT_LABELS[model.format]}
|
||||
<em>无缩略图</em>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="col-name">
|
||||
<div className="cell-title">{model.name}</div>
|
||||
<div className="muted small">
|
||||
#{model.id} · {formatBytes(model.fileSize)} · {formatDateTime(model.updatedAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<FormatBadge format={model.format} kind="model" />
|
||||
</td>
|
||||
<td className="mono">{model.vrmVersion ?? '—'}</td>
|
||||
<td>
|
||||
{model.isPrimary ? (
|
||||
<span className="badge badge-primary">默认 ★</span>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="col-source">
|
||||
<SourceCell source={model} />
|
||||
</td>
|
||||
<td>
|
||||
<div className="mono small">{model.license || '—'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={model.status} />
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button type="button" className="link-btn" onClick={() => setPreviewId(model.id)}>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => makePrimary(model)}
|
||||
disabled={model.isPrimary || busyId === model.id}
|
||||
title={model.isPrimary ? '已是默认模型' : 'POST /api/models/:id/primary'}
|
||||
>
|
||||
{busyId === model.id ? '设置中…' : '设为默认'}
|
||||
</button>
|
||||
<button type="button" className="link-btn" onClick={() => setEditTarget(model)}>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" className="link-btn link-danger" onClick={() => setDeleteTarget(model)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.data ? (
|
||||
<Pagination
|
||||
page={state.data.page ?? filters.page}
|
||||
pageSize={state.data.pageSize ?? PAGE_SIZE}
|
||||
total={state.data.total ?? 0}
|
||||
disabled={state.loading}
|
||||
onChange={(page) => setFilters((previous) => ({ ...previous, page }))}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<aside className="preview-column">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="panel">
|
||||
<Loading text="正在加载 3D 预览模块…" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VrmPreview
|
||||
fileUrl={previewModel?.fileUrl ?? null}
|
||||
name={previewModel ? `${previewModel.name}(#${previewModel.id})` : ''}
|
||||
unsupportedReason={previewUnsupported}
|
||||
/>
|
||||
</Suspense>
|
||||
<div className="panel preview-meta">
|
||||
<header className="panel-head">
|
||||
<h2>预览说明</h2>
|
||||
</header>
|
||||
<div className="panel-body">
|
||||
<p className="muted small">
|
||||
通过 GLTFLoader + VRMLoaderPlugin 加载 <code>fileUrl</code>,VRM 0.x 自动执行 180° 旋转;
|
||||
使用 <code>requestAnimationFrame</code> 循环调用 <code>vrm.update(delta)</code>,
|
||||
卸载时释放渲染器、几何体、材质与贴图。
|
||||
</p>
|
||||
{previewModel ? (
|
||||
<ul className="kv-list">
|
||||
<li>
|
||||
<span className="muted">文件地址</span>
|
||||
<span className="break">{previewModel.fileUrl ?? '—'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="muted">格式 / VRM 版本</span>
|
||||
<span>
|
||||
{MODEL_FORMAT_LABELS[previewModel.format]} / {previewModel.vrmVersion ?? '—'}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="muted">是否默认</span>
|
||||
<span>{previewModel.isPrimary ? '是' : '否'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted small">尚未选择模型。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{uploadOpen ? <ModelUploadModal onClose={() => setUploadOpen(false)} onSaved={onSaved} /> : null}
|
||||
{editTarget ? (
|
||||
<ModelEditModal model={editTarget} onClose={() => setEditTarget(null)} onSaved={onSaved} />
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除模型"
|
||||
danger
|
||||
busy={deleting}
|
||||
confirmText="删除"
|
||||
message={
|
||||
<>
|
||||
<p>
|
||||
确认删除模型 <strong>{deleteTarget.name}</strong>(#{deleteTarget.id})?
|
||||
</p>
|
||||
<p className="muted small">DELETE /api/models/{deleteTarget.id} · 服务端执行软删除。</p>
|
||||
</>
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={`badge badge-pre-${motion.preprocessState}`} title={title}>
|
||||
{label}
|
||||
{motion.preprocessState === 'running' ? <span className="spinner spinner-xs" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 上传弹窗 */
|
||||
|
||||
function MotionUploadModal({
|
||||
songs,
|
||||
songsError,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
songs: Song[];
|
||||
songsError: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<MotionFormState>(EMPTY_MOTION_FORM);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const patch = (part: Partial<MotionFormState>): void => {
|
||||
setForm((previous) => ({ ...previous, ...part }));
|
||||
};
|
||||
|
||||
const onSubmit = async (event: FormEvent): Promise<void> => {
|
||||
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 (
|
||||
<Modal
|
||||
title="上传动作"
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" form="motion-upload-form" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '上传中…' : '上传'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="motion-upload-form" className="form" onSubmit={onSubmit}>
|
||||
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
|
||||
{songsError ? (
|
||||
<Notice kind="info">曲目列表加载失败,关联曲目暂不可选:{songsError}</Notice>
|
||||
) : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="动作文件 file" required error={errors.file} hint={`支持 ${ACCEPT_EXT}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPT_EXT}
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动作名称 name" required error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
maxLength={200}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="关联曲目 songId" hint="可留空">
|
||||
<select value={form.songId} onChange={(event) => patch({ songId: event.target.value })}>
|
||||
<option value="">不关联</option>
|
||||
{songs.map((song) => (
|
||||
<option key={song.id} value={String(song.id)}>
|
||||
#{song.id} {song.title} — {song.artist}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="帧率 fps" error={errors.fps} hint="留空则服务端按缺省 30 处理">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={form.fps}
|
||||
onChange={(event) => patch({ fps: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="状态 status">
|
||||
<select value={form.status} onChange={(event) => patch({ status: event.target.value as PublishStatus })}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="上传后自动预处理 autoPreprocess" hint="POST /api/motions/:id/preprocess">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.autoPreprocess}
|
||||
onChange={(event) => patch({ autoPreprocess: event.target.checked })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 页面 */
|
||||
|
||||
export function MotionsPage() {
|
||||
const [filters, setFilters] = useState<MotionFilters>({ q: '', format: '', status: '', page: 1 });
|
||||
const [qInput, setQInput] = useState('');
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Motion | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>动作管理</h1>
|
||||
<p className="muted">T-WEB-004/006 · 动作文件、MediaPipe33 预处理与关键点下载</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
|
||||
{state.loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen(true)}>
|
||||
+ 上传动作
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
|
||||
{notice.text}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="filter-bar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索动作名称(q)"
|
||||
value={qInput}
|
||||
onChange={(event) => setQInput(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={filters.format}
|
||||
onChange={(event) =>
|
||||
setFilters((previous) => ({ ...previous, format: event.target.value, page: 1 }))
|
||||
}
|
||||
>
|
||||
<option value="">全部格式</option>
|
||||
{MOTION_FORMATS.map((format) => (
|
||||
<option key={format} value={format}>
|
||||
{MOTION_FORMAT_LABELS[format]}({format})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(event) =>
|
||||
setFilters((previous) => ({
|
||||
...previous,
|
||||
status: event.target.value as PublishStatus | '',
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn btn-ghost">
|
||||
查询
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setQInput('');
|
||||
setFilters({ q: '', format: '', status: '', page: 1 });
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="panel">
|
||||
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||
{state.loading && !state.data ? <Loading text="正在加载动作…" /> : null}
|
||||
{!state.loading && !state.error && items.length === 0 ? (
|
||||
<Empty text="还没有动作数据,点击右上角上传 BVH / FBX / glTF 或关键点 JSON。" />
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>关联曲目</th>
|
||||
<th>格式</th>
|
||||
<th>帧率 / 帧数</th>
|
||||
<th>预处理状态</th>
|
||||
<th className="col-source">来源五元组</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((motion) => {
|
||||
const keypointsReady = motion.preprocessState === 'done';
|
||||
return (
|
||||
<tr key={motion.id}>
|
||||
<td>
|
||||
<div className="cell-title">{motion.name}</div>
|
||||
<div className="muted small">#{motion.id}</div>
|
||||
</td>
|
||||
<td>{songTitle(motion.songId)}</td>
|
||||
<td>
|
||||
<FormatBadge format={motion.format} kind="motion" />
|
||||
</td>
|
||||
<td className="mono">
|
||||
{formatNumber(motion.fps)} fps
|
||||
<div className="muted small">{motion.frameCount ?? '—'} 帧</div>
|
||||
</td>
|
||||
<td>
|
||||
<PreprocessBadge motion={motion} />
|
||||
{motion.preprocessState === 'failed' && motion.preprocessError ? (
|
||||
<div className="muted small break" title={motion.preprocessError}>
|
||||
{motion.preprocessError}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="col-source">
|
||||
<SourceCell source={motion} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={motion.status} />
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={busyId === motion.id || motion.preprocessState === 'running'}
|
||||
onClick={() => runPreprocess(motion)}
|
||||
title="POST /api/motions/:id/preprocess"
|
||||
>
|
||||
{busyId === motion.id ? '触发中…' : '预处理'}
|
||||
</button>
|
||||
{keypointsReady ? (
|
||||
<a
|
||||
className="link-btn"
|
||||
href={apiUrl(`/api/motions/${motion.id}/keypoints`)}
|
||||
download={`motion-${motion.id}-keypoints.ndjson`}
|
||||
title="GET /api/motions/:id/keypoints(application/x-ndjson)"
|
||||
>
|
||||
下载关键点
|
||||
</a>
|
||||
) : (
|
||||
<span className="link-btn link-disabled" title="预处理完成后才可下载(否则服务端返回 409)">
|
||||
下载关键点
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn link-danger"
|
||||
onClick={() => setDeleteTarget(motion)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.data ? (
|
||||
<Pagination
|
||||
page={state.data.page ?? filters.page}
|
||||
pageSize={state.data.pageSize ?? PAGE_SIZE}
|
||||
total={state.data.total ?? 0}
|
||||
disabled={state.loading}
|
||||
onChange={(page) => setFilters((previous) => ({ ...previous, page }))}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{uploadOpen ? (
|
||||
<MotionUploadModal
|
||||
songs={songs}
|
||||
songsError={songsState.error}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除动作"
|
||||
danger
|
||||
busy={deleting}
|
||||
confirmText="删除"
|
||||
message={
|
||||
<>
|
||||
<p>
|
||||
确认删除动作 <strong>{deleteTarget.name}</strong>(#{deleteTarget.id})?
|
||||
</p>
|
||||
<p className="muted small">DELETE /api/motions/{deleteTarget.id} · 服务端执行软删除。</p>
|
||||
</>
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isReject = target.action === 'reject';
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
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 (
|
||||
<Modal
|
||||
title={`${isReject ? '驳回' : '通过'} · ${target.item.name}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${isReject ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '提交中…' : isReject ? '确认驳回' : '确认通过'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form">
|
||||
{error ? <Notice kind="error">{error}</Notice> : null}
|
||||
<div className="kv-list">
|
||||
<li>
|
||||
<span className="muted">目标</span>
|
||||
<span>
|
||||
{TARGET_TYPE_LABELS[target.item.targetType]} #{target.item.targetId} · {target.item.name}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="muted">审核人 reviewer</span>
|
||||
<span>{reviewer.trim() === '' ? '(空)' : reviewer}</span>
|
||||
</li>
|
||||
</div>
|
||||
<Field
|
||||
label="审核意见 comment"
|
||||
required={isReject}
|
||||
error={error && isReject && comment.trim() === '' ? error : undefined}
|
||||
hint={isReject ? '驳回必填,服务端校验' : '可选'}
|
||||
>
|
||||
<textarea rows={4} value={comment} onChange={(event) => setComment(event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 页面 */
|
||||
|
||||
export function ReviewsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab: TabKey = searchParams.get('tab') === 'history' ? 'history' : 'pending';
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [reviewer, setReviewer] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(REVIEWER_STORAGE_KEY) ?? 'admin';
|
||||
} catch {
|
||||
return 'admin';
|
||||
}
|
||||
});
|
||||
const [actionTarget, setActionTarget] = useState<ActionTarget | null>(null);
|
||||
const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(REVIEWER_STORAGE_KEY, reviewer);
|
||||
} catch {
|
||||
/* 忽略隐私模式下的写入失败 */
|
||||
}
|
||||
}, [reviewer]);
|
||||
|
||||
const pendingState = useAsync(
|
||||
() => listPending({ status: 'pending', page, pageSize: PAGE_SIZE }),
|
||||
[page],
|
||||
);
|
||||
const historyState = useAsync(() => listReviewRecords({ page, pageSize: PAGE_SIZE }), [page]);
|
||||
|
||||
const switchTab = (next: TabKey): void => {
|
||||
setPage(1);
|
||||
setSearchParams(next === 'pending' ? {} : { tab: next });
|
||||
};
|
||||
|
||||
const reloadAll = (): void => {
|
||||
pendingState.reload();
|
||||
historyState.reload();
|
||||
};
|
||||
|
||||
const pendingItems = pendingState.data?.items ?? [];
|
||||
const historyItems = historyState.data?.items ?? [];
|
||||
const activeState = tab === 'pending' ? pendingState : historyState;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>审核</h1>
|
||||
<p className="muted">T-WEB-007 · 待审资产与审核流水</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<label className="inline-field">
|
||||
<span className="muted">审核人</span>
|
||||
<input
|
||||
type="text"
|
||||
value={reviewer}
|
||||
placeholder="reviewer"
|
||||
onChange={(event) => setReviewer(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-ghost" onClick={reloadAll} disabled={activeState.loading}>
|
||||
{activeState.loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
|
||||
{notice.text}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<div className="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'pending'}
|
||||
className={`tab${tab === 'pending' ? ' tab-active' : ''}`}
|
||||
onClick={() => switchTab('pending')}
|
||||
>
|
||||
待审列表
|
||||
{pendingState.data ? <span className="tab-count">{pendingState.data.total ?? 0}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'history'}
|
||||
className={`tab${tab === 'history' ? ' tab-active' : ''}`}
|
||||
onClick={() => switchTab('history')}
|
||||
>
|
||||
流水
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'pending' ? (
|
||||
<section className="panel">
|
||||
{pendingState.error ? <ErrorBox message={pendingState.error} onRetry={pendingState.reload} /> : null}
|
||||
{pendingState.loading && !pendingState.data ? <Loading text="正在加载待审资产…" /> : null}
|
||||
{!pendingState.loading && !pendingState.error && pendingItems.length === 0 ? (
|
||||
<Empty text="当前没有待审核的资产。" />
|
||||
) : null}
|
||||
|
||||
{pendingItems.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>名称</th>
|
||||
<th>状态</th>
|
||||
<th className="col-source">来源五元组</th>
|
||||
<th>更新时间</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingItems.map((item) => (
|
||||
<tr key={`${item.targetType}-${item.targetId}`}>
|
||||
<td>{TARGET_TYPE_LABELS[item.targetType] ?? item.targetType}</td>
|
||||
<td>
|
||||
<div className="cell-title">{item.name}</div>
|
||||
<div className="muted small">
|
||||
#{item.targetId} · {item.targetType}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="col-source">
|
||||
{item.source ? (
|
||||
<SourceCell source={item.source} />
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono small">{formatDateTime(item.updatedAt)}</td>
|
||||
<td className="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setActionTarget({ item, action: 'approve' })}
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn link-danger"
|
||||
onClick={() => setActionTarget({ item, action: 'reject' })}
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingState.data ? (
|
||||
<Pagination
|
||||
page={pendingState.data.page ?? page}
|
||||
pageSize={pendingState.data.pageSize ?? PAGE_SIZE}
|
||||
total={pendingState.data.total ?? 0}
|
||||
disabled={pendingState.loading}
|
||||
onChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
) : (
|
||||
<section className="panel">
|
||||
{historyState.error ? <ErrorBox message={historyState.error} onRetry={historyState.reload} /> : null}
|
||||
{historyState.loading && !historyState.data ? <Loading text="正在加载审核流水…" /> : null}
|
||||
{!historyState.loading && !historyState.error && historyItems.length === 0 ? (
|
||||
<Empty text="暂无审核流水记录。" />
|
||||
) : null}
|
||||
|
||||
{historyItems.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>目标</th>
|
||||
<th>动作</th>
|
||||
<th>状态流转</th>
|
||||
<th>审核人</th>
|
||||
<th>意见</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyItems.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td className="mono small">{formatDateTime(record.createdAt)}</td>
|
||||
<td>
|
||||
{TARGET_TYPE_LABELS[record.targetType] ?? record.targetType} #{record.targetId}
|
||||
</td>
|
||||
<td>{REVIEW_ACTION_LABELS[record.action] ?? record.action}</td>
|
||||
<td className="small">
|
||||
{STATUS_LABELS[record.fromStatus] ?? record.fromStatus} →{' '}
|
||||
{STATUS_LABELS[record.toStatus] ?? record.toStatus}
|
||||
</td>
|
||||
<td>{record.reviewer ?? '—'}</td>
|
||||
<td className="small break">{record.comment ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{historyState.data ? (
|
||||
<Pagination
|
||||
page={historyState.data.page ?? page}
|
||||
pageSize={historyState.data.pageSize ?? PAGE_SIZE}
|
||||
total={historyState.data.total ?? 0}
|
||||
disabled={historyState.loading}
|
||||
onChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{actionTarget ? (
|
||||
<ReviewActionModal
|
||||
target={actionTarget}
|
||||
reviewer={reviewer}
|
||||
onClose={() => setActionTarget(null)}
|
||||
onDone={(message) => {
|
||||
setActionTarget(null);
|
||||
setNotice({ kind: 'success', text: message });
|
||||
reloadAll();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { errorMessage } from '../api/client';
|
||||
import { createSong, deleteSong, listSongs, updateSong, uploadSongAudio } from '../api/endpoints';
|
||||
import type { PublishStatus, Song, SongCreate, SourceType } from '../api/types';
|
||||
import { SONG_LANGUAGES } from '../api/types';
|
||||
import { formatDuration, formatNumber, parseTags, tagsToText } from '../lib/format';
|
||||
import { LANGUAGE_LABELS, STATUS_OPTIONS } from '../lib/labels';
|
||||
import { hasErrors, parseOptionalNumber, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import {
|
||||
ConfirmDialog,
|
||||
Empty,
|
||||
ErrorBox,
|
||||
Field,
|
||||
Loading,
|
||||
Modal,
|
||||
Notice,
|
||||
Pagination,
|
||||
SourceCell,
|
||||
SourceFieldsForm,
|
||||
StatusBadge,
|
||||
type SourceFormValue,
|
||||
} from '../components/ui';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface SongFilters {
|
||||
q: string;
|
||||
status: PublishStatus | '';
|
||||
artist: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
interface SongFormState extends SourceFormValue {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
language: string;
|
||||
durationSec: string;
|
||||
bpm: string;
|
||||
difficulty: string;
|
||||
tags: string;
|
||||
status: PublishStatus;
|
||||
}
|
||||
|
||||
const EMPTY_SONG_FORM: SongFormState = {
|
||||
title: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
language: '',
|
||||
durationSec: '',
|
||||
bpm: '',
|
||||
difficulty: '',
|
||||
tags: '',
|
||||
status: 'draft',
|
||||
sourceType: '',
|
||||
sourceName: '',
|
||||
sourceUrl: '',
|
||||
license: '',
|
||||
author: '',
|
||||
};
|
||||
|
||||
function songToForm(song: Song): SongFormState {
|
||||
return {
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album ?? '',
|
||||
language: song.language ?? '',
|
||||
durationSec:
|
||||
song.durationMs === null || song.durationMs === undefined ? '' : String(song.durationMs / 1000),
|
||||
bpm: song.bpm === null || song.bpm === undefined ? '' : String(song.bpm),
|
||||
difficulty:
|
||||
song.difficulty === null || song.difficulty === undefined ? '' : String(song.difficulty),
|
||||
tags: tagsToText(song.tags),
|
||||
status: song.status,
|
||||
sourceType: song.sourceType,
|
||||
sourceName: song.sourceName,
|
||||
sourceUrl: song.sourceUrl,
|
||||
license: song.license,
|
||||
author: song.author,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSongForm(form: SongFormState): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
requireText(form.title, 'title', '曲名', errors, 200);
|
||||
requireText(form.artist, 'artist', '演唱者', errors, 200);
|
||||
if (form.album.trim().length > 200) errors.album = '专辑不超过 200 字';
|
||||
|
||||
const seconds = parseOptionalNumber(form.durationSec);
|
||||
if (seconds !== undefined && (!Number.isFinite(seconds) || seconds <= 0)) {
|
||||
errors.durationSec = '时长需为正数(秒)';
|
||||
}
|
||||
const bpm = parseOptionalNumber(form.bpm);
|
||||
if (bpm !== undefined && (!Number.isFinite(bpm) || bpm <= 0 || bpm > 400)) {
|
||||
errors.bpm = 'BPM 需为 0–400 之间的数字';
|
||||
}
|
||||
if (form.difficulty !== '') {
|
||||
const difficulty = Number(form.difficulty);
|
||||
if (!Number.isInteger(difficulty) || difficulty < 1 || difficulty > 5) {
|
||||
errors.difficulty = '难度为 1–5';
|
||||
}
|
||||
}
|
||||
validateSourceFields(form, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function toSongPayload(form: SongFormState): SongCreate {
|
||||
if (form.sourceType === '') {
|
||||
// 已被 validateSongForm 拦截;此处仅为类型收窄。
|
||||
throw new Error('请选择来源类型 sourceType');
|
||||
}
|
||||
const sourceType: SourceType = form.sourceType;
|
||||
const payload: SongCreate = {
|
||||
title: form.title.trim(),
|
||||
artist: form.artist.trim(),
|
||||
status: form.status,
|
||||
sourceType,
|
||||
sourceName: form.sourceName.trim(),
|
||||
sourceUrl: form.sourceUrl.trim(),
|
||||
license: form.license.trim(),
|
||||
author: form.author.trim(),
|
||||
};
|
||||
if (form.album.trim() !== '') payload.album = form.album.trim();
|
||||
if (form.language !== '') payload.language = form.language;
|
||||
|
||||
const seconds = parseOptionalNumber(form.durationSec);
|
||||
if (seconds !== undefined && Number.isFinite(seconds) && seconds > 0) {
|
||||
payload.durationMs = Math.round(seconds * 1000);
|
||||
}
|
||||
const bpm = parseOptionalNumber(form.bpm);
|
||||
if (bpm !== undefined && Number.isFinite(bpm)) payload.bpm = bpm;
|
||||
if (form.difficulty !== '') payload.difficulty = Number(form.difficulty);
|
||||
|
||||
const tags = parseTags(form.tags);
|
||||
if (tags.length > 0) payload.tags = tags;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- 表单弹窗 */
|
||||
|
||||
function SongFormModal({
|
||||
song,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
song: Song | null;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<SongFormState>(song ? songToForm(song) : EMPTY_SONG_FORM);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const patch = (part: Partial<SongFormState>): void => {
|
||||
setForm((previous) => ({ ...previous, ...part }));
|
||||
};
|
||||
|
||||
const onSubmit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const nextErrors = validateSongForm(form);
|
||||
setErrors(nextErrors);
|
||||
if (hasErrors(nextErrors)) return;
|
||||
|
||||
setSaving(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
const payload = toSongPayload(form);
|
||||
if (song) {
|
||||
await updateSong(song.id, payload);
|
||||
} else {
|
||||
await createSong(payload);
|
||||
}
|
||||
onSaved(song ? '曲目已更新' : '曲目已创建');
|
||||
} catch (cause) {
|
||||
setServerError(errorMessage(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={song ? `编辑曲目 #${song.id}` : '新建曲目'}
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" form="song-form" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="song-form" className="form" onSubmit={onSubmit}>
|
||||
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="曲名 title" required error={errors.title}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
maxLength={200}
|
||||
onChange={(event) => patch({ title: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="演唱者 artist" required error={errors.artist}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.artist}
|
||||
maxLength={200}
|
||||
onChange={(event) => patch({ artist: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="专辑 album" error={errors.album}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.album}
|
||||
maxLength={200}
|
||||
onChange={(event) => patch({ album: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="语言 language">
|
||||
<select value={form.language} onChange={(event) => patch({ language: event.target.value })}>
|
||||
<option value="">未指定</option>
|
||||
{SONG_LANGUAGES.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{LANGUAGE_LABELS[language] ?? language}({language})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="时长(秒)" error={errors.durationSec} hint="提交时自动换算为 durationMs">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={form.durationSec}
|
||||
onChange={(event) => patch({ durationSec: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="BPM" error={errors.bpm}>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={form.bpm}
|
||||
onChange={(event) => patch({ bpm: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="难度 difficulty" error={errors.difficulty}>
|
||||
<select value={form.difficulty} onChange={(event) => patch({ difficulty: event.target.value })}>
|
||||
<option value="">未指定</option>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<option key={level} value={String(level)}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="状态 status">
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => patch({ status: event.target.value as PublishStatus })}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="标签 tags" hint="英文/中文逗号分隔">
|
||||
<input
|
||||
type="text"
|
||||
value={form.tags}
|
||||
placeholder="pop, 快歌"
|
||||
onChange={(event) => patch({ tags: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- 音频弹窗 */
|
||||
|
||||
function AudioUploadModal({
|
||||
song,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
song: Song;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!file) {
|
||||
setError('请选择音频文件');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadSongAudio(song.id, file);
|
||||
onSaved('音频已上传');
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`上传音频 · ${song.title}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving || !file}>
|
||||
{saving ? '上传中…' : '上传'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form">
|
||||
{error ? <Notice kind="error">{error}</Notice> : null}
|
||||
<Field label="音频文件" required hint="POST /api/songs/:id/audio · multipart 字段名 file">
|
||||
<input type="file" accept="audio/*" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</Field>
|
||||
{song.audioUrl ? (
|
||||
<div className="kv">
|
||||
<span className="muted">当前音频</span>
|
||||
<audio controls preload="none" src={song.audioUrl} />
|
||||
<code className="break">{song.audioUrl}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 页面 */
|
||||
|
||||
export function SongsPage() {
|
||||
const [filters, setFilters] = useState<SongFilters>({ q: '', status: '', artist: '', page: 1 });
|
||||
const [qInput, setQInput] = useState('');
|
||||
const [statusInput, setStatusInput] = useState<PublishStatus | ''>('');
|
||||
const [artistInput, setArtistInput] = useState('');
|
||||
|
||||
const [editingSong, setEditingSong] = useState<Song | null | undefined>(undefined);
|
||||
const [audioTarget, setAudioTarget] = useState<Song | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Song | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const state = useAsync(
|
||||
() => listSongs({ page: filters.page, pageSize: PAGE_SIZE, q: filters.q, status: filters.status, artist: filters.artist }),
|
||||
[filters.q, filters.status, filters.artist, filters.page],
|
||||
);
|
||||
|
||||
const applyFilters = (next?: { status?: PublishStatus | '' }): void => {
|
||||
setFilters({
|
||||
q: qInput.trim(),
|
||||
status: next?.status ?? statusInput,
|
||||
artist: artistInput.trim(),
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const resetFilters = (): void => {
|
||||
setQInput('');
|
||||
setStatusInput('');
|
||||
setArtistInput('');
|
||||
setFilters({ q: '', status: '', artist: '', page: 1 });
|
||||
};
|
||||
|
||||
const onSaved = (message: string): void => {
|
||||
setEditingSong(undefined);
|
||||
setAudioTarget(null);
|
||||
setNotice({ kind: 'success', text: message });
|
||||
state.reload();
|
||||
};
|
||||
|
||||
const confirmDelete = async (): Promise<void> => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteSong(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
setNotice({ kind: 'success', text: `已删除曲目「${deleteTarget.title}」(软删除)` });
|
||||
state.reload();
|
||||
} catch (cause) {
|
||||
setNotice({ kind: 'error', text: errorMessage(cause) });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const items = state.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>歌曲管理</h1>
|
||||
<p className="muted">T-WEB-008 · 曲目元数据、来源五元组、音频上传与软删除</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
|
||||
{state.loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setEditingSong(null)}>
|
||||
+ 新建曲目
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
|
||||
{notice.text}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="filter-bar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索曲名 / 演唱者 / 专辑(q)"
|
||||
value={qInput}
|
||||
onChange={(event) => setQInput(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={statusInput}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as PublishStatus | '';
|
||||
setStatusInput(value);
|
||||
applyFilters({ status: value });
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="按演唱者筛选(artist)"
|
||||
value={artistInput}
|
||||
onChange={(event) => setArtistInput(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-ghost">
|
||||
查询
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={resetFilters}>
|
||||
重置
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="panel">
|
||||
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||
{state.loading && !state.data ? <Loading text="正在加载曲目…" /> : null}
|
||||
{!state.loading && !state.error && items.length === 0 ? (
|
||||
<Empty text="没有符合条件的曲目,试试调整筛选条件或新建曲目。" />
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>曲名 / 演唱者</th>
|
||||
<th>专辑</th>
|
||||
<th>语言</th>
|
||||
<th>时长</th>
|
||||
<th>BPM</th>
|
||||
<th>难度</th>
|
||||
<th>标签</th>
|
||||
<th className="col-source">来源五元组</th>
|
||||
<th>状态</th>
|
||||
<th className="col-audio">音频</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((song) => (
|
||||
<tr key={song.id}>
|
||||
<td className="mono">{song.id}</td>
|
||||
<td>
|
||||
<div className="cell-title">{song.title}</div>
|
||||
<div className="muted small">{song.artist}</div>
|
||||
</td>
|
||||
<td>{song.album || '—'}</td>
|
||||
<td>{LANGUAGE_LABELS[song.language ?? ''] ?? song.language ?? '—'}</td>
|
||||
<td className="mono">{formatDuration(song.durationMs)}</td>
|
||||
<td className="mono">{formatNumber(song.bpm)}</td>
|
||||
<td className="mono">{song.difficulty ?? '—'}</td>
|
||||
<td className="small">{tagsToText(song.tags) || '—'}</td>
|
||||
<td className="col-source">
|
||||
<SourceCell source={song} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={song.status} />
|
||||
</td>
|
||||
<td className="col-audio">
|
||||
{song.audioUrl ? (
|
||||
<>
|
||||
<audio className="mini-audio" controls preload="none" src={song.audioUrl} />
|
||||
<a
|
||||
className="ext-link small"
|
||||
href={song.audioUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={song.audioUrl}
|
||||
>
|
||||
音频地址
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<span className="muted small">未上传</span>
|
||||
)}
|
||||
<button type="button" className="link-btn" onClick={() => setAudioTarget(song)}>
|
||||
{song.audioUrl ? '替换音频' : '上传音频'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button type="button" className="link-btn" onClick={() => setEditingSong(song)}>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" className="link-btn" onClick={() => setAudioTarget(song)}>
|
||||
音频
|
||||
</button>
|
||||
<button type="button" className="link-btn link-danger" onClick={() => setDeleteTarget(song)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.data ? (
|
||||
<Pagination
|
||||
page={state.data.page ?? filters.page}
|
||||
pageSize={state.data.pageSize ?? PAGE_SIZE}
|
||||
total={state.data.total ?? 0}
|
||||
disabled={state.loading}
|
||||
onChange={(page) => setFilters((previous) => ({ ...previous, page }))}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{editingSong !== undefined ? (
|
||||
<SongFormModal song={editingSong} onClose={() => setEditingSong(undefined)} onSaved={onSaved} />
|
||||
) : null}
|
||||
|
||||
{audioTarget ? (
|
||||
<AudioUploadModal song={audioTarget} onClose={() => setAudioTarget(null)} onSaved={onSaved} />
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除曲目"
|
||||
danger
|
||||
busy={deleting}
|
||||
confirmText="删除"
|
||||
message={
|
||||
<>
|
||||
<p>
|
||||
确认删除曲目 <strong>{deleteTarget.title}</strong>(#{deleteTarget.id})?
|
||||
</p>
|
||||
<p className="muted small">DELETE /api/songs/{deleteTarget.id} · 服务端执行软删除。</p>
|
||||
</>
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"useDefineForClassFields": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"outDir": "./node_modules/.tmp/tsconfig.node",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const API_TARGET = 'http://localhost:3000';
|
||||
|
||||
// base './' 让产物可挂在任意子路径;开发期 /api 与 /health 代理到 NestJS 服务。
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
// VRM 预览分包内是 three.js 本体(约 750 kB),属于预期体积。
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user