feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览)

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 0f66b53480
commit 640736c77b
30 changed files with 6704 additions and 0 deletions
+209
View File
@@ -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>
);
}