feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation

Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
This commit is contained in:
2026-06-03 11:40:21 +08:00
parent 7c43b44c57
commit e72bc061c5
5487 changed files with 979207 additions and 6197 deletions

View File

@@ -0,0 +1,50 @@
import React from "react";
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
import { ModuleGuard } from "@/components/ModuleGuard";
import { OverviewPage } from "./pages/OverviewPage";
import { TrainingSubmitPage } from "./pages/TrainingSubmitPage";
import { TrainingRecordsPage } from "./pages/TrainingRecordsPage";
import { EvaluationPage } from "./pages/EvaluationPage";
import { PromotionPage } from "./pages/PromotionPage";
import { DatasetVersionsPage } from "./pages/DatasetVersionsPage";
const TABS = [
{ to: "/models/overview", label: "模型概览", perm: "read:jobs" },
{ to: "/models/datasets", label: "数据集版本", perm: "read:jobs" },
{ to: "/models/training/submit", label: "训练提交", perm: "write:approval_submit" },
{ to: "/models/training/records", label: "训练记录", perm: "read:jobs" },
{ to: "/models/evaluation", label: "评估管理", perm: "read:jobs" },
{ to: "/models/promotion", label: "模型晋级", perm: "write:approval_submit" },
];
export const ModelsShell: React.FC = () => {
const { path } = useRouteMatch();
return (
<ModuleGuard requiredPerms={["read:jobs", "write:approval_submit"]}>
<div>
<nav className="module-tabs">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className="module-tab"
activeClassName="active"
>
{tab.label}
</NavLink>
))}
</nav>
<Switch>
<Route exact path={`${path}`} render={() => <Redirect to="/models/overview" />} />
<Route path={`${path}/overview`} component={OverviewPage} />
<Route path={`${path}/datasets`} component={DatasetVersionsPage} />
<Route path={`${path}/training/submit`} component={TrainingSubmitPage} />
<Route path={`${path}/training/records`} component={TrainingRecordsPage} />
<Route path={`${path}/evaluation`} component={EvaluationPage} />
<Route path={`${path}/promotion`} component={PromotionPage} />
</Switch>
</div>
</ModuleGuard>
);
};

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
type VersionEntry = Record<string, unknown>;
export const DatasetVersionsPage: React.FC = () => {
const [versions, setVersions] = useState<VersionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [project, setProject] = useState("dms");
const [diffA, setDiffA] = useState("");
const [diffB, setDiffB] = useState("");
const [diffResult, setDiffResult] = useState<Record<string, unknown> | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const filtered = versions.filter((v) => !search || String(v.description || v.version_id || "").toLowerCase().includes(search.toLowerCase()));
const load = useCallback(async () => {
setLoading(true); setError(null);
try {
const res = await hsapApi.listDatasetVersions(project);
setVersions((res.items || []) as VersionEntry[]);
} catch (e) { setError(String(e)); }
setLoading(false);
}, [project]);
useEffect(() => { load(); }, [load]);
const handleDiff = async () => {
if (!diffA || !diffB) return;
try {
const res = await hsapApi.diffDatasetVersions(diffA, diffB, project);
setDiffResult(res);
} catch (e) { setError(String(e)); }
};
return (
<div className="page-container">
<div className="page-header flex items-center justify-between">
<div>
<h1></h1>
<p>build </p>
</div>
<div className="flex gap-2">
<select className="form-input w-auto" value={project} onChange={(e) => setProject(e.target.value)}>
<option value="dms">DMS</option>
<option value="lane">Lane</option>
</select>
<Button variant="default" size="small" onClick={load}></Button>
</div>
</div>
<div className="flex gap-2 mb-3">
<input className="form-input w-48" placeholder="搜索版本描述..." value={search} onChange={(e) => setSearch(e.target.value)} />
<span className="text-xs text-gray-400 self-center"> {filtered.length} </span>
</div>
{/* Version diff */}
<div className="card mb-4">
<div className="card-header"></div>
<div className="flex gap-2 items-end">
<div className="form-group flex-1">
<label className="form-label"> A ()</label>
<select className="form-input" value={diffA} onChange={(e) => setDiffA(e.target.value)}>
<option value="">...</option>
{versions.map((v) => <option key={v._id as string} value={v._id as string}>{v.version_id as string} - {v.description as string || "无描述"}</option>)}
</select>
</div>
<div className="form-group flex-1">
<label className="form-label"> B ()</label>
<select className="form-input" value={diffB} onChange={(e) => setDiffB(e.target.value)}>
<option value="">...</option>
{versions.map((v) => <option key={v._id as string} value={v._id as string}>{v.version_id as string} - {v.description as string || "无描述"}</option>)}
</select>
</div>
<Button variant="default" onClick={handleDiff} disabled={!diffA || !diffB}></Button>
</div>
{diffResult && (
<div className="mt-4 bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-3 gap-4 mb-3 text-sm">
<div>
<span className="font-semibold">{(diffResult.v1 as Record<string,unknown>)?.id as string || "—"}</span>
<p className="text-xs text-gray-500">: {(diffResult.v1 as Record<string,unknown>)?.total as number || 0}</p>
</div>
<div className="text-center text-2xl text-gray-400"></div>
<div>
<span className="font-semibold">{(diffResult.v2 as Record<string,unknown>)?.id as string || "—"}</span>
<p className="text-xs text-gray-500">: {(diffResult.v2 as Record<string,unknown>)?.total as number || 0}</p>
</div>
</div>
{diffResult.image_delta != null && (
<p className="text-sm mb-2">
: <span className={Number(diffResult.image_delta) >= 0 ? "text-green-600" : "text-red-600"}>
{Number(diffResult.image_delta) >= 0 ? "+" : ""}{diffResult.image_delta as number}
</span>
</p>
)}
{((diffResult.added_packs as string[])?.length > 0) && (
<p className="text-sm text-green-600">+ : {(diffResult.added_packs as string[]).join(", ")}</p>
)}
{((diffResult.removed_packs as string[])?.length > 0) && (
<p className="text-sm text-red-600">- : {(diffResult.removed_packs as string[]).join(", ")}</p>
)}
{((diffResult.pack_changes as unknown[])?.length > 0) && (
<div className="mt-2">
<p className="text-xs font-semibold text-gray-500 mb-1">:</p>
{(diffResult.pack_changes as Record<string, unknown>[]).map((pc, i) => (
<div key={i} className="text-xs text-gray-600 ml-2">
{pc.pack as string}: train {(pc.v1 as Record<string,number>).train}{(pc.v2 as Record<string,number>).train}, val {(pc.v1 as Record<string,number>).val}{(pc.v2 as Record<string,number>).val}
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Version list */}
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无数据集版本,请创建第一个快照">
<div className="space-y-4">
{filtered.map((v) => {
const summary = v.summary as Record<string, unknown> | undefined;
const diff = v.diff as Record<string, unknown> | undefined;
const isExpanded = expandedId === v._id;
return (
<div key={v._id as string} className="card">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<Badge variant="info">{v.version_id as string}</Badge>
<span className="text-sm font-medium">{v.description as string || "无描述"}</span>
{(v.parent_version as string) && (
<span className="text-xs text-gray-400">
<span className="font-mono">{v.parent_version as string}</span>
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span>{v.created_at as string}</span>
<span>{v.author as string || "—"}</span>
<button onClick={() => setExpandedId(isExpanded ? null : (v._id as string))}
className="text-blue-600 hover:underline">{isExpanded ? "收起" : "详情"}</button>
</div>
</div>
{/* Summary stats */}
<div className="flex gap-4 text-sm">
<span>📦 {summary?.packs_count as number || 0} </span>
<span>🖼 {summary?.total_images as number || 0} </span>
<span>🏷 {summary?.total_labels as number || 0} </span>
<span>📋 {summary?.batches_count as number || 0} </span>
</div>
{/* Expanded detail */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-semibold text-xs text-gray-500 mb-1"></p>
{Object.entries((v.packs || {}) as Record<string, Record<string, number>>).map(([name, info]) => (
<div key={name} className="text-xs font-mono text-gray-600">
{name}: train={info.train_images} val={info.val_images} test={info.test_images}
</div>
))}
</div>
<div>
<p className="font-semibold text-xs text-gray-500 mb-1"></p>
<div className="flex flex-wrap gap-1">
{((v.batches || []) as string[]).map((b) => (
<Badge key={b} size="small">{b}</Badge>
))}
</div>
{diff && (
<div className="mt-3">
<p className="font-semibold text-xs text-gray-500 mb-1"></p>
{((diff.added_packs as string[])?.length > 0) && (
<p className="text-xs text-green-600">+ : {(diff.added_packs as string[]).join(", ")}</p>
)}
{((diff.removed_packs as string[])?.length > 0) && (
<p className="text-xs text-red-600">- : {(diff.removed_packs as string[]).join(", ")}</p>
)}
{((diff.added_batches as string[])?.length > 0) && (
<p className="text-xs text-green-600">+ : {(diff.added_batches as string[]).join(", ")}</p>
)}
</div>
)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link to="/labeling/catalog" className="text-blue-600 text-xs hover:underline"> </Link>
<Link to="/models/training/records" className="text-blue-600 text-xs hover:underline"> </Link>
</div>
</div>
)}
</div>
);
})}
</div>
</PageQueryState>
</div>
);
};

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState, useMemo } from "react";
import { hsapApi } from "@/app/hsap-api";
import { StatusBadge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
import { ListPaginationBar } from "@/components/ListPaginationBar";
type EvalRecord = Record<string, unknown>;
export const EvaluationPage: React.FC = () => {
const [records, setRecords] = useState<EvalRecord[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const load = async (newOffset = 0, newLimit = 20) => {
setLoading(true); setError(null);
try {
const res = await hsapApi.listTrainingRecords({ offset: newOffset, limit: newLimit });
const all = (res.items || []) as EvalRecord[];
const evalRecs = all.filter((r) => {
const a = (r.action as string || "").toLowerCase();
return a.includes("eval") || a.includes("promote");
});
setRecords(evalRecs);
setTotal(evalRecs.length);
setOffset(newOffset);
setLimit(newLimit);
} catch (e) { setError(String(e)); }
setLoading(false);
};
useEffect(() => { load(); }, []);
// Extract metric data for chart
const metricData = useMemo(() => {
const data: { label: string; map50: number; map50_95: number }[] = [];
for (const r of records) {
const result = r.result as Record<string, unknown> | undefined;
const metrics = result?.metrics as Record<string, number> | undefined;
if (metrics?.map50 != null) {
data.push({
label: `${r.task || "?"}/${r.pack || "?"}`.slice(0, 24),
map50: metrics.map50,
map50_95: metrics.map50_95 ?? 0,
});
}
}
return data;
}, [records]);
const maxMap50 = Math.max(...metricData.map((d) => d.map50), 0.01);
return (
<div className="page-container">
<div className="page-header">
<h1></h1>
<p></p>
</div>
{/* mAP comparison bar chart */}
{metricData.length > 0 && (
<div className="card mb-4">
<div className="card-header">mAP </div>
<div className="space-y-2">
{metricData.map((d, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="w-32 truncate font-mono text-xs text-gray-500" title={d.label}>{d.label}</span>
<div className="flex-1 flex items-center gap-1">
<div className="flex-1 h-5 bg-gray-100 rounded relative overflow-hidden">
<div className="absolute inset-y-0 left-0 bg-blue-600 rounded" style={{ width: `${(d.map50 / maxMap50) * 100}%` }} />
</div>
<span className="w-14 text-right font-mono text-xs">{d.map50.toFixed(3)}</span>
</div>
{d.map50_95 > 0 && (
<div className="flex items-center gap-1 flex-1">
<span className="text-[10px] text-gray-400 w-16 text-right">mAP50-95</span>
<div className="flex-1 h-4 bg-gray-100 rounded relative overflow-hidden">
<div className="absolute inset-y-0 left-0 bg-green-500 rounded" style={{ width: `${(d.map50_95 / maxMap50) * 100}%` }} />
</div>
<span className="w-14 text-right font-mono text-xs">{d.map50_95.toFixed(3)}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Evaluation record list */}
<PageQueryState loading={loading} error={error} empty={records.length === 0} emptyMessage="暂无评估记录">
<div className="card overflow-hidden">
<table className="table-auto">
<thead>
<tr>
<th className="w-8" />
<th>Job ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>mAP@50</th>
<th>mAP@50-95</th>
<th></th>
</tr>
</thead>
<tbody>
{records.map((r) => {
const rid = r.id as string;
const result = r.result as Record<string, unknown> | undefined;
const metrics = result?.metrics as Record<string, number> | undefined;
return (
<React.Fragment key={rid}>
<tr className={expandedId === rid ? "bg-blue-50/50" : ""}>
<td>
<button onClick={() => setExpandedId(expandedId === rid ? null : rid)} className="text-gray-400 hover:text-blue-600 text-xs px-1">
{expandedId === rid ? "▼" : "▶"}
</button>
</td>
<td className="font-mono text-xs">{rid.slice(0, 16)}...</td>
<td>{r.project as string || "—"}</td>
<td>{r.task as string || "—"}</td>
<td>{r.action as string || "—"}</td>
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
<td className="font-mono text-xs">{metrics?.map50?.toFixed(4) || "—"}</td>
<td className="font-mono text-xs">{metrics?.map50_95?.toFixed(4) || "—"}</td>
<td className="text-xs text-gray-500">{r.created_at as string || "—"}</td>
</tr>
{expandedId === rid && (
<tr>
<td colSpan={9} className="bg-gray-50 p-4">
{result ? (
<pre className="text-xs bg-white border rounded p-3 max-h-48 overflow-auto">{JSON.stringify(result, null, 2)}</pre>
) : <p className="text-gray-400 text-sm"></p>}
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
</div>
</PageQueryState>
</div>
);
};

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { hsapApi } from "@/app/hsap-api";
import { Badge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
import { Button } from "@/components/ui/Button";
type ModelEntry = { name?: string; version?: string; task?: string; project?: string; metrics?: Record<string, number>; status?: string; created_at?: string };
export const OverviewPage: React.FC = () => {
const [registry, setRegistry] = useState<Record<string, unknown> | null>(null);
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([
hsapApi.getModelRegistry("dms"),
hsapApi.listTrainingRecords({ limit: 5 }),
])
.then(([reg, rec]) => { setRegistry(reg); setRecords((rec.items || []) as Record<string, unknown>[]); })
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
const models = (registry?.models || []) as ModelEntry[];
const activePacks = (registry?.active_packs || []) as string[];
const completedCount = records.filter((r) => r.status === "completed").length;
const runningCount = records.filter((r) => r.status === "running").length;
return (
<div className="page-container">
<div className="page-header">
<h1></h1>
<p></p>
</div>
<PageQueryState loading={loading} error={error}>
{/* KPI cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="card text-center">
<div className="text-3xl font-bold text-blue-700">{models.length || "—"}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-green-600">{completedCount}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-orange-600">{runningCount}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-gray-600">{activePacks.length || "—"}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
</div>
{/* Model registry */}
<div className="card mb-4">
<div className="card-header"></div>
{models.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<table className="table-auto">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>mAP@50</th>
<th>mAP@50-95</th>
<th></th>
</tr>
</thead>
<tbody>
{models.map((m, i) => (
<tr key={m.name || i}>
<td className="font-medium">{m.name || m.version || `模型 #${i + 1}`}</td>
<td>{m.task || "—"}</td>
<td className="font-mono text-xs">{m.version || "—"}</td>
<td className="font-mono">{m.metrics?.map50?.toFixed(4) || "—"}</td>
<td className="font-mono">{m.metrics?.map50_95?.toFixed(4) || "—"}</td>
<td><Badge variant={m.status === "production" ? "success" : "info"}>{m.status || "experiment"}</Badge></td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Recent training records */}
<div className="card">
<div className="card-header flex items-center justify-between">
<span></span>
<Link to="/models/training/records" className="text-blue-600 text-sm hover:underline"> </Link>
</div>
{records.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<table className="table-auto">
<thead>
<tr><th>Job ID</th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{records.slice(0, 5).map((r) => (
<tr key={r.id as string}>
<td className="font-mono text-xs">{(r.id as string).slice(0, 16)}...</td>
<td>{r.action as string}</td>
<td><Badge variant={r.status === "completed" ? "success" : r.status === "failed" ? "danger" : "warning"}>{r.status as string}</Badge></td>
<td className="text-xs text-gray-500">{r.created_at as string}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Quick actions */}
<div className="flex gap-2 mt-4">
<Link to="/models/training/submit"><Button variant="primary" size="small"></Button></Link>
<Link to="/models/evaluation"><Button variant="default" size="small"></Button></Link>
<Link to="/models/promotion"><Button variant="default" size="small"></Button></Link>
</div>
</PageQueryState>
</div>
);
};

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from "react";
import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
import { StatusBadge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
type ModelEntry = { name?: string; version?: string; task?: string; metrics?: Record<string, number>; status?: string };
export const PromotionPage: React.FC = () => {
const [models, setModels] = useState<ModelEntry[]>([]);
const [history, setHistory] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [project, setProject] = useState("dms");
const [task, setTask] = useState("");
const [version, setVersion] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<string | null>(null);
const load = async () => {
setLoading(true); setError(null);
try {
const [reg, rec] = await Promise.all([
hsapApi.getModelRegistry(project, task || undefined),
hsapApi.listTrainingRecords({ project, limit: 20 }),
]);
setModels((reg.models || []) as ModelEntry[]);
setHistory(((rec.items || []) as Record<string, unknown>[]).filter((r) => {
const a = (r.action as string || "").toLowerCase();
return a.includes("promote");
}));
} catch (e) { setError(String(e)); }
setLoading(false);
};
useEffect(() => { load(); }, [project]);
const handleSubmit = async () => {
if (!version.trim()) { setError("请选择或输入模型版本"); return; }
setSubmitting(true); setError(null); setResult(null);
try {
const res = await hsapApi.createTrainingRecord(`promote_${project}`, { project, task: task.trim(), version: version.trim() }, note.trim() || undefined) as Record<string, unknown>;
setResult(`晋级申请已提交 · Job: ${res.id as string}`);
load();
} catch (e) { setError(String(e)); }
setSubmitting(false);
};
return (
<div className="page-container">
<div className="page-header">
<h1></h1>
<p></p>
</div>
<PageQueryState loading={loading} error={error}>
<div className="grid grid-cols-2 gap-6">
{/* Left: Submit form */}
<div className="card">
<div className="card-header"></div>
<div className="form-group">
<label className="form-label"></label>
<select className="form-input" value={project} onChange={(e) => { setProject(e.target.value); setVersion(""); }}>
<option value="dms">DMS</option>
<option value="lane">Lane</option>
</select>
</div>
<div className="form-group">
<label className="form-label"></label>
<input className="form-input" value={task} onChange={(e) => setTask(e.target.value)} placeholder="如 ddaw, dam" />
</div>
<div className="form-group">
<label className="form-label"> *</label>
<select className="form-input" value={version} onChange={(e) => setVersion(e.target.value)}>
<option value="">...</option>
{models.filter((m) => !task || m.task === task).map((m, i) => (
<option key={i} value={m.version || m.name || ""}>
{m.version || m.name || `#${i}`} {m.metrics?.map50 != null ? `(mAP50=${m.metrics.map50.toFixed(3)})` : ""}
</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1"></p>
<input className="form-input mt-1" value={version} onChange={(e) => setVersion(e.target.value)} placeholder="如 v2.1.0" />
</div>
<div className="form-group">
<label className="form-label"></label>
<textarea className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="说明晋级原因(可选)" rows={3} />
</div>
{error && <div className="bg-red-50 border border-red-200 rounded p-3 mb-3 text-sm text-red-700">{error}</div>}
{result && <div className="bg-green-50 border border-green-200 rounded p-3 mb-3 text-sm text-green-700">{result}</div>}
<Button variant="primary" onClick={handleSubmit} loading={submitting}></Button>
</div>
{/* Right: Promotion history */}
<div className="card">
<div className="card-header"></div>
{history.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<table className="table-auto">
<thead>
<tr><th>Job ID</th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{history.map((r) => {
const params = r.params as Record<string, unknown> | undefined;
return (
<tr key={r.id as string}>
<td className="font-mono text-xs">{(r.id as string).slice(0, 12)}...</td>
<td>{params?.task as string || "—"}</td>
<td className="font-mono text-xs">{params?.version as string || "—"}</td>
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
<td className="text-xs text-gray-500">{r.created_at as string}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* Candidate models */}
{models.length > 0 && (
<div className="card mt-4">
<div className="card-header"> ({project})</div>
<table className="table-auto">
<thead>
<tr><th></th><th></th><th>mAP@50</th><th>mAP@50-95</th><th></th><th></th></tr>
</thead>
<tbody>
{models.filter((m) => !task || m.task === task).map((m, i) => (
<tr key={i}>
<td className="font-mono text-xs font-medium">{m.version || m.name || "—"}</td>
<td>{m.task || "—"}</td>
<td className="font-mono text-xs">{m.metrics?.map50?.toFixed(4) || "—"}</td>
<td className="font-mono text-xs">{m.metrics?.map50_95?.toFixed(4) || "—"}</td>
<td>{m.status || "experiment"}</td>
<td>
<Button size="small" variant="primary" onClick={() => { setVersion(m.version || m.name || ""); }}>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageQueryState>
</div>
);
};

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useState, useCallback } from "react";
import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
import { StatusBadge, Badge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
import { ListPaginationBar } from "@/components/ListPaginationBar";
type TrainingRecord = Record<string, unknown>;
export const TrainingRecordsPage: React.FC = () => {
const [records, setRecords] = useState<TrainingRecord[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [project, setProject] = useState("");
const [status, setStatus] = useState("");
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailCache, setDetailCache] = useState<Record<string, TrainingRecord | null>>({});
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
setLoading(true);
setError(null);
try {
const res = await hsapApi.listTrainingRecords({
project: project || undefined,
status: status || undefined,
offset: newOffset,
limit: newLimit,
});
setRecords((res.items || []) as TrainingRecord[]);
setTotal(res.total);
setOffset(newOffset);
setLimit(newLimit);
} catch (e) {
setError(String(e));
}
setLoading(false);
}, [project, status]);
useEffect(() => { load(); }, [load]);
// Auto-refresh every 15 seconds
useEffect(() => {
const t = setInterval(() => load(offset, limit), 15000);
return () => clearInterval(t);
}, [load, offset, limit]);
const handleToggleDetail = async (jobId: string) => {
if (expandedId === jobId) { setExpandedId(null); return; }
setExpandedId(jobId);
if (!detailCache[jobId]) {
try {
const detail = await hsapApi.getTrainingRecord(jobId);
setDetailCache((prev) => ({ ...prev, [jobId]: detail }));
} catch {
setDetailCache((prev) => ({ ...prev, [jobId]: null }));
}
}
};
const detail = expandedId ? detailCache[expandedId] : null;
return (
<div className="page-container">
<div className="page-header flex items-center justify-between">
<div>
<h1></h1>
<p>/ 15 </p>
</div>
<Button size="small" variant="default" onClick={() => load(offset, limit)}></Button>
</div>
{/* Filters */}
<div className="flex gap-2 mb-4 flex-wrap">
<input className="form-input w-44" placeholder="搜索 Job ID/任务..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
<select className="form-input w-auto" value={project} onChange={(e) => { setProject(e.target.value); setOffset(0); }}>
<option value=""></option>
<option value="dms">DMS</option>
<option value="lane">Lane</option>
</select>
<select className="form-input w-auto" value={status} onChange={(e) => { setStatus(e.target.value); setOffset(0); }}>
<option value=""></option>
<option value="pending"></option>
<option value="approved"></option>
<option value="running"></option>
<option value="completed"></option>
<option value="failed"></option>
</select>
</div>
<PageQueryState loading={loading} error={error} empty={records.length === 0} emptyMessage="暂无训练记录">
<div className="card overflow-hidden">
<table className="table-auto">
<thead>
<tr>
<th className="w-10" />
<th>Job ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{records.map((r) => {
const rid = r.id as string;
const isExpanded = expandedId === rid;
return (
<React.Fragment key={rid}>
<tr className={isExpanded ? "bg-blue-50/50" : ""}>
<td>
<button onClick={() => handleToggleDetail(rid)} className="text-gray-400 hover:text-blue-600 text-xs px-1">
{isExpanded ? "▼" : "▶"}
</button>
</td>
<td className="font-mono text-xs">{rid.slice(0, 16)}...</td>
<td>{r.project as string || "—"}</td>
<td>{r.task as string || "—"}</td>
<td><Badge variant="info">{(r.action as string || "—").replace(/_/g, " ")}</Badge></td>
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
<td className="text-xs font-mono">{r.pack as string || "—"}</td>
<td className="text-xs text-gray-500 whitespace-nowrap">{r.created_at as string || "—"}</td>
</tr>
{isExpanded && (
<tr key={`${rid}-detail`}>
<td colSpan={8} className="bg-gray-50 p-4">
{detail === undefined ? (
<p className="text-gray-400 text-sm">...</p>
) : detail === null ? (
<p className="text-red-400 text-sm"></p>
) : (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-semibold mb-2 text-gray-700"></p>
<pre className="text-xs bg-white border rounded p-3 max-h-48 overflow-auto">
{JSON.stringify(detail.params || {}, null, 2)}
</pre>
</div>
<div>
<p className="font-semibold mb-2 text-gray-700"></p>
{detail.result ? (
<pre className="text-xs bg-white border rounded p-3 max-h-48 overflow-auto">
{JSON.stringify(detail.result, null, 2)}
</pre>
) : (
<p className="text-xs text-gray-400">
{detail.status === "running" ? "执行中..." : detail.status === "failed" ? `失败: ${detail.error || "未知错误"}` : "暂无结果"}
</p>
)}
</div>
{detail.note != null && String(detail.note) && (
<div className="col-span-2">
<p className="font-semibold mb-1 text-gray-700"></p>
<p className="text-sm text-gray-600">{String(detail.note ?? "")}</p>
</div>
)}
</div>
)}
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
<ListPaginationBar total={total} offset={offset} limit={limit}
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
</div>
</PageQueryState>
</div>
);
};

View File

@@ -0,0 +1,95 @@
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
const ACTION_LABELS: Record<string, string> = {
train_dms: "DMS 训练", train_lane: "Lane 训练",
eval_dms: "DMS 评估", eval_lane: "Lane 评估",
promote_dms: "DMS 晋级", promote_lane: "Lane 晋级",
pipeline_dms: "DMS 全流程",
};
export const TrainingSubmitPage: React.FC = () => {
const [action, setAction] = useState("train_dms");
const [project, setProject] = useState("dms");
const [task, setTask] = useState("");
const [pack, setPack] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const e: Record<string, string> = {};
if (!task.trim()) e.task = "请输入任务名称";
if (["train_dms", "train_lane", "eval_dms", "eval_lane"].includes(action) && !pack.trim()) e.pack = "请输入数据包名称";
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
setError(null);
setResult(null);
try {
const res = await hsapApi.createTrainingRecord(action, { project, task: task.trim(), pack: pack.trim() }, note.trim() || undefined) as Record<string, unknown>;
setResult(res);
} catch (e) {
setError(String(e));
}
setSubmitting(false);
};
return (
<div className="page-container">
<div className="page-header">
<h1></h1>
<p>/</p>
</div>
<div className="card max-w-lg">
<div className="form-group">
<label className="form-label"> *</label>
<select className="form-input" value={action} onChange={(e) => { setAction(e.target.value); setErrors({}); }}>
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{k} {v}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label"> *</label>
<select className="form-input" value={project} onChange={(e) => setProject(e.target.value)}>
<option value="dms">DMS</option>
<option value="lane">Lane</option>
</select>
</div>
<div className="form-group">
<label className="form-label"> *</label>
<input className={`form-input ${errors.task ? "border-red-500" : ""}`} value={task} onChange={(e) => { setTask(e.target.value); setErrors((p) => ({ ...p, task: "" })); }} placeholder="如 ddaw, dam, lane_v1" />
{errors.task && <p className="text-red-500 text-xs mt-1">{errors.task}</p>}
</div>
<div className="form-group">
<label className="form-label"></label>
<input className={`form-input ${errors.pack ? "border-red-500" : ""}`} value={pack} onChange={(e) => { setPack(e.target.value); setErrors((p) => ({ ...p, pack: "" })); }} placeholder="如 dms_v1" />
{errors.pack && <p className="text-red-500 text-xs mt-1">{errors.pack}</p>}
</div>
<div className="form-group">
<label className="form-label"></label>
<textarea className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="可选备注信息" rows={3} />
</div>
{error && <div className="bg-red-50 border border-red-200 rounded p-3 mb-3 text-sm text-red-700">{error}</div>}
{result && (
<div className="bg-green-50 border border-green-200 rounded p-3 mb-3">
<p className="text-green-700 text-sm font-medium"></p>
<p className="text-xs text-gray-500 mt-1">Job ID: {result.id as string}</p>
<Link to="/models/training/records" className="text-blue-600 text-xs hover:underline mt-1 inline-block"> </Link>
</div>
)}
<Button variant="primary" onClick={handleSubmit} loading={submitting}></Button>
</div>
</div>
);
};