Files
opendance-web/web-server/admin/src/pages/Dashboard.tsx
T

210 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}