import { Link } from 'react-router-dom'; import { apiGet } from '../api/client'; import { getCatalogVersion, getHealth } from '../api/endpoints'; import { PUBLISH_STATUSES, type ContractVersion, type Health, type Page, type PublishStatus, } from '../api/types'; import { STATUS_LABELS } from '../lib/labels'; import { formatDateTime } from '../lib/format'; import { settle, useAsync, type Settled } from '../lib/useAsync'; import { Empty, ErrorBox, Loading } from '../components/ui'; type StatusCounts = Record; interface KindRow { key: 'songs' | 'motions' | 'models'; label: string; path: string; } const KINDS: KindRow[] = [ { key: 'songs', label: '曲目 songs', path: '/songs' }, { key: 'motions', label: '动作 motions', path: '/motions' }, { key: 'models', label: '模型 models', path: '/models' }, ]; /** 每个状态各发一次 pageSize=1 的请求,用 total 求和;单个失败不影响其他。 */ async function fetchStatusCounts(kind: string): Promise { const results = await Promise.all( PUBLISH_STATUSES.map(async (status) => { try { const page = await apiGet>(`/api/${kind}`, { page: 1, pageSize: 1, status }); const total = typeof page.total === 'number' ? page.total : 0; return [status, total] as const; } catch { return [status, null] as const; } }), ); const counts: StatusCounts = { draft: null, pending: null, published: null, rejected: null }; for (const [status, value] of results) counts[status] = value; return counts; } function sumCounts(counts: StatusCounts): number | null { let total = 0; for (const status of PUBLISH_STATUSES) { const value = counts[status]; if (value === null) return null; total += value; } return total; } interface DashboardData { health: Settled; version: Settled; counts: Record; } export function DashboardPage() { const state = useAsync(async () => { const [health, version, songs, motions, models] = await Promise.all([ settle(getHealth()), settle(getCatalogVersion()), fetchStatusCounts('songs'), fetchStatusCounts('motions'), fetchStatusCounts('models'), ]); return { health, version, counts: { songs, motions, models } }; }, []); if (state.loading && !state.data) return ; if (state.error && !state.data) return ; if (!state.data) return ; const { health, version, counts } = state.data; const unreachable = !health.ok; const healthValue = health.value; return (

概览

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

{unreachable ? (
⚠ API 服务不可达 {health.error ?? '未知错误'} 管理端默认请求同源 /api 与 /health:开发期由 vite 代理到 http://localhost:3000,生产期由 nginx 反代到 http://api:3000。 请确认服务端已启动,或通过 VITE_API_BASE 指定其他基址。
) : null} {state.error ? : null}
{KINDS.map((kind) => { const kindCounts = counts[kind.key]; const total = sumCounts(kindCounts); return (

{kind.label}

进入管理 →
{total === null ? — : total} 条记录
    {PUBLISH_STATUSES.map((status) => (
  • ))}
{total === null ?

部分状态统计失败(服务不可达或鉴权失败)

: null}
); })}

契约版本 / 目录指纹

GET /api/catalog/version
{version.ok && version.value ? ( <>
{Object.entries(version.value.contractVersion ?? {}).map(([key, value]) => (
{key} {value}
))}
revision(目录 sha256) {version.value.revision || '—'}
) : ( )}

服务健康

GET /health
{health.ok && healthValue ? (
status {healthValue.status}
version {healthValue.version}
uptime {Math.round(healthValue.uptimeSec ?? 0)} s
database {healthValue.database?.connected ? 'connected' : 'disconnected'} {healthValue.database?.server ? ` · ${healthValue.database.server}` : ''}
迁移待执行 {healthValue.database?.migrationsPending ?? 0}
) : ( )}

最后刷新:{formatDateTime(new Date().toISOString())}

); }