210 lines
7.6 KiB
TypeScript
210 lines
7.6 KiB
TypeScript
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>
|
||
);
|
||
}
|