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:
47
platform/web/src/modules/fleet/FleetShell.tsx
Normal file
47
platform/web/src/modules/fleet/FleetShell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { VehiclesPage } from "./pages/VehiclesPage";
|
||||
import { LiveMapPage } from "./pages/LiveMapPage";
|
||||
import { TripRecordsPage } from "./pages/TripRecordsPage";
|
||||
import { TboxConfigPage } from "./pages/TboxConfigPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/fleet/dashboard", label: "车队总览", perm: "read:fleet" },
|
||||
{ to: "/fleet/vehicles", label: "车辆管理", perm: "read:fleet" },
|
||||
{ to: "/fleet/map", label: "实时地图", perm: "read:fleet" },
|
||||
{ to: "/fleet/trips", label: "行程记录", perm: "read:fleet" },
|
||||
{ to: "/fleet/tbox", label: "T-Box 配置", perm: "write:fleet" },
|
||||
];
|
||||
|
||||
export const FleetShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:fleet"]}>
|
||||
<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="/fleet/dashboard" />} />
|
||||
<Route path={`${path}/dashboard`} component={DashboardPage} />
|
||||
<Route path={`${path}/vehicles`} component={VehiclesPage} />
|
||||
<Route path={`${path}/map`} component={LiveMapPage} />
|
||||
<Route path={`${path}/trips`} component={TripRecordsPage} />
|
||||
<Route path={`${path}/tbox`} component={TboxConfigPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
57
platform/web/src/modules/fleet/pages/DashboardPage.tsx
Normal file
57
platform/web/src/modules/fleet/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [summary, setSummary] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hsapApi.fleetSummary()
|
||||
.then(setSummary)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(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-4 gap-4 mb-6">
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-blue-700">{String(summary?.active_vehicles ?? "—")}</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">{String(summary?.active_runs ?? "—")}</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">{String(summary?.total_vehicles ?? "—")}</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">{String(summary?.total_runs ?? "—")}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">总行程数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">快捷操作</div>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/fleet/map" className="text-blue-600 text-sm hover:underline">查看实时地图 →</Link>
|
||||
<Link to="/fleet/vehicles" className="text-blue-600 text-sm hover:underline">管理车辆 →</Link>
|
||||
<Link to="/fleet/trips" className="text-blue-600 text-sm hover:underline">行程记录 →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
platform/web/src/modules/fleet/pages/LiveMapPage.tsx
Normal file
81
platform/web/src/modules/fleet/pages/LiveMapPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
|
||||
export const LiveMapPage: React.FC = () => {
|
||||
const [live, setLive] = useState<Record<string, unknown> | null>(null);
|
||||
const [mapConfig, setMapConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [l, m] = await Promise.all([hsapApi.fleetLive(), hsapApi.fleetMapConfig()]);
|
||||
setLive(l);
|
||||
setMapConfig(m);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const vehicles = (live?.vehicles || []) as Record<string, unknown>[];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>实时地图</h1>
|
||||
<p>车辆 GPS 实时位置追踪</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{/* Map placeholder — Leaflet/AMap will be integrated */}
|
||||
<div className="card mb-4" style={{ height: "400px", background: "#e5e7eb" }}>
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">🗺️</div>
|
||||
<p>地图组件 (Leaflet/高德)</p>
|
||||
<p className="text-xs mt-1">底图: {mapConfig?.tile_provider as string || "gaode"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">在线车辆 ({vehicles.length})</div>
|
||||
{vehicles.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无在线车辆</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>车牌号</th>
|
||||
<th>经纬度</th>
|
||||
<th>速度</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-medium">{v.plate_no as string || "—"}</td>
|
||||
<td className="font-mono text-xs">
|
||||
{String(v.lat ?? "—")}, {String(v.lng ?? "—")}
|
||||
</td>
|
||||
<td>{v.speed_kmh != null ? `${v.speed_kmh} km/h` : "—"}</td>
|
||||
<td><Badge variant="success">在线</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<button onClick={load} className="mt-3 text-sm text-blue-600 hover:underline">刷新</button>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
platform/web/src/modules/fleet/pages/TboxConfigPage.tsx
Normal file
60
platform/web/src/modules/fleet/pages/TboxConfigPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
|
||||
export const TboxConfigPage: React.FC = () => {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>T-Box 配置</h1>
|
||||
<p>T-Box 设备上报配置说明</p>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">上报接口</div>
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="font-mono text-xs">POST</td>
|
||||
<td className="font-mono text-xs">/api/v1/tbox/gps</td>
|
||||
<td>单点 GPS 上报</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="font-mono text-xs">POST</td>
|
||||
<td className="font-mono text-xs">/api/v1/tbox/gps/batch</td>
|
||||
<td>批量 GPS 上报(最多 100 点)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">认证</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
T-Box 上报需要在 HTTP Header 中携带 <code className="bg-gray-100 px-1 rounded">X-Tbox-Token</code>,
|
||||
值为环境变量 <code className="bg-gray-100 px-1 rounded">AS_TBOX_INGEST_TOKEN</code> 的值(默认: <code className="bg-gray-100 px-1 rounded">hsap-demo-tbox-token</code>)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">上报示例</div>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto">{`curl -X POST http://127.0.0.1:8787/api/v1/tbox/gps \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Tbox-Token: hsap-demo-tbox-token" \\
|
||||
-d '{
|
||||
"device_id": "TBOX-001",
|
||||
"lat": 28.2282,
|
||||
"lng": 112.9388,
|
||||
"speed_kmh": 40,
|
||||
"run_signal": "active",
|
||||
"plate_no": "湘A·采集01"
|
||||
}'`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
platform/web/src/modules/fleet/pages/TripRecordsPage.tsx
Normal file
94
platform/web/src/modules/fleet/pages/TripRecordsPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } 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";
|
||||
|
||||
export const TripRecordsPage: React.FC = () => {
|
||||
const [runs, setRuns] = useState<Record<string, unknown>[]>([]);
|
||||
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 load = async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.fleetRuns({ offset: newOffset, limit: newLimit });
|
||||
setRuns((res.items || []) as Record<string, unknown>[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleImportGpx = async (vehicleId: number) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".gpx";
|
||||
input.onchange = async (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
await hsapApi.fleetImportGpx(vehicleId, f);
|
||||
load(0, limit);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>行程记录</h1>
|
||||
<p>查看车辆采集行程历史</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={runs.length === 0} emptyMessage="暂无行程记录">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>车辆</th>
|
||||
<th>状态</th>
|
||||
<th>开始时间</th>
|
||||
<th>结束时间</th>
|
||||
<th>里程 (km)</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.id as number}>
|
||||
<td className="font-mono text-xs">{String(r.id)}</td>
|
||||
<td>{(r.plate_no as string) || `ID:${r.vehicle_id}`}</td>
|
||||
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500">{r.started_at as string || "—"}</td>
|
||||
<td className="text-xs text-gray-500">{r.ended_at as string || "—"}</td>
|
||||
<td>{r.total_km != null ? `${Number(r.total_km).toFixed(1)}` : "—"}</td>
|
||||
<td className="flex gap-2">
|
||||
<Button size="small" variant="default" onClick={() => handleImportGpx(r.vehicle_id as number)}>
|
||||
导入 GPX
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
platform/web/src/modules/fleet/pages/VehiclesPage.tsx
Normal file
121
platform/web/src/modules/fleet/pages/VehiclesPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
export const VehiclesPage: React.FC = () => {
|
||||
const [vehicles, setVehicles] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [plateNo, setPlateNo] = useState("");
|
||||
const [deviceId, setDeviceId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [team, setTeam] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.fleetVehicles();
|
||||
setVehicles((res.items || []) as Record<string, unknown>[]);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!plateNo || !deviceId) return;
|
||||
try {
|
||||
await hsapApi.fleetCreateVehicle({ plate_no: plateNo, tbox_device_id: deviceId, name: name || undefined, team: team || undefined });
|
||||
setShowForm(false);
|
||||
setPlateNo(""); setDeviceId(""); setName(""); setTeam("");
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除此车辆?")) return;
|
||||
try {
|
||||
await hsapApi.fleetDeleteVehicle(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>车辆管理</h1>
|
||||
<p>管理采集车队车辆信息</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? "取消" : "添加车辆"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card mb-4 max-w-lg">
|
||||
<div className="form-group">
|
||||
<label className="form-label">车牌号</label>
|
||||
<input className="form-input" value={plateNo} onChange={(e) => setPlateNo(e.target.value)} placeholder="如 湘A·采集01" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">T-Box 设备 ID</label>
|
||||
<input className="form-input" value={deviceId} onChange={(e) => setDeviceId(e.target.value)} placeholder="如 TBOX-001" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">车辆名称(可选)</label>
|
||||
<input className="form-input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属车队(可选)</label>
|
||||
<input className="form-input" value={team} onChange={(e) => setTeam(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleCreate}>创建车辆</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={vehicles.length === 0} emptyMessage="暂无车辆">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>车牌号</th>
|
||||
<th>T-Box</th>
|
||||
<th>名称</th>
|
||||
<th>车队</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v) => (
|
||||
<tr key={v.id as number}>
|
||||
<td>{String(v.id)}</td>
|
||||
<td className="font-medium">{v.plate_no as string}</td>
|
||||
<td className="font-mono text-xs">{v.tbox_device_id as string}</td>
|
||||
<td>{(v.name as string) || "—"}</td>
|
||||
<td>{(v.team as string) || "—"}</td>
|
||||
<td><Badge variant={v.status === "active" ? "success" : "default"}>{v.status as string}</Badge></td>
|
||||
<td>
|
||||
<Button size="small" variant="danger" onClick={() => handleDelete(v.id as number)}>删除</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
platform/web/src/modules/labeling/LabelingShell.tsx
Normal file
54
platform/web/src/modules/labeling/LabelingShell.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { WorkbenchPage } from "./pages/WorkbenchPage";
|
||||
import { CampaignsPage } from "./pages/CampaignsPage";
|
||||
import { ExportPage } from "./pages/ExportPage";
|
||||
import { DeliveriesPage } from "./pages/DeliveriesPage";
|
||||
import { CatalogPage } from "./pages/CatalogPage";
|
||||
import { QualityReviewPage } from "./pages/QualityReviewPage";
|
||||
import { SimulationStudioPage } from "./pages/SimulationStudioPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending" },
|
||||
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending" },
|
||||
{ to: "/labeling/review", label: "标注质检", perm: "write:approval_review" },
|
||||
{ to: "/labeling/export", label: "导出与入库", perm: "read:pending" },
|
||||
{ to: "/labeling/deliveries", label: "批次台账", perm: "read:deliveries" },
|
||||
{ to: "/labeling/catalog", label: "数据目录", perm: "read:catalog" },
|
||||
{ to: "/labeling/simulate", label: "仿真工坊", perm: "read:pending" },
|
||||
];
|
||||
|
||||
export const LabelingShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:pending", "read:deliveries", "read:catalog", "write:delivery_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="/labeling/workbench" />} />
|
||||
<Route path={`${path}/workbench`} component={WorkbenchPage} />
|
||||
<Route path={`${path}/review/:campaignId`} component={QualityReviewPage} />
|
||||
<Route path={`${path}/review`} component={QualityReviewPage} />
|
||||
<Route path={`${path}/campaigns`} component={CampaignsPage} />
|
||||
<Route path={`${path}/export`} component={ExportPage} />
|
||||
<Route path={`${path}/deliveries`} component={DeliveriesPage} />
|
||||
<Route path={`${path}/catalog`} component={CatalogPage} />
|
||||
<Route path={`${path}/simulate`} component={SimulationStudioPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
122
platform/web/src/modules/labeling/pages/CampaignsPage.tsx
Normal file
122
platform/web/src/modules/labeling/pages/CampaignsPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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 { StageBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
export const CampaignsPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q) || (b.campaign_id || "").toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", limit: 100 });
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
setInfo(null);
|
||||
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (campaignId: string) => {
|
||||
try { await hsapApi.submitLabelingCampaign(campaignId); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>标注进度</h1>
|
||||
<p>查看和管理进行中的标注活动</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={load}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次、任务或 Campaign ID..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无进行中的标注活动">
|
||||
<div className="space-y-3">
|
||||
{filtered.map((b) => {
|
||||
const pct = b.total_tasks && b.total_tasks > 0 ? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100) : 0;
|
||||
return (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{b.campaign_id && <span className="font-mono">{b.campaign_id.slice(0, 14)}...</span>}
|
||||
{b.assigned_to_name && <span>👤 {b.assigned_to_name}</span>}
|
||||
</div>
|
||||
{b.total_tasks != null && b.total_tasks > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden max-w-[200px]">
|
||||
<div className={`h-full rounded-full transition-all ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{b.completed_tasks}/{b.total_tasks}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && (
|
||||
<>
|
||||
<a href={`/labeling/campaigns/${encodeURIComponent(b.campaign_id)}/annotate`} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
✏️ 标注
|
||||
</a>
|
||||
<button onClick={() => handleExport(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
📤 导出
|
||||
</button>
|
||||
<button onClick={() => handleSubmit(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition-colors">
|
||||
✅ 提交
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
297
platform/web/src/modules/labeling/pages/CatalogPage.tsx
Normal file
297
platform/web/src/modules/labeling/pages/CatalogPage.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ClassCountBars } from "@/components/ClassCountBars";
|
||||
import { ClassCountPie } from "@/components/ClassCountPie";
|
||||
import { ClassCountRadar } from "@/components/ClassCountRadar";
|
||||
import { BboxScatter } from "@/components/BboxScatter";
|
||||
import { SplitCountsBars } from "@/components/SplitCountsBars";
|
||||
import {
|
||||
aggregateBboxPoints, aggregateSplitCounts, bboxPointsFromPack,
|
||||
buildDmsPackTree, dmsPacks, dmsTaskModes,
|
||||
findPackRow, isDmsCatalogScope, packTaskKey, parseCatalogScope,
|
||||
parsePackTaskKey, primaryPack, scopeKeyFromSelection, selectionFromScopeKey,
|
||||
splitCountsFromPack, type CatalogReport, type CatalogUiSelection,
|
||||
type CatalogViewKind, type DmsPackRow, type DmsTaskEntry,
|
||||
} from "@/lib/dmsCatalog";
|
||||
|
||||
const CHART_SIZE = 152;
|
||||
const SCOPE_STORAGE_KEY = "hsap.catalog.scope";
|
||||
|
||||
type DomainTab = "dms" | "forward" | "lane";
|
||||
const DOMAIN_TABS: { key: DomainTab; label: string; desc: string }[] = [
|
||||
{ key: "dms", label: "DMS 舱内", desc: "驾驶员监测数据" },
|
||||
{ key: "forward", label: "ADAS 前向", desc: "前向感知检测数据" },
|
||||
{ key: "lane", label: "车道线", desc: "Lane 检测数据" },
|
||||
];
|
||||
|
||||
const ChartCell: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/40 p-2 flex flex-col min-h-0">
|
||||
<p className="text-[11px] text-gray-500 m-0 mb-1 leading-none">{title}</p>
|
||||
<div className="flex-1 flex items-center justify-center min-h-[132px] overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function readStoredScope(fallback: string): string {
|
||||
try { return localStorage.getItem(SCOPE_STORAGE_KEY) || fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
export const CatalogPage: React.FC = () => {
|
||||
const [cat, setCat] = useState<CatalogReport | null>(null);
|
||||
const [dmsDetail, setDmsDetail] = useState<DmsTaskEntry | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
const [domain, setDomain] = useState<DomainTab>("dms");
|
||||
const [subView, setSubView] = useState<CatalogViewKind>("pack");
|
||||
const [ui, setUi] = useState<CatalogUiSelection>(() =>
|
||||
selectionFromScopeKey(readStoredScope("dms-pack:dms_v1:dam"), null)
|
||||
);
|
||||
|
||||
const scopeKey = useMemo(() => scopeKeyFromSelection(ui), [ui]);
|
||||
|
||||
const load = async (refresh = false) => {
|
||||
setLoading(true); setErr("");
|
||||
try { setCat((await hsapApi.catalog(refresh)) as CatalogReport); }
|
||||
catch (e) { setErr(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const scope = parseCatalogScope(scopeKey);
|
||||
const isPackView = scope.project === "dms-pack";
|
||||
|
||||
const loadDmsDetail = async (refresh = false) => {
|
||||
if (!isDmsCatalogScope(scope) || !scope.task) return;
|
||||
try { setDmsDetail((await hsapApi.catalogDms(scope.task, refresh)) as DmsTaskEntry); }
|
||||
catch { setDmsDetail(null); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { if (!cat) return; setUi((prev) => { const next = selectionFromScopeKey(scopeKeyFromSelection(prev), cat); return next; }); }, [cat]);
|
||||
useEffect(() => { loadDmsDetail(); }, [scopeKey]);
|
||||
useEffect(() => { try { localStorage.setItem(SCOPE_STORAGE_KEY, scopeKey); } catch { /* */ } }, [scopeKey]);
|
||||
|
||||
// Filter tasks by domain
|
||||
const dmsData = useMemo(() => {
|
||||
if (!cat?.dms) return {};
|
||||
const filtered: Record<string, DmsTaskEntry> = {};
|
||||
for (const [k, v] of Object.entries(cat.dms)) {
|
||||
const entry = v as DmsTaskEntry;
|
||||
const d = entry.domain || (k === "forward" || k === "adas" ? "forward" : "dms");
|
||||
if (d === domain || (domain === "forward" && d === "forward")) {
|
||||
filtered[k] = entry;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}, [cat, domain]);
|
||||
|
||||
// Build filtered catalog for pack/batch trees
|
||||
const filteredCat: CatalogReport | null = useMemo(() => {
|
||||
if (!cat) return null;
|
||||
return { ...cat, dms: dmsData as Record<string, unknown> } as CatalogReport;
|
||||
}, [cat, dmsData]);
|
||||
|
||||
// Full pack tree then filter to only packs relevant to current domain
|
||||
const allPackTree = useMemo(() => buildDmsPackTree(filteredCat), [filteredCat]);
|
||||
const packTree = useMemo(() => {
|
||||
if (domain === "dms") {
|
||||
// DMS 舱内: only dms_v1, dms_v2
|
||||
return allPackTree.filter((p) => p.pack.startsWith("dms_"));
|
||||
} else if (domain === "forward") {
|
||||
// ADAS 前向: only adas_v1
|
||||
return allPackTree.filter((p) => p.pack.startsWith("adas_"));
|
||||
}
|
||||
return allPackTree;
|
||||
}, [allPackTree, domain]);
|
||||
const lanePacks = useMemo(() => Object.keys(cat?.lane || {}).sort(), [cat]);
|
||||
|
||||
// Build task list for current domain
|
||||
const taskList = useMemo(() => {
|
||||
return Object.entries(dmsData).map(([id, entry]) => ({
|
||||
id, label: entry.label || id, domain: entry.domain, nc: entry.nc,
|
||||
}));
|
||||
}, [dmsData]);
|
||||
|
||||
const activePackNode = packTree.find((p) => p.pack === ui.pack) || packTree[0];
|
||||
const packTasks = activePackNode?.tasks || [];
|
||||
|
||||
// Reset selection when domain changes
|
||||
useEffect(() => {
|
||||
if (!filteredCat) return;
|
||||
const tasks = Object.keys(dmsData);
|
||||
const laneKeys = Object.keys(cat?.lane || {});
|
||||
let defaultScope: string;
|
||||
if (domain === "lane") {
|
||||
defaultScope = `lane:${laneKeys[0] || ""}`;
|
||||
} else if (domain === "forward") {
|
||||
const fwdPack = packTree[0]?.pack || "adas_v1";
|
||||
const fwdTask = packTree[0]?.tasks?.[0];
|
||||
defaultScope = `dms-pack:${fwdPack}:${fwdTask?.id || "adas"}`;
|
||||
} else {
|
||||
const dmsPack = packTree.find((p) => p.pack === "dms_v1")?.pack || packTree[0]?.pack || "dms_v1";
|
||||
defaultScope = `dms-pack:${dmsPack}:dam`;
|
||||
}
|
||||
setUi(selectionFromScopeKey(readStoredScope(defaultScope), filteredCat));
|
||||
setSubView(domain === "lane" ? "lane" : "pack");
|
||||
}, [domain, packTree]);
|
||||
|
||||
const setPack = (pack: string) => {
|
||||
setUi((prev) => {
|
||||
const node = packTree.find((p) => p.pack === pack) || packTree[0];
|
||||
const prevKey = packTaskKey(prev.task, prev.mode || undefined);
|
||||
const match = node?.tasks.find((t) => t.key === prevKey) || node?.tasks[0];
|
||||
return { ...prev, view: "pack" as const, pack: node?.pack || pack, task: match?.id || prev.task, mode: match?.mode || "" };
|
||||
});
|
||||
};
|
||||
|
||||
const setTaskKey = (taskKey: string) => {
|
||||
const { task, mode } = parsePackTaskKey(taskKey);
|
||||
setUi((prev) => ({ ...prev, view: "pack" as const, task, mode }));
|
||||
};
|
||||
|
||||
// DMS/Forward: use existing data paths
|
||||
const taskEntry: DmsTaskEntry | undefined = isDmsCatalogScope(scope) ? dmsDetail || (dmsData[scope.task] as DmsTaskEntry | undefined) : undefined;
|
||||
const packRow: DmsPackRow | undefined = isPackView && scope.pack ? findPackRow(taskEntry, scope.pack, scope.mode) : undefined;
|
||||
const activePackTask = packTasks.find((t) => t.key === packTaskKey(ui.task, ui.mode || undefined));
|
||||
const modePacks = scope.project === "dms" ? dmsPacks(taskEntry, scope.mode || "") : [];
|
||||
const tablePacks: DmsPackRow[] = isPackView ? (packRow ? [packRow] : []) : modePacks;
|
||||
|
||||
const classCounts = isPackView ? packRow?.class_counts || {} : (scope.mode && taskEntry?.modes?.[scope.mode]?.class_counts) || taskEntry?.class_counts || primaryPack(modePacks)?.class_counts || modePacks[0]?.class_counts || {};
|
||||
const bboxPoints = isPackView ? bboxPointsFromPack(packRow) : aggregateBboxPoints(modePacks);
|
||||
const splitCounts = isPackView ? splitCountsFromPack(packRow) : aggregateSplitCounts(modePacks);
|
||||
const laneEntry = scope.project === "lane" ? cat?.lane?.[scope.task] : null;
|
||||
|
||||
const selectClass = "mt-1 block w-full min-w-[10rem] rounded-md border border-gray-300 px-3 py-2 text-sm";
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>数据目录</h1>
|
||||
<p>按训练包与任务查看统计分布</p>
|
||||
</div>
|
||||
<Button variant="default" size="small" onClick={() => { load(true); loadDmsDetail(true); }} disabled={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-red-500 text-sm mb-3">{err}</p>}
|
||||
|
||||
<PageQueryState loading={loading}>
|
||||
<>
|
||||
{/* Domain tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{DOMAIN_TABS.map((tab) => (
|
||||
<button key={tab.key} onClick={() => setDomain(tab.key)}
|
||||
className={`px-4 py-2 rounded-md text-sm transition-colors ${
|
||||
domain === tab.key ? "bg-blue-700 text-white" : "bg-white border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
}`}>
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
<span className="ml-1.5 text-xs opacity-70">{tab.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DMS / Forward sub-views */}
|
||||
{domain !== "lane" && (
|
||||
<div className="flex flex-wrap gap-2 mb-4 text-sm">
|
||||
<p className="text-xs text-gray-500 m-0 self-center mr-2">视图</p>
|
||||
{(["pack", "batch"] as const).map((v) => (
|
||||
<button key={v} onClick={() => setSubView(v)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${
|
||||
subView === v ? "border-blue-300 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
|
||||
}`}>
|
||||
{v === "pack" ? "训练包" : "采集批次"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pack selector */}
|
||||
{domain !== "lane" && subView === "pack" && packTree.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label className="flex-1 min-w-[8rem] text-xs text-gray-500">训练包
|
||||
<select className={selectClass} value={ui.pack} onChange={(e) => setPack(e.target.value)}>
|
||||
{packTree.map((p) => <option key={p.pack} value={p.pack}>{p.pack}{p.enabled ? "(启用)" : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex-[1.2] min-w-[10rem] text-xs text-gray-500">任务
|
||||
<select className={selectClass} value={packTaskKey(ui.task, ui.mode || undefined)} onChange={(e) => setTaskKey(e.target.value)}>
|
||||
{packTasks.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DMS/Forward: Charts */}
|
||||
{domain !== "lane" && taskEntry && isDmsCatalogScope(scope) && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3 mb-4">
|
||||
<div className="flex flex-wrap gap-2 items-center mb-2">
|
||||
<span className="text-base font-semibold">{activePackTask?.label || taskEntry.label || scope.task}</span>
|
||||
{isPackView && scope.pack && <Badge variant="info">{scope.pack}</Badge>}
|
||||
{taskEntry.nc != null && <Badge variant="info">{taskEntry.nc} 类</Badge>}
|
||||
{Array.isArray((taskEntry as Record<string, unknown>)?.names) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{((taskEntry as Record<string, unknown>).names as string[]).map((n: string) => (
|
||||
<Badge key={n} variant="default" size="small">{n}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)] items-stretch">
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<p className="text-[11px] text-gray-500 m-0 mb-1.5">类别分布</p>
|
||||
<ClassCountBars counts={classCounts as Record<string, number>} compact maxBars={14} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||
<ChartCell title="数据划分"><SplitCountsBars counts={splitCounts} height={100} /></ChartCell>
|
||||
<ChartCell title="类别占比"><ClassCountPie counts={classCounts as Record<string, number>} size={CHART_SIZE} /></ChartCell>
|
||||
<ChartCell title="框宽高散点"><BboxScatter points={bboxPoints} size={CHART_SIZE} compact /></ChartCell>
|
||||
<ChartCell title="雷达"><ClassCountRadar counts={classCounts as Record<string, number>} size={CHART_SIZE} compact /></ChartCell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lane */}
|
||||
{domain === "lane" && laneEntry && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 mb-4">
|
||||
<div className="font-mono text-base font-semibold">{scope.task}</div>
|
||||
<p className="text-sm text-gray-500 mt-2">train_lines: {String(laneEntry.train_lines ?? "—")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{domain !== "lane" && !taskEntry && (
|
||||
<div className="card text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">暂无 {DOMAIN_TABS.find((t) => t.key === domain)?.label} 数据</p>
|
||||
<p className="text-sm">请先在送标工作台中扫描入库,或检查数据集链接是否正确</p>
|
||||
</div>
|
||||
)}
|
||||
{domain === "lane" && !laneEntry && (
|
||||
<div className="card text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">暂无车道线数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pack table */}
|
||||
{domain !== "lane" && isDmsCatalogScope(scope) && taskEntry && tablePacks.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="bg-gray-50 text-left border-b border-gray-200"><th className="px-4 py-2">训练包</th><th>启用</th><th>train</th><th>val</th><th>test</th></tr></thead>
|
||||
<tbody>
|
||||
{tablePacks.map((p) => (
|
||||
<tr key={p.name} className="border-b border-gray-100">
|
||||
<td className="px-4 py-2 font-mono">{p.name}</td><td>{p.enabled ? "是" : "否"}</td>
|
||||
<td>{p.train_images ?? "—"}</td><td>{p.val_images ?? "—"}</td><td>{p.test_images ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
platform/web/src/modules/labeling/pages/DashboardPage.tsx
Normal file
203
platform/web/src/modules/labeling/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
type DashboardData = {
|
||||
stages: Record<string, number>; total_batches: number; pending_approvals: number;
|
||||
running_jobs: number; model_count: number; fleet: Record<string, unknown>;
|
||||
activity: Record<string, unknown>[]; recent_training: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/dashboard", {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
cache: "no-store",
|
||||
}).then((r) => r.json()).then(setData).catch((e) => setError(String(e))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>数据闭环</h1>
|
||||
<p>双数据源驱动 — T-Box 实采 + 世界模型仿真</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{data && (
|
||||
<>
|
||||
{/* Pipeline flow visualization */}
|
||||
<div className="card mb-6">
|
||||
<div className="card-header">数据流入管线</div>
|
||||
<div className="flex items-center gap-0 text-sm py-4">
|
||||
{/* Left: T-Box */}
|
||||
<div className="flex-1 text-center">
|
||||
<Link to="/fleet/dashboard" className="block p-4 rounded-xl border-2 border-blue-200 bg-blue-50/50 hover:border-blue-400 transition-colors">
|
||||
<div className="text-3xl mb-2">🚛</div>
|
||||
<div className="font-semibold text-blue-800">T-Box 实采</div>
|
||||
<div className="text-xs text-gray-500 mt-1">车队采集 · GPS 标注</div>
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
{data.fleet?.active_vehicles != null ? `${data.fleet.active_vehicles} 辆在线` : "—"}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mt-2 text-xs text-gray-400">多帧视频 → 预处理 → 入库</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="text-gray-300 text-2xl px-2">→</div>
|
||||
|
||||
{/* Middle: Processing */}
|
||||
<div className="flex-1 text-center">
|
||||
<div className="p-4 rounded-xl border-2 border-gray-200 bg-gray-50/50">
|
||||
<div className="text-3xl mb-2">⚙️</div>
|
||||
<div className="font-semibold text-gray-700">数据加工</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.stages.out_for_labeling || 0} 标中 · {data.stages.returned || 0} 待入库
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{data.total_batches} 个批次</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">标注 → 质检 → 入库 → build</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="text-gray-300 text-2xl px-2">→</div>
|
||||
|
||||
{/* Right: Training */}
|
||||
<div className="flex-1 text-center">
|
||||
<Link to="/models/overview" className="block p-4 rounded-xl border-2 border-green-200 bg-green-50/50 hover:border-green-400 transition-colors">
|
||||
<div className="text-3xl mb-2">🧠</div>
|
||||
<div className="font-semibold text-green-800">模型训练</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{data.model_count} 个模型</div>
|
||||
<div className="text-xs text-green-600 mt-1">{data.running_jobs} 个执行中</div>
|
||||
</Link>
|
||||
<div className="mt-2 text-xs text-gray-400">train → eval → promote</div>
|
||||
</div>
|
||||
|
||||
{/* Rightmost: World Model */}
|
||||
<div className="text-gray-300 text-2xl px-2">↻</div>
|
||||
|
||||
<div className="flex-1 text-center">
|
||||
<Link to="/labeling/simulate" className="block p-4 rounded-xl border-2 border-purple-200 bg-purple-50/50 hover:border-purple-400 transition-colors">
|
||||
<div className="text-3xl mb-2">🌐</div>
|
||||
<div className="font-semibold text-purple-800">世界模型仿真</div>
|
||||
<div className="text-xs text-gray-500 mt-1">场景生成 · 自动标注</div>
|
||||
<div className="text-xs text-purple-600 mt-1">补数据缺口</div>
|
||||
</Link>
|
||||
<div className="mt-2 text-xs text-gray-400">评估反馈 → 生成 → 直接入库</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
<Link to="/labeling/workbench" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-blue-700">{data.total_batches}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">总批次</div>
|
||||
</Link>
|
||||
<Link to="/system/audit" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-orange-600">{data.pending_approvals}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">待审核</div>
|
||||
</Link>
|
||||
<Link to="/models/overview" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-green-600">{data.model_count}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">模型</div>
|
||||
</Link>
|
||||
<Link to="/system/jobs" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-purple-600">{data.running_jobs}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">执行中</div>
|
||||
</Link>
|
||||
<Link to="/fleet/dashboard" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{data.fleet?.active_vehicles != null ? String(data.fleet.active_vehicles) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">在线车辆</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stage distribution + Activity */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<div className="card-header">批次阶段分布</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ k: "raw_pool", label: "待送标", color: "bg-gray-400" },
|
||||
{ k: "out_for_labeling", label: "标中", color: "bg-blue-500" },
|
||||
{ k: "in_review", label: "质检中", color: "bg-yellow-500" },
|
||||
{ k: "review_approved", label: "已通过", color: "bg-green-500" },
|
||||
{ k: "returned", label: "已入库", color: "bg-green-600" },
|
||||
].map(({ k, label, color }) => {
|
||||
const v = data.stages[k] || 0;
|
||||
const max = Math.max(...Object.values(data.stages), 1);
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-14 text-gray-500">{label}</span>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded overflow-hidden">
|
||||
<div className={`h-full rounded transition-all ${color}`} style={{ width: `${(v / max) * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-6 text-right font-mono font-semibold text-sm">{v}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">快捷入口</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Link to="/labeling/workbench" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">📋</span> 送标工作台
|
||||
</Link>
|
||||
<Link to="/labeling/simulate" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-purple-50 transition-colors">
|
||||
<span className="text-lg">🌐</span> 仿真工坊
|
||||
</Link>
|
||||
<Link to="/labeling/campaigns" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">✏️</span> 标注进度
|
||||
</Link>
|
||||
<Link to="/labeling/review" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">✅</span> 标注质检
|
||||
</Link>
|
||||
<Link to="/models/training/submit" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">🚀</span> 训练提交
|
||||
</Link>
|
||||
<Link to="/system/audit" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">📝</span> 审核队列
|
||||
</Link>
|
||||
<Link to="/fleet/dashboard" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">🚛</span> 车队总览
|
||||
</Link>
|
||||
<Link to="/models/datasets" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">📦</span> 数据集版本
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data source summary */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<div className="card border-l-4 border-l-blue-500">
|
||||
<div className="text-xs text-gray-400 mb-1">T-Box 实采数据流</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
采集车多帧视频 → 预处理去噪去重 → 入库 →
|
||||
标注 → 质检 → build → 模型训练
|
||||
</div>
|
||||
</div>
|
||||
<div className="card border-l-4 border-l-purple-500">
|
||||
<div className="text-xs text-gray-400 mb-1">世界模型仿真数据流</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
评估反馈(数据缺口) → 场景配置 → 生成 →
|
||||
自动标注 → 直接入库 → 补充训练
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
145
platform/web/src/modules/labeling/pages/DeliveriesPage.tsx
Normal file
145
platform/web/src/modules/labeling/pages/DeliveriesPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import type { BatchDelivery } from "@/lib/types";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
export const DeliveriesPage: React.FC = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [deliveries, setDeliveries] = useState<BatchDelivery[]>([]);
|
||||
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 [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const load = async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listDeliveries({ status: statusFilter || undefined, offset: newOffset, limit: newLimit });
|
||||
let items = res.items || [];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((d) => (d.batch_name || "").toLowerCase().includes(q) || (d.task || "").toLowerCase().includes(q) || (d.owner_name || "").toLowerCase().includes(q));
|
||||
}
|
||||
setDeliveries(items);
|
||||
setTotal(search ? items.length : res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [search, statusFilter]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const batchName = prompt("批次名称 (如 20260601_pilot):");
|
||||
if (!batchName) return;
|
||||
const dataPath = prompt("数据路径 (如 datasets/dms/inbox/ddaw/20260601_pilot):");
|
||||
if (!dataPath) return;
|
||||
try {
|
||||
await hsapApi.createDelivery({
|
||||
project: "dms",
|
||||
batch_name: batchName,
|
||||
data_path: dataPath,
|
||||
});
|
||||
load(0, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (id: string) => {
|
||||
try {
|
||||
await hsapApi.submitDelivery(id);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除此送标记录?")) return;
|
||||
try {
|
||||
await hsapApi.deleteDelivery(id);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>批次台账</h1>
|
||||
<p>管理数据送标申请与审核流程</p>
|
||||
</div>
|
||||
{hasPermission("write:delivery_submit") && (
|
||||
<Button variant="primary" onClick={handleCreate}>新建送标</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次/任务/提单人..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "草稿", "已提交", "已入湖", "已驳回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["draft", "submitted", "ingested", "rejected"][i - 1];
|
||||
return <button key={val} onClick={() => { setStatusFilter(val); setOffset(0); }} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${statusFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(0, limit)}>刷新</Button>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{total} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={deliveries.length === 0} emptyMessage="暂无送标记录">
|
||||
<div className="space-y-2">
|
||||
{deliveries.map((d) => (
|
||||
<div key={d.id} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{d.batch_name}</span>
|
||||
<span className="text-xs text-gray-400">{d.project}/{d.task || "—"}</span>
|
||||
<StatusBadge status={d.status} />
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>🖼 {d.estimated_count ?? "—"}</span>
|
||||
<span>👤 {d.submitted_by_name || d.owner_name || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{d.status === "draft" && (
|
||||
<>
|
||||
<button onClick={() => handleSubmit(d.id)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">✅ 提交审核</button>
|
||||
<button onClick={() => handleDelete(d.id)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors">🗑 删除</button>
|
||||
</>
|
||||
)}
|
||||
{d.approval_id && (
|
||||
<Link to={`/system/audit/${d.approval_id}`} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors">📋 审核</Link>
|
||||
)}
|
||||
{d.status === "ingested" && <span className="text-green-600 text-sm font-medium">✓ 已入湖</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
158
platform/web/src/modules/labeling/pages/ExportPage.tsx
Normal file
158
platform/web/src/modules/labeling/pages/ExportPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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 { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
export const ExportPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (search && !(b.batch || "").toLowerCase().includes(search.toLowerCase()) && !(b.task || "").toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (stageFilter && b.stage !== stageFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [submitted, returned] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "returned", limit: 100 }),
|
||||
]);
|
||||
if (submitted.status === "fulfilled") results.push(...((submitted.value.items || []) as LabelingBatchRow[]));
|
||||
if (returned.status === "fulfilled") results.push(...((returned.value.items || []) as LabelingBatchRow[]));
|
||||
if (submitted.status === "rejected" && returned.status === "rejected") setError(String(submitted.reason));
|
||||
setBatches(results);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
try { await hsapApi.labelingExport(campaignId); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const handleImportVendor = async (campaignId: string) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file"; input.accept = ".zip";
|
||||
input.onchange = async (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
setImportingId(campaignId);
|
||||
try { await hsapApi.importVendorZip(campaignId, f); load(); }
|
||||
catch (err) { setError(String(err)); }
|
||||
setImportingId(null);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const hasData = batches.length > 0;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>导出与入库</h1>
|
||||
<p>标注完成后的导出、供应商回标导入、入库流程</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次/任务..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "待导出", "待入库"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["labeling_submitted", "returned"][i - 1];
|
||||
return <button key={val} onClick={() => setStageFilter(val)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow guide */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">流程说明</div>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">1</span>
|
||||
<span><strong>标注提交</strong> — 标注员在 Campaign 中完成标注后,点击"提交批次"</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">2</span>
|
||||
<span>
|
||||
<strong>导出标注</strong> — 在此页面点击"执行导出",将标注结果转为 YOLO 格式
|
||||
{hasData && <span className="text-gray-400">(下表有待导出批次)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-xs font-bold">3</span>
|
||||
<span>
|
||||
<strong>供应商回标</strong> — 如果是外部供应商标注的,点击"导入供应商"上传 ZIP 回标文件
|
||||
<span className="block text-xs text-gray-400 mt-0.5">ZIP 格式要求:每张图片对应一个同名 .txt 标注文件(YOLO 格式),放在同一目录下打包</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-xs font-bold">4</span>
|
||||
<span>
|
||||
<strong>入库 build</strong> — 导出完成后,批次进入 <Badge variant="success" size="small">待入库</Badge> 状态,可通过审核队列提交 build
|
||||
<span className="block text-xs text-gray-400 mt-0.5">build 成功后自动生成数据集版本快照</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0}>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((b) => (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.task || "—"}</span>
|
||||
<Badge variant={b.stage === "returned" ? "success" : "warning"}>{b.stage === "labeling_submitted" ? "待导出" : "待入库"}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-1">{b.campaign_id?.slice(0, 16) || "—"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && b.stage === "labeling_submitted" && (
|
||||
<>
|
||||
<Button size="small" variant="primary" onClick={() => handleExport(b.campaign_id!)}>📤 执行导出</Button>
|
||||
<Button size="small" variant="default" loading={importingId === b.campaign_id} onClick={() => handleImportVendor(b.campaign_id!)}>📥 导入供应商</Button>
|
||||
</>
|
||||
)}
|
||||
{b.stage === "returned" && <span className="text-green-600 text-sm font-medium">✓ 已入库</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-400 text-lg mb-3">暂无待导出或待入库的批次</p>
|
||||
<p className="text-gray-400 text-sm mb-4">完成标注后,在标注进度页提交批次,即可在此处导出</p>
|
||||
<Link to="/labeling/campaigns"><Button variant="default" size="small">去标注进度 →</Button></Link>
|
||||
</div>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
273
platform/web/src/modules/labeling/pages/QualityReviewPage.tsx
Normal file
273
platform/web/src/modules/labeling/pages/QualityReviewPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ReviewItem = { id: string; image_path: string; fileName: string; score: string; has_label: boolean };
|
||||
type ScoreCounts = { good: number; fine: number; bad: number; pending: number };
|
||||
type ScoreKey = keyof ScoreCounts;
|
||||
|
||||
const SCORE_CFG: Record<ScoreKey, { label: string; color: string; bg: string; icon: string; btn: string }> = {
|
||||
good: { label: "合格", color: "text-green-600", bg: "bg-green-500", icon: "✓", btn: "bg-green-600 hover:bg-green-500 text-white" },
|
||||
fine: { label: "可用", color: "text-yellow-600", bg: "bg-yellow-500", icon: "~", btn: "bg-yellow-600 hover:bg-yellow-500 text-white" },
|
||||
bad: { label: "退回", color: "text-red-600", bg: "bg-red-500", icon: "✗", btn: "bg-red-600 hover:bg-red-500 text-white" },
|
||||
pending: { label: "未评", color: "text-gray-400", bg: "bg-gray-300", icon: "○", btn: "" },
|
||||
};
|
||||
|
||||
// ── List ──
|
||||
const ReviewListPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [filtered, setFiltered] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
const [sort, setSort] = useState<"batch"|"task">("batch");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [inReview, approved, rejected] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "in_review", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "review_approved", limit: 50 }),
|
||||
hsapApi.labelingBatches({ stage: "review_rejected", limit: 50 }),
|
||||
]);
|
||||
if (inReview.status === "fulfilled") results.push(...((inReview.value.items || []) as LabelingBatchRow[]));
|
||||
if (approved.status === "fulfilled") results.push(...((approved.value.items || []) as LabelingBatchRow[]));
|
||||
if (rejected.status === "fulfilled") results.push(...((rejected.value.items || []) as LabelingBatchRow[]));
|
||||
setBatches(results);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
let result = [...batches];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((b) => (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q));
|
||||
}
|
||||
if (stageFilter) result = result.filter((b) => b.stage === stageFilter);
|
||||
result.sort((a, b) => (a[sort] || "").localeCompare(b[sort] || ""));
|
||||
setFiltered(result);
|
||||
}, [batches, search, stageFilter, sort]);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>标注质检</h1>
|
||||
<p>审核标注质量 — 合格和可用的流入训练集,退回的返回标注员修正</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={load}>刷新</Button>
|
||||
</div>
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次名称、任务名称..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "质检中", "已通过", "已退回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["in_review", "review_approved", "review_rejected"][i - 1];
|
||||
return (
|
||||
<button key={val} onClick={() => setStageFilter(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}>{label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-gray-300">|</span>
|
||||
{/* Sort */}
|
||||
<select className="text-xs border border-gray-300 rounded-lg px-2.5 py-1.5 text-gray-600"
|
||||
value={sort} onChange={(e) => setSort(e.target.value as "batch"|"task")}>
|
||||
<option value="batch">按批次排序</option>
|
||||
<option value="task">按任务排序</option>
|
||||
</select>
|
||||
{/* Count */}
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无待质检的批次">
|
||||
<div className="space-y-2">
|
||||
{filtered.map((b) => {
|
||||
const pct = b.total_tasks && b.total_tasks > 0 ? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100) : 0;
|
||||
return (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
||||
<span className="font-mono">{(b.campaign_id || "").slice(0, 14)}...</span>
|
||||
{pct > 0 && <span>{b.completed_tasks}/{b.total_tasks} ({pct}%)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "in_review" && <Link to={`/labeling/review/${b.campaign_id}`}><Button size="small" variant="primary">▶ 开始质检</Button></Link>}
|
||||
{b.stage === "review_approved" && <span className="text-green-600 text-sm font-medium">✓ 已通过</span>}
|
||||
{b.stage === "review_rejected" && <span className="text-red-600 text-sm font-medium">✗ 已退回</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Detail ──
|
||||
const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
|
||||
const [items, setItems] = useState<ReviewItem[]>([]);
|
||||
const [scores, setScores] = useState<ScoreCounts>({ good: 0, fine: 0, bad: 0, pending: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentIdx, setCurrentIdx] = useState(0);
|
||||
const [localScores, setLocalScores] = useState<Record<string, string>>({});
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ offset: "0", limit: "2000" });
|
||||
const res = await fetch(`/api/v1/labeling/campaigns/${campaignId}/review-queue?${params}`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` }, cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setItems((res.items || []) as ReviewItem[]);
|
||||
setScores((res.scores || { good: 0, fine: 0, bad: 0, pending: 0 }) as ScoreCounts);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [campaignId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const currentImage = items[currentIdx];
|
||||
const imageUrl = currentImage
|
||||
? `/api/v1/labeling/campaigns/${campaignId}/review-image?path=${encodeURIComponent(currentImage.image_path)}`
|
||||
: "";
|
||||
const currentScore = currentImage ? (localScores[currentImage.image_path] || currentImage.score || "pending") : "pending";
|
||||
|
||||
const handleScore = (score: string) => {
|
||||
if (!currentImage) return;
|
||||
setLocalScores((prev) => ({ ...prev, [currentImage.image_path]: score }));
|
||||
fetch(`/api/v1/labeling/campaigns/${campaignId}/review-submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
body: JSON.stringify({ scores: [{ image_path: currentImage.image_path, score }] }),
|
||||
}).catch(() => {});
|
||||
setScores((prev) => {
|
||||
const oldScore = currentImage.score || "pending";
|
||||
const next = { ...prev };
|
||||
if (next[oldScore as ScoreKey] > 0) next[oldScore as ScoreKey]--;
|
||||
next[score as ScoreKey] = (next[score as ScoreKey] || 0) + 1;
|
||||
return next;
|
||||
});
|
||||
if (currentIdx < items.length - 1) setTimeout(() => setCurrentIdx((i) => i + 1), 250);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === "ArrowLeft") setCurrentIdx((i) => Math.max(0, i - 1));
|
||||
else if (e.key === "ArrowRight") setCurrentIdx((i) => Math.min(items.length - 1, i + 1));
|
||||
else if (e.key === "g" || e.key === "G") handleScore("good");
|
||||
else if (e.key === "f" || e.key === "F") handleScore("fine");
|
||||
else if (e.key === "b" || e.key === "B") handleScore("bad");
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [currentIdx, items.length]);
|
||||
|
||||
const reviewed = scores.good + scores.fine + scores.bad;
|
||||
const total = reviewed + scores.pending;
|
||||
const progressPct = total > 0 ? (reviewed / total) * 100 : 0;
|
||||
const passRate = reviewed > 0 ? ((scores.good + scores.fine) / reviewed) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900 text-white">
|
||||
{/* Top control bar */}
|
||||
<header className="flex items-center gap-4 px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
|
||||
<Link to="/labeling/review" className="text-blue-400 text-sm hover:underline">← 返回列表</Link>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span className="text-sm font-mono text-gray-300">{campaignId.slice(0, 16)}...</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
{(["good", "fine", "bad"] as ScoreKey[]).map((k) => (
|
||||
<span key={k} className={`text-sm ${SCORE_CFG[k].color} flex items-center gap-1`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${SCORE_CFG[k].bg}`} />{SCORE_CFG[k].label}: {scores[k]}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-gray-500">{reviewed}/{total} 已评</span>
|
||||
<div className="w-24 h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs ${passRate >= 80 ? "text-green-400" : passRate > 0 ? "text-yellow-400" : "text-gray-500"}`}>
|
||||
{Math.round(passRate)}% 通过
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={items.length === 0} emptyMessage="该批次暂无图片">
|
||||
{currentImage && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
|
||||
{imgError ? (
|
||||
<div className="text-gray-500 text-center"><p className="text-lg mb-2">图片加载失败</p><p className="text-sm">{currentImage.fileName}</p></div>
|
||||
) : (
|
||||
<img src={imageUrl} alt={currentImage.fileName} className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||
onError={() => setImgError(true)} onLoad={() => setImgError(false)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="shrink-0 bg-gray-800 border-t border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center gap-3 max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="small" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} disabled={currentIdx <= 0}>←</Button>
|
||||
<span className="text-sm text-gray-400 w-20 text-center tabular-nums">{currentIdx + 1}/{items.length}</span>
|
||||
<Button variant="default" size="small" onClick={() => setCurrentIdx((i) => Math.min(items.length - 1, i + 1))} disabled={currentIdx >= items.length - 1}>→</Button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{(["good", "fine", "bad"] as ScoreKey[]).map((s) => (
|
||||
<button key={s} onClick={() => handleScore(s)}
|
||||
className={`px-6 py-2.5 rounded-lg font-semibold text-sm transition-all ${SCORE_CFG[s].btn} ${
|
||||
currentScore === s ? "ring-2 ring-offset-2 ring-offset-gray-800 scale-105" : "hover:scale-105"
|
||||
}`}>
|
||||
{SCORE_CFG[s].icon} {SCORE_CFG[s].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-1.5 text-xs text-gray-500">
|
||||
{currentImage.fileName} · 标注: {currentImage.has_label ? "有" : "无"} · 快捷键: G/F/B 评分 ←→ 翻页
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Router ──
|
||||
export const QualityReviewPage: React.FC = () => {
|
||||
const { campaignId } = useParams<{ campaignId: string }>();
|
||||
if (!campaignId) return <ReviewListPage />;
|
||||
return <ReviewDetailPage campaignId={campaignId} />;
|
||||
};
|
||||
251
platform/web/src/modules/labeling/pages/SimulationStudioPage.tsx
Normal file
251
platform/web/src/modules/labeling/pages/SimulationStudioPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
type SimJob = Record<string, unknown>;
|
||||
|
||||
const SCENES = [
|
||||
{ key: "urban_highway", label: "城市快速路", icon: "🛣️" },
|
||||
{ key: "urban_street", label: "城市街道", icon: "🏙️" },
|
||||
{ key: "rural_road", label: "乡村道路", icon: "🌾" },
|
||||
{ key: "tunnel", label: "隧道", icon: "🚇" },
|
||||
{ key: "night_city", label: "夜间城市", icon: "🌃" },
|
||||
{ key: "rain_highway", label: "雨天高速", icon: "🌧️" },
|
||||
{ key: "fog_rural", label: "雾天乡村", icon: "🌫️" },
|
||||
];
|
||||
|
||||
const CAMERAS = [
|
||||
{ key: "truck_front", label: "卡车前视", icon: "🚛", height: "2.5m", fov: "75°" },
|
||||
{ key: "truck_side", label: "卡车侧视", icon: "🚛", height: "2.5m", fov: "100°" },
|
||||
{ key: "car_front", label: "轿车前视", icon: "🚗", height: "1.2m", fov: "60°" },
|
||||
{ key: "car_wide", label: "轿车广角", icon: "🚗", height: "1.2m", fov: "120°" },
|
||||
];
|
||||
|
||||
const WEATHERS = ["clear", "cloudy", "rain", "fog", "night"];
|
||||
const WEATHER_LABELS: Record<string, string> = { clear: "晴天", cloudy: "多云", rain: "雨天", fog: "大雾", night: "夜间" };
|
||||
const DENSITY_LABELS: Record<string, string> = { sparse: "稀疏 5-10", medium: "中等 10-30", dense: "密集 30+" };
|
||||
const ALL_CLASSES = ["Pedestrain", "Car", "Truck", "Bus", "Motor-vehicles", "Tricycle", "cones"];
|
||||
|
||||
const JOBS_PAGE = 20;
|
||||
|
||||
export const SimulationStudioPage: React.FC = () => {
|
||||
// Config form
|
||||
const [scene, setScene] = useState("urban_highway");
|
||||
const [camera, setCamera] = useState("truck_front");
|
||||
const [weather, setWeather] = useState("clear");
|
||||
const [objects, setObjects] = useState<string[]>(["Pedestrain", "Car", "Truck", "Bus"]);
|
||||
const [density, setDensity] = useState("medium");
|
||||
const [count, setCount] = useState(100);
|
||||
const [fovVariant, setFovVariant] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
// Jobs list
|
||||
const [jobs, setJobs] = useState<SimJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/simulate/jobs?limit=50`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` }, cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setJobs((res.items || []) as SimJob[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { loadJobs(); }, []);
|
||||
|
||||
const toggleObject = (obj: string) => {
|
||||
setObjects((prev) => prev.includes(obj) ? prev.filter((o) => o !== obj) : [...prev, obj]);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (objects.length === 0) { setError("请至少选择一种目标类别"); return; }
|
||||
setGenerating(true); setError(null); setInfo(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/simulate/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
body: JSON.stringify({ scene, camera, weather, objects, density, count, fov_variant: fovVariant, note }),
|
||||
}).then((r) => r.json());
|
||||
setInfo(`任务已提交: ${res.id}`);
|
||||
loadJobs();
|
||||
} catch (e) { setError(String(e)); }
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleIngest = async (jobId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/simulate/jobs/${jobId}/ingest?task=adas`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
}).then((r) => r.json());
|
||||
if (res.ok) setInfo(`已入库: ${res.batch}`);
|
||||
else setError(String(res.error));
|
||||
loadJobs();
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>仿真工坊</h1>
|
||||
<p>世界模型生成合成数据 — 自动标注,直接入库</p>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Config panel */}
|
||||
<div>
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">场景配置</div>
|
||||
|
||||
{/* Scene */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">场景模板</label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{SCENES.map((s) => (
|
||||
<button key={s.key} onClick={() => setScene(s.key)}
|
||||
className={`p-2 rounded-lg text-xs text-center transition-colors ${
|
||||
scene === s.key ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}>
|
||||
<div className="text-lg">{s.icon}</div>
|
||||
<div>{s.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Camera */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">相机参数</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{CAMERAS.map((c) => (
|
||||
<button key={c.key} onClick={() => setCamera(c.key)}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
camera === c.key ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}>
|
||||
<div>{c.icon} {c.label}</div>
|
||||
<div className="text-[10px] opacity-70">高度 {c.height} · FOV {c.fov}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">天气/光照</label>
|
||||
<div className="flex gap-1.5">
|
||||
{WEATHERS.map((w) => (
|
||||
<button key={w} onClick={() => setWeather(w)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
weather === w ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}>{WEATHER_LABELS[w] || w}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objects */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">目标类别 ({objects.length}/7)</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ALL_CLASSES.map((c) => (
|
||||
<button key={c} onClick={() => toggleObject(c)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${
|
||||
objects.includes(c) ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-500"
|
||||
}`}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Density + Count */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="form-label">目标密度</label>
|
||||
<select className="form-input" value={density} onChange={(e) => setDensity(e.target.value)}>
|
||||
{Object.entries(DENSITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">生成数量</label>
|
||||
<select className="form-input" value={count} onChange={(e) => setCount(Number(e.target.value))}>
|
||||
{[50, 100, 200, 500, 1000, 2000, 5000].map((n) => <option key={n} value={n}>{n} 张</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-FOV variant */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={fovVariant} onChange={(e) => setFovVariant(e.target.checked)} className="rounded" />
|
||||
<span>生成多FOV变体(卡车+轿车视角各一份)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">备注</label>
|
||||
<input className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="如:补卡车视角雨天行人数据" />
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={handleGenerate} loading={generating} className="w-full">
|
||||
🚀 生成仿真数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Job history */}
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>生成历史</span>
|
||||
<Button size="small" variant="default" onClick={loadJobs}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={jobs.length === 0} emptyMessage="暂无生成记录">
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{jobs.map((job) => {
|
||||
const params = job.params as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<div key={job.id as string} className="border border-gray-100 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{(job.id as string).slice(0, 20)}...</span>
|
||||
<StatusBadge status={(job.status as string) || "queued"} />
|
||||
</div>
|
||||
{params && (
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<div>{String(params.scene_label ?? "")} · {String(params.camera_label ?? "")} · {WEATHER_LABELS[String(params.weather ?? "")] || String(params.weather ?? "")}</div>
|
||||
<div className="flex gap-1">
|
||||
{(params.objects as string[])?.map((o) => <Badge key={o} size="small" variant="default">{o}</Badge>)}
|
||||
</div>
|
||||
<div>{DENSITY_LABELS[params.density as string]} · {params.count as number} 张</div>
|
||||
{params.note != null && String(params.note) && <div className="text-gray-400 italic">"{String(params.note)}"</div>}
|
||||
</div>
|
||||
)}
|
||||
{job.status === "completed" && !Boolean(job.batch_registered) && (
|
||||
<button onClick={() => handleIngest(job.id as string)}
|
||||
className="mt-2 text-xs text-blue-600 hover:underline">入库到训练集 →</button>
|
||||
)}
|
||||
{Boolean(job.batch_registered) && (
|
||||
<span className="mt-2 text-xs text-green-600 inline-block">✓ 已入库: {String(job.batch_name ?? "")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
platform/web/src/modules/labeling/pages/WorkbenchPage.tsx
Normal file
200
platform/web/src/modules/labeling/pages/WorkbenchPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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 { StageBadge, Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ScanItem = { project: string; task: string; batch: string; path: string; images: number; labels: number; has_labels: boolean; stage_hint: string };
|
||||
|
||||
export const WorkbenchPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState("raw_pool");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredBatches = batches.filter((b) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
// Scan inbox
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanItems, setScanItems] = useState<ScanItem[]>([]);
|
||||
const [showScan, setShowScan] = useState(false);
|
||||
const [registering, setRegistering] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (filterStage: string) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.labelingBatches({ stage: filterStage, limit: 100 });
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(stage); }, [stage, load]);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true); setError(null);
|
||||
try {
|
||||
const dms = await hsapApi.scanInbox("dms");
|
||||
setScanItems((dms.items || []) as unknown as ScanItem[]);
|
||||
setShowScan(true);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const handleRegister = async (item: ScanItem) => {
|
||||
setRegistering(item.batch);
|
||||
try {
|
||||
await hsapApi.registerBatch({
|
||||
project: item.project, task: item.task, batch: item.batch,
|
||||
stage: item.stage_hint, location: "inbox",
|
||||
});
|
||||
setScanItems((prev) => prev.filter((s) => s.batch !== item.batch));
|
||||
load(stage);
|
||||
setInfo(`已登记: ${item.task}/${item.batch}`);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setRegistering(null);
|
||||
};
|
||||
|
||||
const handleRegisterAll = async () => {
|
||||
let count = 0;
|
||||
for (const item of scanItems) {
|
||||
try {
|
||||
await hsapApi.registerBatch({
|
||||
project: item.project, task: item.task, batch: item.batch,
|
||||
stage: item.stage_hint, location: "inbox",
|
||||
});
|
||||
count++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setScanItems([]); setShowScan(false);
|
||||
load(stage);
|
||||
setInfo(`已登记 ${count} 个批次`);
|
||||
};
|
||||
|
||||
const handleOpenCampaign = async (row: LabelingBatchRow) => {
|
||||
if (!row.task) return;
|
||||
try {
|
||||
await hsapApi.openLabelingCampaign({
|
||||
project: row.project, task: row.task, batch: row.batch,
|
||||
mode: row.mode || null, pack: row.pack || null, location: row.location,
|
||||
});
|
||||
load(stage);
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const STAGES = [
|
||||
{ key: "raw_pool", label: "待送标" },
|
||||
{ key: "out_for_labeling", label: "标中" },
|
||||
{ key: "returned", label: "待入库" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>送标工作台</h1>
|
||||
<p>管理数据标注批次的全生命周期</p>
|
||||
</div>
|
||||
<Button variant="primary" size="small" onClick={handleScan} loading={scanning}>
|
||||
扫描入库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
{/* Scan results panel */}
|
||||
{showScan && (
|
||||
<div className="card mb-4 border-blue-200 bg-blue-50/30">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>扫描结果 — 发现 {scanItems.length} 个未登记批次</span>
|
||||
<div className="flex gap-2">
|
||||
{scanItems.length > 0 && <Button size="small" variant="primary" onClick={handleRegisterAll}>全部登记</Button>}
|
||||
<Button size="small" variant="default" onClick={() => setShowScan(false)}>收起</Button>
|
||||
</div>
|
||||
</div>
|
||||
{scanItems.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">所有 inbox 数据均已登记</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr><th>任务</th><th>批次</th><th>图片</th><th>标注</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanItems.map((item) => (
|
||||
<tr key={`${item.task}/${item.batch}`}>
|
||||
<td className="font-medium">{item.task}</td>
|
||||
<td>{item.batch}</td>
|
||||
<td>{item.images}</td>
|
||||
<td>{item.has_labels ? <Badge variant="success" size="small">{item.labels} 个</Badge> : <span className="text-gray-400">无标注</span>}</td>
|
||||
<td><StageBadge stage={item.stage_hint} /></td>
|
||||
<td>
|
||||
<Button size="small" variant="primary" loading={registering === item.batch} onClick={() => handleRegister(item)}>
|
||||
登记入库
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage filter + Search */}
|
||||
<div className="flex gap-2 mb-4 items-center flex-wrap">
|
||||
{STAGES.map((s) => (
|
||||
<button key={s.key} onClick={() => setStage(s.key)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${stage === s.key ? "bg-blue-700 text-white" : "bg-white border border-gray-300 text-gray-600 hover:bg-gray-50"}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<input className="form-input w-40" placeholder="搜索批次..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filteredBatches.length === 0}>
|
||||
<div className="space-y-2">
|
||||
{filteredBatches.map((b) => (
|
||||
<div key={`${b.project}/${b.task}/${b.batch}`} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.project}/{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>🖼 {b.counts?.images ?? 0}</span>
|
||||
<span>🏷 {b.counts?.labels ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "raw_pool" && (
|
||||
<Button size="small" variant="primary" onClick={() => handleOpenCampaign(b)}>开标</Button>
|
||||
)}
|
||||
{b.stage === "out_for_labeling" && b.campaign_id && (
|
||||
<a href={`/labeling/campaigns/${b.campaign_id}/annotate`} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
✏️ 进入标注
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// FORCE REBUILD 1780387552
|
||||
// UNIQUE_MARKER_1780387674745592125
|
||||
// CARD_LAYOUT_MARKER_v2_20260602
|
||||
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>
|
||||
);
|
||||
};
|
||||
49
platform/web/src/modules/system/SystemShell.tsx
Normal file
49
platform/web/src/modules/system/SystemShell.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { AuditQueuePage } from "./pages/AuditQueuePage";
|
||||
import { AuditDetailPage } from "./pages/AuditDetailPage";
|
||||
import { JobMonitorPage } from "./pages/JobMonitorPage";
|
||||
import { ExecutionLogsPage } from "./pages/ExecutionLogsPage";
|
||||
import { UserManagementPage } from "./pages/UserManagementPage";
|
||||
import { AuditLogPage } from "./pages/AuditLogPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/system/audit", label: "审核队列", perm: "read:audit" },
|
||||
{ to: "/system/jobs", label: "任务监控", perm: "read:jobs" },
|
||||
{ to: "/system/audit-log", label: "操作日志", perm: "admin:users" },
|
||||
{ to: "/system/logs", label: "Agent 追踪", perm: "" },
|
||||
{ to: "/system/users", label: "用户管理", perm: "admin:users" },
|
||||
];
|
||||
|
||||
export const SystemShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:audit", "read:jobs", "admin:users", ""]}>
|
||||
<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="/system/audit" />} />
|
||||
<Route exact path={`${path}/audit`} component={AuditQueuePage} />
|
||||
<Route path={`${path}/audit/:id`} component={AuditDetailPage} />
|
||||
<Route path={`${path}/jobs`} component={JobMonitorPage} />
|
||||
<Route path={`${path}/audit-log`} component={AuditLogPage} />
|
||||
<Route path={`${path}/logs`} component={ExecutionLogsPage} />
|
||||
<Route path={`${path}/users`} component={UserManagementPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
212
platform/web/src/modules/system/pages/AuditDetailPage.tsx
Normal file
212
platform/web/src/modules/system/pages/AuditDetailPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type RejectionCategory = { key: string; label: string };
|
||||
|
||||
export const AuditDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { hasPermission: can } = useAuth();
|
||||
const isReviewer = can("write:approval_review") || can("*");
|
||||
|
||||
const [approval, setApproval] = useState<Record<string, unknown> | null>(null);
|
||||
const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
|
||||
const [images, setImages] = useState<{ id: string; url?: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [rejectCategory, setRejectCategory] = useState("");
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [categories, setCategories] = useState<RejectionCategory[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
hsapApi.getApproval(id),
|
||||
hsapApi.getApprovalPreview(id).catch(() => null),
|
||||
]);
|
||||
setApproval(a); setPreview(p as Record<string, unknown> | null);
|
||||
const imgRes = await hsapApi.listApprovalImages(id, 0, 60).catch(() => null);
|
||||
const items = (imgRes?.items || []) as { id: string }[];
|
||||
const withUrls = await Promise.all(items.slice(0, 12).map(async (img) => {
|
||||
try { return { ...img, url: await hsapApi.fetchApprovalImageBlob(id, img.id, true) }; }
|
||||
catch { return img; }
|
||||
}));
|
||||
setImages(withUrls);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [id]);
|
||||
useEffect(() => {
|
||||
if (isReviewer) { hsapApi.approvalRejectionCategories().then((r) => setCategories(r.categories || [])); }
|
||||
}, [isReviewer]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setBusy(true);
|
||||
try { await hsapApi.approve(id!); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setBusy(true);
|
||||
try { await hsapApi.reject(id!, rejectComment || undefined, rejectCategory || undefined); load(); setRejecting(false); }
|
||||
catch (e) { setError(String(e)); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<Link to="/system/audit" className="text-blue-600 text-sm hover:underline mb-2 inline-block">← 返回审核队列</Link>
|
||||
<h1>审核详情</h1>
|
||||
<p className="font-mono text-xs text-gray-400">ID: {id}</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{approval && (
|
||||
<>
|
||||
{/* Summary card */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>{approval.action_label as string || approval.action as string || "审核单"}</span>
|
||||
<StatusBadge status={(approval.status as string) || "pending"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">提单人: </span><span>{approval.submitted_by as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">审核人: </span><span>{approval.reviewed_by as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">提交时间: </span><span className="text-xs">{approval.submitted_at as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">审核时间: </span><span className="text-xs">{approval.reviewed_at as string || "—"}</span>
|
||||
</div>
|
||||
{approval.note != null && String(approval.note) && <div className="col-span-2"><span className="text-gray-500">备注: </span>{String(approval.note)}</div>}
|
||||
{approval.review_comment != null && String(approval.review_comment) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">审核意见: </span>
|
||||
<span className="text-gray-800">{String(approval.review_comment ?? "")}</span>
|
||||
</div>
|
||||
)}
|
||||
{approval.rejection_category != null && String(approval.rejection_category) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">驳回分类: </span>
|
||||
<Badge variant="danger">{String(approval.rejection_category ?? "")}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job link */}
|
||||
{approval.job_id != null && String(approval.job_id) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">关联任务: </span>
|
||||
<Link to={`/system/jobs`} className="text-blue-600 text-sm hover:underline font-mono">
|
||||
{String(approval.job_id ?? "")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview - structured view */}
|
||||
{preview && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">变更详情</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{(preview.task as string) && (
|
||||
<div><span className="text-gray-500">任务: </span><Badge variant="info">{preview.task as string}</Badge></div>
|
||||
)}
|
||||
{(preview.pack as string) && (
|
||||
<div><span className="text-gray-500">数据包: </span><Badge variant="default">{preview.pack as string}</Badge></div>
|
||||
)}
|
||||
{(preview.scope_label as string) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">范围: </span>
|
||||
<span>{String(preview.scope_label ?? "")}</span>
|
||||
</div>
|
||||
)}
|
||||
{(preview.class_names as Record<string, string>) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500 text-xs">类别: </span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(preview.class_names as Record<string, string>).map(([k, v]) => (
|
||||
<Badge key={k} size="small" variant="default">{v || k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Params detail */}
|
||||
{approval.params != null && typeof approval.params === "object" && Object.keys(approval.params as Record<string, unknown>).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 font-semibold">参数明细</span>
|
||||
<pre className="text-xs mt-1 bg-gray-50 p-2 rounded max-h-40 overflow-auto">
|
||||
{JSON.stringify(approval.params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">图片预览 ({images.length})</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="w-24 h-24 bg-gray-100 rounded overflow-hidden">
|
||||
{img.url ? (
|
||||
<img src={img.url} alt={img.id} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-xs">{img.id.slice(0, 8)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === "pending" && isReviewer && (
|
||||
<div className="card">
|
||||
{!rejecting ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="success" onClick={handleApprove} loading={busy}>✓ 通过</Button>
|
||||
<Button variant="danger" onClick={() => setRejecting(true)}>✗ 驳回</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">驳回审核</p>
|
||||
<div className="space-y-3">
|
||||
<select className="form-input max-w-xs" value={rejectCategory} onChange={(e) => setRejectCategory(e.target.value)}>
|
||||
<option value="">选择驳回原因分类</option>
|
||||
{categories.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<textarea className="form-input max-w-md" value={rejectComment} onChange={(e) => setRejectComment(e.target.value)} placeholder="补充说明(可选)" rows={3} />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="danger" onClick={handleReject} loading={busy} disabled={!rejectCategory}>确认驳回</Button>
|
||||
<Button variant="default" onClick={() => { setRejecting(false); setRejectCategory(""); setRejectComment(""); }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
platform/web/src/modules/system/pages/AuditLogPage.tsx
Normal file
111
platform/web/src/modules/system/pages/AuditLogPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type LogEntry = {
|
||||
id: number; timestamp: string; user_id: number; user_name: string;
|
||||
category: string; action: string; target_type: string; target_id: string;
|
||||
summary: string; detail: Record<string, unknown> | null; ip_address: string;
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { label: string; color: "info" | "success" | "warning" | "danger" | "default" }> = {
|
||||
auth: { label: "认证", color: "info" },
|
||||
data: { label: "数据", color: "success" },
|
||||
labeling: { label: "标注", color: "warning" },
|
||||
audit: { label: "审核", color: "danger" },
|
||||
training: { label: "训练", color: "info" },
|
||||
system: { label: "系统", color: "default" },
|
||||
};
|
||||
|
||||
export const AuditLogPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 30) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set("category", category);
|
||||
if (search) params.set("search", search);
|
||||
params.set("offset", String(newOffset));
|
||||
params.set("limit", String(newLimit));
|
||||
const res = await fetch(`/api/v1/system/audit-log?${params}`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setLogs((res.items || []) as LogEntry[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [category, search]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const catBadge = (cat: string) => {
|
||||
const c = CATEGORY_LABELS[cat] || { label: cat, color: "default" as const };
|
||||
return <Badge variant={c.color} size="small">{c.label}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>操作日志</h1>
|
||||
<p>平台所有关键操作的审计记录,保留 90 天</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select className="form-input w-auto" value={category} onChange={(e) => { setCategory(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<input className="form-input w-48" placeholder="搜索用户/摘要..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={logs.length === 0} emptyMessage="暂无操作记录">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="space-y-0 divide-y divide-gray-100">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="py-2.5 px-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Userpic username={log.user_name} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{log.user_name || "系统"}</span>
|
||||
{catBadge(log.category)}
|
||||
<span className="text-sm text-gray-700 truncate">{log.summary || log.action}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-gray-400">
|
||||
<span>{log.timestamp || "—"}</span>
|
||||
{log.target_type && <span>· {log.target_type}/{log.target_id?.slice(0, 16)}</span>}
|
||||
<button onClick={() => setExpandedId(expandedId === log.id ? null : log.id)} className="text-blue-500 hover:underline">
|
||||
{expandedId === log.id ? "收起" : "详情"}
|
||||
</button>
|
||||
</div>
|
||||
{expandedId === log.id && log.detail && (
|
||||
<pre className="text-xs mt-1 bg-gray-50 p-2 rounded max-h-32 overflow-auto">
|
||||
{JSON.stringify(log.detail, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
platform/web/src/modules/system/pages/AuditQueuePage.tsx
Normal file
200
platform/web/src/modules/system/pages/AuditQueuePage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type RejectionCategory = { key: string; label: string };
|
||||
|
||||
export const AuditQueuePage: React.FC = () => {
|
||||
const { hasPermission: can } = useAuth();
|
||||
const isReviewer = can("write:approval_review") || can("*");
|
||||
const [approvals, setApprovals] = useState<Record<string, unknown>[]>([]);
|
||||
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 [filterStatus, setFilterStatus] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchMode, setBatchMode] = useState<"" | "approve" | "reject">("");
|
||||
const [rejectCategory, setRejectCategory] = useState("");
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const [batchBusy, setBatchBusy] = useState(false);
|
||||
const [categories, setCategories] = useState<RejectionCategory[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listApprovals({ status: filterStatus || undefined, offset: newOffset, limit: newLimit });
|
||||
let items = (res.items || []) as Record<string, unknown>[];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((a) =>
|
||||
String(a.action_label || a.action || "").toLowerCase().includes(q) ||
|
||||
String(a.submitted_by || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
setApprovals(items);
|
||||
setTotal(search ? items.length : res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [filterStatus]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
if (isReviewer) { hsapApi.approvalRejectionCategories().then((r) => setCategories(r.categories || [])); }
|
||||
}, [isReviewer]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === approvals.length) { setSelected(new Set()); }
|
||||
else { setSelected(new Set(approvals.filter((a) => a.status === "pending").map((a) => a.id as string))); }
|
||||
};
|
||||
|
||||
const handleBatchAction = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setBatchBusy(true);
|
||||
const ids = [...selected];
|
||||
try {
|
||||
if (batchMode === "approve") {
|
||||
await hsapApi.batchApprove(ids);
|
||||
} else if (batchMode === "reject") {
|
||||
await hsapApi.batchReject(ids, rejectComment || undefined, rejectCategory || undefined);
|
||||
}
|
||||
setSelected(new Set());
|
||||
setBatchMode("");
|
||||
load(offset, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setBatchBusy(false);
|
||||
};
|
||||
|
||||
const handleSingleApprove = async (id: string) => {
|
||||
try { await hsapApi.approve(id); load(offset, limit); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>审核队列</h1>
|
||||
<p>审核平台中所有操作请求</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter + Batch bar */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<input className="form-input w-44" placeholder="搜索操作/提单人..." value={search} onChange={(e) => { setSearch(e.target.value); }} />
|
||||
<select className="form-input w-auto" value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setSelected(new Set()); }}>
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
</select>
|
||||
{isReviewer && selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">{selected.size} 项已选</span>
|
||||
{!batchMode ? (
|
||||
<>
|
||||
<Button size="small" variant="success" onClick={() => setBatchMode("approve")}>批量通过</Button>
|
||||
<Button size="small" variant="danger" onClick={() => setBatchMode("reject")}>批量驳回</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-sm font-medium">{batchMode === "approve" ? "批量通过" : "批量驳回"}</span>
|
||||
{batchMode === "reject" && (
|
||||
<>
|
||||
<select className="form-input w-auto text-xs" value={rejectCategory} onChange={(e) => setRejectCategory(e.target.value)}>
|
||||
<option value="">选择原因</option>
|
||||
{categories.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<input className="form-input w-40 text-xs" value={rejectComment} onChange={(e) => setRejectComment(e.target.value)} placeholder="备注(可选)" />
|
||||
</>
|
||||
)}
|
||||
<Button size="small" variant={batchMode === "approve" ? "success" : "danger"} onClick={handleBatchAction} loading={batchBusy}>确认</Button>
|
||||
<Button size="small" variant="default" onClick={() => { setBatchMode(""); setRejectCategory(""); setRejectComment(""); }}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={approvals.length === 0} emptyMessage="暂无审核记录">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{isReviewer && <th className="w-8"><input type="checkbox" onChange={toggleAll} checked={selected.size > 0 && selected.size === approvals.filter((a) => a.status === "pending").length} /></th>}
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>详情</th>
|
||||
<th>提单人</th>
|
||||
<th>审核人</th>
|
||||
<th>驳回原因</th>
|
||||
<th>时间</th>
|
||||
{isReviewer && <th>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.map((a) => {
|
||||
const params = a.params as Record<string, unknown> | undefined;
|
||||
const detail = params
|
||||
? Object.entries(params).filter(([k]) => !["project", "submitted_by"].includes(k)).map(([k, v]) => `${k}=${v}`).join(", ")
|
||||
: "";
|
||||
return (
|
||||
<tr key={a.id as string} className={a.status === "pending" && selected.has(a.id as string) ? "bg-blue-50" : ""}>
|
||||
{isReviewer && (
|
||||
<td>
|
||||
{a.status === "pending" && (
|
||||
<input type="checkbox" checked={selected.has(a.id as string)} onChange={() => toggleSelect(a.id as string)} />
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Link to={`/system/audit/${a.id}`} className="text-blue-600 hover:underline text-sm font-medium">
|
||||
{a.action_label as string || a.action as string || "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td><StatusBadge status={(a.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500 max-w-xs truncate">{detail || "—"}</td>
|
||||
<td>{a.submitted_by as string || "—"}</td>
|
||||
<td>{a.reviewed_by as string || "—"}</td>
|
||||
<td className="text-xs">
|
||||
{a.status === "rejected" && a.rejection_category ? (
|
||||
<Badge variant="danger" size="small">{a.rejection_category as string}</Badge>
|
||||
) : a.status === "rejected" ? (
|
||||
<span className="text-gray-400">—</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-xs text-gray-500 whitespace-nowrap">{a.submitted_at as string || a.created_at as string || "—"}</td>
|
||||
{isReviewer && (
|
||||
<td>
|
||||
{a.status === "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<Button size="small" variant="success" onClick={() => handleSingleApprove(a.id as string)}>通过</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
platform/web/src/modules/system/pages/ExecutionLogsPage.tsx
Normal file
91
platform/web/src/modules/system/pages/ExecutionLogsPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
export const ExecutionLogsPage: React.FC = () => {
|
||||
const [traces, setTraces] = useState<string[]>([]);
|
||||
const [selectedTrace, setSelectedTrace] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTraces = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listTraces(50);
|
||||
setTraces(res.trace_ids || []);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { loadTraces(); }, []);
|
||||
|
||||
const handleViewTrace = async (traceId: string) => {
|
||||
try {
|
||||
const t = await hsapApi.getTrace(traceId);
|
||||
setSelectedTrace(t);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>执行日志</h1>
|
||||
<p>Agent 执行 Trace 与 Span 日志查看</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Trace list */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>Trace 列表</span>
|
||||
<Button size="small" variant="default" onClick={loadTraces}>刷新</Button>
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={traces.length === 0} emptyMessage="暂无 Trace 记录">
|
||||
<div className="max-h-96 overflow-y-auto space-y-1">
|
||||
{traces.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleViewTrace(t)}
|
||||
className={`w-full text-left px-3 py-2 text-xs font-mono rounded hover:bg-blue-50 transition-colors ${
|
||||
selectedTrace && (selectedTrace.trace_id as string) === t ? "bg-blue-100 text-blue-800" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t.slice(0, 32)}...
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
|
||||
{/* Trace detail */}
|
||||
<div className="card col-span-2">
|
||||
<div className="card-header">Trace 详情</div>
|
||||
{selectedTrace ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2 font-mono">
|
||||
Trace ID: {selectedTrace.trace_id as string}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Spans: {(selectedTrace.spans as unknown[])?.length || 0}
|
||||
</p>
|
||||
<pre className="text-xs overflow-auto max-h-96 bg-gray-50 p-3 rounded">
|
||||
{JSON.stringify(selectedTrace.spans, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
请从左侧选择一个 Trace 查看详情
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
platform/web/src/modules/system/pages/JobMonitorPage.tsx
Normal file
108
platform/web/src/modules/system/pages/JobMonitorPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState, useCallback } 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";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
export const JobMonitorPage: React.FC = () => {
|
||||
const [jobs, setJobs] = useState<Record<string, unknown>[]>([]);
|
||||
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 [filterStatus, setFilterStatus] = useState("");
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listJobs({
|
||||
status: filterStatus || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setJobs((res.items || []) as Record<string, unknown>[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [filterStatus]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => load(offset, limit), 10000);
|
||||
return () => clearInterval(t);
|
||||
}, [offset, limit, load]);
|
||||
|
||||
const handleView = async (jobId: string) => {
|
||||
try {
|
||||
const job = await hsapApi.getJob(jobId);
|
||||
alert(JSON.stringify(job, null, 2));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>任务监控</h1>
|
||||
<p>查看异步任务执行状态,每 10 秒自动刷新</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>手动刷新</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<select className="form-input w-auto" value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={jobs.length === 0} emptyMessage="暂无任务记录">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>完成时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id as string}>
|
||||
<td className="font-mono text-xs">{(j.id as string || "").slice(0, 16)}...</td>
|
||||
<td>{j.action as string || "—"}</td>
|
||||
<td><StatusBadge status={(j.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500">{j.created_at as string || "—"}</td>
|
||||
<td className="text-xs text-gray-500">{j.completed_at as string || "—"}</td>
|
||||
<td>
|
||||
<Button size="small" variant="default" onClick={() => handleView(j.id as string)}>
|
||||
详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
215
platform/web/src/modules/system/pages/UserManagementPage.tsx
Normal file
215
platform/web/src/modules/system/pages/UserManagementPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type UserRecord = {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
feishu_open_id?: string;
|
||||
feishu_union_id?: string;
|
||||
feishu_user_id?: string;
|
||||
feishu_department_ids?: string[];
|
||||
roles: { code: string; name: string }[];
|
||||
permissions: string[];
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ code: "admin", name: "管理员", color: "danger" as const },
|
||||
{ code: "reviewer", name: "审核员", color: "warning" as const },
|
||||
{ code: "engineer", name: "工程师", color: "info" as const },
|
||||
{ code: "labeler", name: "标注员", color: "success" as const },
|
||||
{ code: "viewer", name: "观察者", color: "default" as const },
|
||||
];
|
||||
|
||||
export const UserManagementPage: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
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 [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [editingUser, setEditingUser] = useState<UserRecord | null>(null);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listUsers({
|
||||
search: search || undefined,
|
||||
role: roleFilter || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setUsers((res.items || []) as unknown as UserRecord[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search, roleFilter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleEditRoles = (user: UserRecord) => {
|
||||
setEditingUser(user);
|
||||
setSelectedRoles(user.roles.map((r) => r.code));
|
||||
};
|
||||
|
||||
const handleSaveRoles = async () => {
|
||||
if (!editingUser) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await hsapApi.setUserRoles(editingUser.id, selectedRoles);
|
||||
setEditingUser(null);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const roleBadge = (code: string) => {
|
||||
const opt = ROLE_OPTIONS.find((r) => r.code === code);
|
||||
return <Badge key={code} variant={opt?.color || "default"} size="small">{opt?.name || code}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>管理平台用户角色权限,关联飞书账号信息</p>
|
||||
</div>
|
||||
<Button variant="primary" size="small" onClick={async () => {
|
||||
try {
|
||||
const res = await hsapApi.syncFeishuUsers();
|
||||
alert(`同步完成!共 ${res.total} 人(新增 ${res.created},更新 ${res.updated})`);
|
||||
load(0, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
}}>
|
||||
同步飞书用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
className="form-input max-w-xs"
|
||||
placeholder="搜索姓名或邮箱..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOffset(0); }}
|
||||
/>
|
||||
<select className="form-input w-auto" value={roleFilter} onChange={(e) => { setRoleFilter(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部角色</option>
|
||||
{ROLE_OPTIONS.map((r) => <option key={r.code} value={r.code}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={users.length === 0} emptyMessage="暂无用户">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>邮箱</th>
|
||||
<th>飞书 ID</th>
|
||||
<th>飞书部门</th>
|
||||
<th>角色</th>
|
||||
<th>权限数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Userpic username={u.name} avatarUrl={u.avatar_url} size={28} />
|
||||
<span className="font-medium">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">{u.email || "—"}</td>
|
||||
<td className="text-xs font-mono text-gray-400">
|
||||
{u.feishu_user_id ? (
|
||||
<span title={`OpenID: ${u.feishu_open_id || ""} UnionID: ${u.feishu_union_id || ""}`}>
|
||||
{(u.feishu_user_id as string).slice(0, 12)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">非飞书用户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">
|
||||
{u.feishu_department_ids?.length ? `${u.feishu_department_ids.length} 个部门` : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1 flex-wrap">{u.roles.map((r) => roleBadge(r.code))}</div>
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">{u.permissions?.length || 0}</td>
|
||||
<td>
|
||||
<Button size="small" variant="default" onClick={() => handleEditRoles(u)}>编辑角色</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
|
||||
{/* Role Edit Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setEditingUser(null)}>
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-1">编辑角色 — {editingUser.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{editingUser.email || "—"}</p>
|
||||
|
||||
{editingUser.feishu_open_id && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-xs text-gray-500 space-y-1">
|
||||
<p><span className="font-medium">飞书 Open ID:</span> {editingUser.feishu_open_id}</p>
|
||||
{editingUser.feishu_union_id && <p><span className="font-medium">Union ID:</span> {editingUser.feishu_union_id}</p>}
|
||||
{editingUser.feishu_user_id && <p><span className="font-medium">User ID:</span> {editingUser.feishu_user_id}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label key={role.code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 rounded p-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles.includes(role.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedRoles([...selectedRoles, role.code]);
|
||||
else setSelectedRoles(selectedRoles.filter((r) => r !== role.code));
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<Badge variant={role.color} size="small">{role.name}</Badge>
|
||||
<span className="text-xs text-gray-400">{role.code}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="default" size="small" onClick={() => setEditingUser(null)}>取消</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSaveRoles} loading={saving}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user