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

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

View File

@@ -0,0 +1,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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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} />;
};

View 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>
);
};

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};