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:
50
platform/web/src/modules/models/ModelsShell.tsx
Normal file
50
platform/web/src/modules/models/ModelsShell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
204
platform/web/src/modules/models/pages/DatasetVersionsPage.tsx
Normal file
204
platform/web/src/modules/models/pages/DatasetVersionsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
150
platform/web/src/modules/models/pages/EvaluationPage.tsx
Normal file
150
platform/web/src/modules/models/pages/EvaluationPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
platform/web/src/modules/models/pages/OverviewPage.tsx
Normal file
128
platform/web/src/modules/models/pages/OverviewPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
154
platform/web/src/modules/models/pages/PromotionPage.tsx
Normal file
154
platform/web/src/modules/models/pages/PromotionPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
178
platform/web/src/modules/models/pages/TrainingRecordsPage.tsx
Normal file
178
platform/web/src/modules/models/pages/TrainingRecordsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
95
platform/web/src/modules/models/pages/TrainingSubmitPage.tsx
Normal file
95
platform/web/src/modules/models/pages/TrainingSubmitPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user