feat: 合并 Docker Compose、标注表格优化与部署文档

将 platform + CVAT 合并为单文件 docker-compose.yml,完善 .env 与 init/dev_up 脚本;
新增 docs/DEPLOY.md 与更新 README 以支持新机器部署;含数据湖示例、车队地图、
紧凑表格 UI、ADAS det_7cls 路径与批次台账等近期改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 17:06:31 +08:00
parent 0b8ade048e
commit 483e027482
117 changed files with 5933 additions and 1499 deletions

View File

@@ -8,11 +8,14 @@
"name": "hsap-platform-web",
"version": "1.1.0",
"dependencies": {
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^5.3.4"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
@@ -807,6 +810,17 @@
"node": ">= 8"
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1216,6 +1230,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/history": {
"version": "4.7.11",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
@@ -1223,6 +1244,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1898,6 +1929,12 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2323,6 +2360,20 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -9,11 +9,14 @@
"preview": "vite preview"
},
"dependencies": {
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^5.3.4"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -30,7 +30,7 @@ const MODULES: ModuleDef[] = [
{ to: "/labeling/my-tasks", label: "我的标注", perm: "read:pending" },
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending" },
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending" },
{ to: "/labeling/review", 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" },
@@ -148,7 +148,8 @@ export const Sidebar: React.FC = () => {
{/* Brand */}
<div className="sidebar-brand">
<NavLink to="/" className="no-underline text-inherit">
HSAP <span className="text-gray-400 font-normal text-sm"></span>
HSAP
<span className="text-gray-400 font-normal text-[11px]"></span>
</NavLink>
</div>

View File

@@ -76,15 +76,35 @@ export const hsapApi = {
health: () => fetchJson<{ status: string; database: string }>(`${API_BASE}/api/v1/health`),
// ── Labeling ──
labelingBatches: (opts?: { stage?: string; offset?: number; limit?: number }) => {
labelingBatches: (opts?: {
stage?: string;
stages?: string[];
offset?: number;
limit?: number;
refresh?: boolean;
q?: string;
}) => {
const p = new URLSearchParams();
if (opts?.stage) p.set("stage", opts.stage);
if (opts?.stages?.length) p.set("stages", opts.stages.join(","));
else if (opts?.stage) p.set("stage", opts.stage);
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
const q = p.toString();
return fetchJson<PagedResult<LabelingBatchRow>>(`${API_BASE}/api/v1/labeling/batches${q ? `?${q}` : ""}`);
if (opts?.refresh) p.set("refresh", "true");
if (opts?.q) p.set("q", opts.q);
const qs = p.toString();
return fetchJson<PagedResult<LabelingBatchRow> & { source?: string; updated_at?: string }>(
`${API_BASE}/api/v1/labeling/batches${qs ? `?${qs}` : ""}`,
);
},
rebuildBatchIndex: () =>
postJson<{ ok: boolean; count: number; elapsed_ms: number }>(`${API_BASE}/api/v1/labeling/batches/rebuild-index`),
archiveLabelingBatch: (campaignId: string) =>
postJson<{ ok: boolean; campaign_id: string }>(
`${API_BASE}/api/v1/labeling/batches/${encodeURIComponent(campaignId)}/archive`,
),
openLabelingCampaign: (body: { project: string; task: string; batch: string; mode?: string | null; pack?: string | null; location?: string }) =>
postJson(`${API_BASE}/api/v1/labeling/campaigns/open`, body),
@@ -165,6 +185,46 @@ export const hsapApi = {
labelingExportStats: (campaignId: string) =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export-stats`),
fetchReviewImageBlob: async (campaignId: string, imagePath: string) => {
const q = new URLSearchParams({ path: imagePath });
const res = await fetch(
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-image?${q}`,
{ headers: authHeaders(), cache: "no-store" },
);
if (!res.ok) throw new Error(await res.text());
return URL.createObjectURL(await res.blob());
},
reviewProgress: (campaignId: string) =>
fetchJson<{
good: number; fine: number; bad: number; pending: number;
total: number; reviewed: number; pass_rate: number; complete: boolean; stage?: string;
}>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-progress`),
reviewProgressBatch: (campaignIds: string[]) =>
fetchJson<{ items: Record<string, {
good: number; fine: number; bad: number; pending: number;
total: number; reviewed: number; pass_rate: number; complete: boolean; stage?: string;
}> }>(`${API_BASE}/api/v1/labeling/review-progress?campaign_ids=${encodeURIComponent(campaignIds.join(","))}`),
reviewQueue: (campaignId: string, opts?: { offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
const q = p.toString();
return fetchJson<{
items: { id: string; image_path: string; fileName: string; score: string; has_label: boolean }[];
total: number;
scores: { good: number; fine: number; bad: number; pending: number };
}>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-queue${q ? `?${q}` : ""}`);
},
reviewSubmit: (campaignId: string, scores: { image_path: string; score: string }[]) =>
postJson<{ ok: boolean; auto_advanced?: boolean; stage?: string }>(
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-submit`,
{ scores },
),
cuboidFit: (campaignId: string) =>
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/cuboid-fit`),
@@ -285,6 +345,25 @@ export const hsapApi = {
registerBatch: (body: Record<string, unknown>) => postJson(`${API_BASE}/api/v1/register-batch`, body),
// ── Deliveries ──
scanDeliveries: (projects?: string[]) => {
const p = new URLSearchParams();
if (projects?.length) p.set("projects", projects.join(","));
const qs = p.toString();
return fetchJson<{
items: Record<string, unknown>[];
count: number;
needs_ledger: number;
needs_workbench: number;
scanned_at?: string;
}>(`${API_BASE}/api/v1/deliveries/scan${qs ? `?${qs}` : ""}`);
},
registerScannedDeliveries: (items: Record<string, unknown>[], syncWorkbench = true) =>
postJson<{ ok: boolean; created: number; updated: number; synced_workbench: number }>(
`${API_BASE}/api/v1/deliveries/scan/register`,
{ items, sync_workbench: syncWorkbench },
),
listDeliveries: (opts?: { status?: string; mine?: boolean; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.status) p.set("status", opts.status);
@@ -465,7 +544,7 @@ export const hsapApi = {
},
setUserRoles: (userId: number, roles: string[]) =>
putJson(`${API_BASE}/api/v1/auth/users/${userId}/roles`, { roles }),
putJson(`${API_BASE}/api/v1/auth/users/${userId}/roles`, { role_codes: roles }),
syncFeishuUsers: () =>
postJson<{ ok: boolean; created: number; updated: number; total: number }>(`${API_BASE}/api/v1/system/feishu/sync-users`),

View File

@@ -0,0 +1,25 @@
import React from "react";
type ColWidth = string | number;
type CompactTableShellProps = {
children: React.ReactNode;
colWidths?: ColWidth[];
};
export const CompactTableShell: React.FC<CompactTableShellProps> = ({ children, colWidths }) => (
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-hidden">
<table className="table-auto table-fixed w-full min-w-0 [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-center [&_td]:py-1.5 [&_td]:px-2 [&_td]:text-center">
{colWidths && colWidths.length > 0 && (
<colgroup>
{colWidths.map((w, i) => (
<col key={i} style={typeof w === "number" ? { width: w } : { width: w }} />
))}
</colgroup>
)}
{children}
</table>
</div>
</div>
);

View File

@@ -0,0 +1,23 @@
import React from "react";
interface TruncatedTextProps {
text: string;
className?: string;
maxWidthClass?: string;
}
export const TruncatedText: React.FC<TruncatedTextProps> = ({
text,
className = "",
maxWidthClass = "max-w-[12rem]",
}) => {
if (!text) return <span className={`${className} text-center`}></span>;
return (
<span
className={`block truncate cursor-default text-center mx-auto ${maxWidthClass} ${className}`}
title={text}
>
{text}
</span>
);
};

View File

@@ -63,7 +63,11 @@ export const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
failed: { variant: "danger", label: "失败" },
draft: { variant: "default", label: "草稿" },
submitted: { variant: "info", label: "已提交" },
pending_review: { variant: "warning", label: "待审核" },
ingesting: { variant: "info", label: "入湖中" },
in_lake: { variant: "success", label: "已入湖" },
ingested: { variant: "success", label: "已入湖" },
ingest_failed: { variant: "danger", label: "入湖失败" },
cancelled: { variant: "default", label: "已取消" },
};
const m = map[status] || { variant: "default" as const, label: status };

View File

@@ -0,0 +1,131 @@
import type { LabelingBatchRow } from "./types";
/** ADAS 3D MOONproject=adas, task=cuboid_7cls */
export function isAdas3dScope(b: LabelingBatchRow): boolean {
const project = b.project || "dms";
const task = b.task || "";
return project === "adas" && task === "cuboid_7cls";
}
/** ADAS 2D 七类project=adas, task=det_7cls兼容旧 dms+adas */
export function isAdas2dScope(b: LabelingBatchRow): boolean {
const project = b.project || "dms";
const task = b.task || "";
if (project === "adas" && task === "det_7cls") return true;
return project === "dms" && task === "adas";
}
/** 前向 ADAS 相关2D 七类、3D、交通标志 forward */
export function isAdasScope(b: LabelingBatchRow): boolean {
if (isAdas3dScope(b) || isAdas2dScope(b)) return true;
const project = b.project || "dms";
if (project === "lane") return false;
const task = b.task || "";
return b.domain === "forward" || task === "forward";
}
/** 列表「项目」列DMS / ADAS / 车道线 */
export function displayProject(b: LabelingBatchRow): string {
const project = b.project || "dms";
if (project === "lane") return "车道线";
if (isAdasScope(b)) return "ADAS";
return "DMS";
}
export function displayProjectFields(row: {
project?: string;
task?: string;
domain?: string;
}): string {
return displayProject(row as LabelingBatchRow);
}
export function displayTaskFields(row: {
project?: string;
task?: string;
mode?: string;
domain?: string;
task_label?: string;
mode_label?: string;
}): string {
return displayTask(row as LabelingBatchRow);
}
/** 列表「任务」列 */
export function displayTask(b: LabelingBatchRow): string {
const project = b.project || "dms";
if (project === "lane") return "车道线";
if (isAdas3dScope(b)) return "3D 七类";
if (isAdas2dScope(b)) return "2D 七类";
if (isAdasScope(b)) {
if (b.task === "forward") {
return b.mode_label || (b.mode === "classify" ? "细分类" : "粗检测");
}
return "2D";
}
const task = b.task || "";
const cabin: Record<string, string> = {
addw: "ADDW",
addw_face: "ADDW 人脸",
ddaw: "DDAW",
dam: "DAM",
};
if (cabin[task]) {
if (task === "dam" && b.mode_label) return `DAM · ${b.mode_label}`;
return cabin[task];
}
return b.task_label || task || "—";
}
/** 台账表单:业务线 → API project/task */
export type DeliveryLineKind =
| "dms"
| "adas_2d"
| "adas_3d"
| "forward"
| "lane";
export function deliveryLineToApi(line: DeliveryLineKind): { project: string; task: string; mode: string } {
switch (line) {
case "adas_2d":
return { project: "adas", task: "det_7cls", mode: "" };
case "adas_3d":
return { project: "adas", task: "cuboid_7cls", mode: "" };
case "forward":
return { project: "dms", task: "forward", mode: "" };
case "lane":
return { project: "lane", task: "lane_v1", mode: "" };
default:
return { project: "dms", task: "", mode: "" };
}
}
export function defaultInboxPath(line: DeliveryLineKind, batch: string, task?: string, mode?: string): string {
const b = batch.trim();
if (line === "adas_2d") return `/data/hsap/datasets/adas/inbox/det_7cls/${b}`;
if (line === "adas_3d") return `/data/hsap/datasets/adas/inbox/cuboid_7cls/${b}`;
if (line === "lane") return `/data/hsap/datasets/lane/inbox/${b}`;
if (line === "forward" && mode) return `/data/hsap/datasets/dms/inbox/forward/${mode}/${b}`;
if (task) return `/data/hsap/datasets/dms/inbox/${task}/${b}`;
return `/data/hsap/datasets/dms/inbox/{task}/${b}`;
}
/** @deprecated 用 deliveryLineToApi */
export function inferDeliveryProject(task: string, explicitProject?: string): string {
const t = (task || "").trim().toLowerCase();
if (explicitProject === "lane" || t === "lane" || t === "lane_v1") return "lane";
if (explicitProject === "adas" || t === "cuboid_7cls" || t.includes("cuboid") || t === "moon3d") return "adas";
return "dms";
}
/** 提交 build 时默认训练包 */
export function defaultBuildPack(b: LabelingBatchRow): string {
if (b.pack) return b.pack;
if (isAdas3dScope(b)) return "adas_moon3d_v1";
if (isAdas2dScope(b)) return "adas_v1";
if (b.project === "lane") return "lane_v1";
return "dms_v2";
}

View File

@@ -31,6 +31,7 @@ export type BatchRecord = {
export type LabelingBatchRow = BatchRecord & {
scope_key?: string;
domain?: string;
domain_label?: string;
task_label?: string;
mode_label?: string;

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { TileLayer, useMap } from "react-leaflet";
export type TileDef = {
url: string;
subdomains: string[];
attribution: string;
id: string;
};
const OSM_FALLBACK: TileDef = {
id: "osm",
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: ["a", "b", "c"],
attribution: "&copy; OpenStreetMap",
};
type Props = {
primary: TileDef;
onActiveChange?: (tile: TileDef) => void;
};
function TileErrorWatcher({ primary, onFallback }: { primary: TileDef; onFallback: () => void }) {
const map = useMap();
useEffect(() => {
let errors = 0;
const onError = () => {
errors += 1;
if (errors >= 3 && primary.id !== "osm") onFallback();
};
map.on("tileerror", onError);
return () => {
map.off("tileerror", onError);
};
}, [map, primary.id, onFallback]);
return null;
}
export function FallbackTileLayer({ primary, onActiveChange }: Props) {
const [active, setActive] = useState<TileDef>(primary);
useEffect(() => {
setActive(primary);
}, [primary.id, primary.url]);
useEffect(() => {
onActiveChange?.(active);
}, [active, onActiveChange]);
const fallback = () => {
if (active.id !== "osm") setActive(OSM_FALLBACK);
};
return (
<>
<TileLayer
key={active.id}
attribution={active.attribution}
url={active.url}
subdomains={active.subdomains}
/>
{active.id === primary.id && primary.id !== "osm" && (
<TileErrorWatcher primary={primary} onFallback={fallback} />
)}
</>
);
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo } from "react";
import { CircleMarker, MapContainer, Polyline, Popup, useMap } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { FallbackTileLayer, type TileDef } from "./FallbackTileLayer";
import "./fleet-map.css";
export type LiveVehicle = {
vehicle_id: number;
plate_no?: string;
lat?: number | null;
lng?: number | null;
last_lat?: number | null;
last_lng?: number | null;
status?: string;
online?: boolean;
};
export type MapConfig = {
provider?: string;
tileProvider?: string;
amapKey?: string;
};
function vehicleLat(v: LiveVehicle): number | null {
const lat = v.lat ?? v.last_lat;
return lat != null ? Number(lat) : null;
}
function vehicleLng(v: LiveVehicle): number | null {
const lng = v.lng ?? v.last_lng;
return lng != null ? Number(lng) : null;
}
function resolveTiles(cfg: MapConfig): TileDef {
const p = (cfg.tileProvider || cfg.provider || "gaode").toLowerCase();
if (p === "gaode") {
const base =
"https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}";
const url = cfg.amapKey ? `${base}&key=${cfg.amapKey}` : base;
return { id: "gaode", url, subdomains: ["1", "2", "3", "4"], attribution: "&copy; 高德" };
}
return {
id: "osm",
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: ["a", "b", "c"],
attribution: "&copy; OpenStreetMap",
};
}
function MapResizeFix() {
const map = useMap();
useEffect(() => {
const fix = () => map.invalidateSize();
fix();
const t1 = window.setTimeout(fix, 100);
const t2 = window.setTimeout(fix, 400);
const ro = new ResizeObserver(fix);
const el = map.getContainer().parentElement;
if (el) ro.observe(el);
return () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
ro.disconnect();
};
}, [map]);
return null;
}
function FitBounds({ points }: { points: [number, number][] }) {
const map = useMap();
useEffect(() => {
if (points.length < 1) return;
if (points.length === 1) {
map.setView(points[0], 14);
return;
}
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: 15 });
}, [map, points]);
return null;
}
type Props = {
mapConfig: MapConfig | null;
vehicles: LiveVehicle[];
className?: string;
};
export function FleetMapPanel({ mapConfig, vehicles, className }: Props) {
const tiles = useMemo(() => resolveTiles(mapConfig || {}), [mapConfig]);
const points = useMemo(() => {
const pts: [number, number][] = [];
for (const v of vehicles) {
const lat = vehicleLat(v);
const lng = vehicleLng(v);
if (lat != null && lng != null) pts.push([lat, lng]);
}
return pts;
}, [vehicles]);
const center: [number, number] = points[0] || [28.2, 112.98];
return (
<div
className={`fleet-map-shell rounded-lg border border-gray-200 overflow-hidden min-h-[420px] ${className || ""}`}
style={{ height: 420 }}
>
<MapContainer
center={center}
zoom={12}
className="h-full w-full"
style={{ height: "100%", minHeight: 420, width: "100%" }}
scrollWheelZoom
>
<FallbackTileLayer primary={tiles} />
<MapResizeFix />
<FitBounds points={points} />
{vehicles.map((v) => {
const lat = vehicleLat(v);
const lng = vehicleLng(v);
if (lat == null || lng == null) return null;
return (
<CircleMarker
key={v.vehicle_id}
center={[lat, lng]}
radius={8}
pathOptions={{ color: "#2563eb", fillColor: "#3b82f6", fillOpacity: 0.9 }}
>
<Popup>
{v.plate_no || v.vehicle_id} · {v.online ? "在线" : v.status || "—"}
</Popup>
</CircleMarker>
);
})}
</MapContainer>
</div>
);
}

View File

@@ -0,0 +1,14 @@
.fleet-map-shell {
position: relative;
}
.fleet-map-shell .leaflet-container {
height: 100% !important;
width: 100% !important;
font: inherit;
}
.fleet-map-shell .leaflet-pane,
.fleet-map-shell .leaflet-tile-pane {
z-index: 1;
}

View File

@@ -1,49 +1,72 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { hsapApi } from "@/app/hsap-api";
import { PageQueryState } from "@/components/PageQueryState";
import { Badge } from "@/components/ui/Badge";
import { FleetMapPanel, type LiveVehicle } from "../components/FleetMapPanel";
type LiveResponse = {
vehicles?: LiveVehicle[];
stats?: Record<string, unknown>;
};
export const LiveMapPage: React.FC = () => {
const [live, setLive] = useState<Record<string, unknown> | null>(null);
const [live, setLive] = useState<LiveResponse | 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);
const load = useCallback(async () => {
setError(null);
try {
const [l, m] = await Promise.all([hsapApi.fleetLive(), hsapApi.fleetMapConfig()]);
setLive(l);
setLive(l as LiveResponse);
setMapConfig(m);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
setLoading(false);
};
}, []);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
const pollSec = Number(mapConfig?.pollIntervalSec ?? 5);
const ms = Math.max(3, pollSec) * 1000;
const timer = window.setInterval(load, ms);
return () => window.clearInterval(timer);
}, [load, mapConfig?.pollIntervalSec]);
const vehicles = (live?.vehicles || []) as Record<string, unknown>[];
const vehicles = useMemo(
() =>
((live?.vehicles || []) as Record<string, unknown>[]).map((v) => ({
vehicle_id: Number(v.vehicle_id ?? v.id ?? 0),
plate_no: String(v.plate_no ?? ""),
lat: (v.lat ?? v.last_lat) as number | null | undefined,
lng: (v.lng ?? v.last_lng) as number | null | undefined,
last_lat: v.last_lat as number | null | undefined,
last_lng: v.last_lng as number | null | undefined,
speed_kmh: (v.speed_kmh ?? v.last_speed_kmh) as number | null | undefined,
online: Boolean(v.online),
status: String(v.status ?? ""),
})),
[live?.vehicles],
);
const tileLabel = String(mapConfig?.tileProvider ?? mapConfig?.provider ?? "gaode");
return (
<div className="page-container">
<div className="page-header">
<h1></h1>
<p> GPS </p>
<p> GPS · {tileLabel}</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>
<FleetMapPanel
mapConfig={mapConfig as { provider?: string; tileProvider?: string; amapKey?: string } | null}
vehicles={vehicles}
className="mb-4"
/>
<div className="card">
<div className="card-header">线 ({vehicles.length})</div>
@@ -60,20 +83,30 @@ export const LiveMapPage: React.FC = () => {
</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>
))}
{vehicles.map((v) => {
const lat = v.lat ?? v.last_lat;
const lng = v.lng ?? v.last_lng;
return (
<tr key={v.vehicle_id}>
<td className="font-medium">{v.plate_no || "—"}</td>
<td className="font-mono text-xs">
{lat != null && lng != null ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : "—, —"}
</td>
<td>{v.speed_kmh != null ? `${v.speed_kmh} km/h` : "—"}</td>
<td>
<Badge variant={v.online ? "success" : "default"}>
{v.online ? "在线" : "离线"}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<button onClick={load} className="mt-3 text-sm text-blue-600 hover:underline"></button>
<button type="button" onClick={load} className="mt-3 text-sm text-blue-600 hover:underline">
</button>
</div>
</PageQueryState>
</div>

View File

@@ -29,6 +29,8 @@ export const LabelingShell: React.FC = () => {
const { hasPermission } = useAuth();
const isCoordinator = hasPermission("write:labeling_assign");
const isAnnotate = /\/labeling\/annotate\//.test(location.pathname);
const isReviewDetail = /\/labeling\/review\/[^/]+/.test(location.pathname);
const isImmersive = isAnnotate || isReviewDetail;
const visibleTabs = TABS.filter((tab) => {
if (tab.coordinatorOnly && !isCoordinator) return false;
@@ -40,8 +42,8 @@ export const LabelingShell: React.FC = () => {
return (
<ModuleGuard requiredPerms={["read:pending", "read:deliveries", "read:catalog", "write:delivery_submit"]}>
<div className={isAnnotate ? "h-[calc(100vh-0px)] flex flex-col min-h-0" : undefined}>
{!isAnnotate && (
<div className={isImmersive ? "labeling-immersive h-full min-h-0 flex flex-col overflow-hidden" : undefined}>
{!isImmersive && (
<nav className="module-tabs">
{visibleTabs.map((tab) => (
<NavLink
@@ -55,7 +57,8 @@ export const LabelingShell: React.FC = () => {
))}
</nav>
)}
<Switch>
<div className={isImmersive ? "flex-1 min-h-0 flex flex-col overflow-hidden" : undefined}>
<Switch>
<Route exact path={`${path}`} render={() => <Redirect to={defaultPath} />} />
<Route path={`${path}/annotate/:campaignId`} component={AnnotationPage} />
<Route path={`${path}/my-tasks`} component={MyTasksPage} />
@@ -67,7 +70,8 @@ export const LabelingShell: React.FC = () => {
<Route path={`${path}/deliveries`} component={DeliveriesPage} />
<Route path={`${path}/catalog`} component={CatalogPage} />
<Route path={`${path}/simulate`} component={SimulationStudioPage} />
</Switch>
</Switch>
</div>
</div>
</ModuleGuard>
);

View File

@@ -0,0 +1,124 @@
import React from "react";
import { StageBadge } from "@/components/ui/Badge";
import { CompactTableShell } from "@/components/CompactTableShell";
import { TruncatedText } from "@/components/TruncatedText";
import { TableIconAction } from "./TableIconAction";
import { displayProject, displayTask } from "@/lib/labelingDisplay";
import type { LabelingBatchRow } from "@/lib/types";
type CampaignBatchTableProps = {
batches: LabelingBatchRow[];
expandCampaign: string | null;
onToggleExpand: (campaignId: string) => void;
onExport: (campaignId: string) => void;
onSubmit: (campaignId: string) => void;
renderExpand?: (b: LabelingBatchRow) => React.ReactNode;
};
const COLS = ["31%", "7%", "6%", "8%", "9%", "10%", "12.5rem"];
export const CampaignBatchTable: React.FC<CampaignBatchTableProps> = ({
batches,
expandCampaign,
onToggleExpand,
onExport,
onSubmit,
renderExpand,
}) => (
<CompactTableShell colWidths={COLS}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2">Campaign</th>
<th className="py-2 w-[12.5rem]"></th>
</tr>
</thead>
<tbody>
{batches.map((b) => {
const cid = b.campaign_id || "";
const isExpanded = expandCampaign === cid;
const canExport = ["labeling_submitted", "returned"].includes(b.stage || "");
const pct =
b.total_tasks && b.total_tasks > 0
? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100)
: 0;
return (
<React.Fragment key={cid || b.batch}>
<tr className={`align-middle ${isExpanded ? "bg-blue-50/40" : ""}`}>
<td className="py-2 max-w-0 overflow-hidden">
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
</td>
<td className="py-2 max-w-0 overflow-hidden">
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
</td>
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProject(b)}</td>
<td className="py-2 whitespace-nowrap">
<StageBadge stage={b.stage} />
</td>
<td className="py-2 whitespace-nowrap">
{b.total_tasks != null && b.total_tasks > 0 ? (
<div className="flex items-center justify-center gap-1 min-w-0">
<div className="w-10 h-1.5 bg-gray-100 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[11px] text-gray-500 tabular-nums">
{b.completed_tasks}/{b.total_tasks}
</span>
</div>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="py-2 max-w-0 overflow-hidden">
<TruncatedText text={cid || "—"} className="font-mono text-xs text-gray-500" maxWidthClass="max-w-full" />
</td>
<td className="py-2 px-0.5 align-middle w-[12.5rem]">
{cid && (
<div className="inline-flex flex-nowrap items-center justify-center gap-0.5">
<TableIconAction
dense
to={`/labeling/annotate/${encodeURIComponent(cid)}`}
tone="blue"
>
</TableIconAction>
<TableIconAction
dense
tone="gray"
disabled={!canExport}
title={canExport ? undefined : "质检通过后可导出"}
onClick={() => onExport(cid)}
>
📤
</TableIconAction>
<TableIconAction dense tone="green" onClick={() => onSubmit(cid)}>
</TableIconAction>
<TableIconAction dense tone="purple" onClick={() => onToggleExpand(cid)}>
👥 {isExpanded ? "收起" : "分配"}
</TableIconAction>
</div>
)}
</td>
</tr>
{isExpanded && renderExpand && (
<tr>
<td colSpan={7} className="p-0 border-b border-gray-100 bg-gray-50/50">
{renderExpand(b)}
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</CompactTableShell>
);

View File

@@ -0,0 +1,271 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import {
defaultInboxPath,
deliveryLineToApi,
type DeliveryLineKind,
} from "@/lib/labelingDisplay";
export type DeliveryFormValues = {
line: DeliveryLineKind;
project: string;
task: string;
mode: string;
batch_name: string;
data_path: string;
source_type: string;
collection_start: string;
collection_end: string;
estimated_count: string;
vehicle_scene: string;
remark: string;
};
const EMPTY: DeliveryFormValues = {
line: "dms",
project: "dms",
task: "",
mode: "",
batch_name: "",
data_path: "",
source_type: "platform_delivery",
collection_start: "",
collection_end: "",
estimated_count: "",
vehicle_scene: "",
remark: "",
};
const LINES: { id: DeliveryLineKind; label: string; desc: string }[] = [
{ id: "dms", label: "DMS 舱内", desc: "addw / ddaw / dam / addw_face" },
{ id: "adas_2d", label: "ADAS 2D 七类", desc: "project=adas · det_7cls · adas/inbox/det_7cls/" },
{ id: "adas_3d", label: "ADAS 3D MOON", desc: "project=adas · cuboid_7cls · adas/inbox/" },
{ id: "forward", label: "前向交通标志", desc: "project=dms · task=forward · 须填子模式" },
{ id: "lane", label: "车道线", desc: "project=lane · lane/inbox/{批次}" },
];
type DeliveryCreateModalProps = {
open: boolean;
saving: boolean;
onClose: () => void;
onSubmit: (values: DeliveryFormValues) => void;
};
const fieldCls =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200";
const labelCls = "block text-xs font-medium text-gray-600 mb-1";
export const DeliveryCreateModal: React.FC<DeliveryCreateModalProps> = ({
open,
saving,
onClose,
onSubmit,
}) => {
const [form, setForm] = useState<DeliveryFormValues>(EMPTY);
const [intake, setIntake] = useState<"nas" | "inbox">("nas");
useEffect(() => {
if (!open) return;
const api = deliveryLineToApi(form.line);
setForm((f) => ({
...f,
project: api.project,
task: f.line === "dms" || f.line === "forward" ? f.task : api.task,
mode: f.line === "forward" ? f.mode : api.mode,
}));
}, [form.line, open]);
useEffect(() => {
if (!open || intake !== "inbox" || !form.batch_name.trim()) return;
if (form.data_path.trim()) return;
const p = defaultInboxPath(
form.line,
form.batch_name.trim(),
form.task || undefined,
form.mode || undefined,
);
setForm((f) => ({ ...f, data_path: p }));
}, [form.batch_name, form.line, form.mode, form.task, intake, open]);
if (!open) return null;
const set = (k: keyof DeliveryFormValues, v: string) => setForm((f) => ({ ...f, [k]: v }));
const onLineChange = (line: DeliveryLineKind) => {
const api = deliveryLineToApi(line);
setForm((f) => ({
...f,
line,
project: api.project,
task: line === "dms" ? "" : api.task,
mode: line === "forward" ? f.mode : api.mode,
data_path: "",
}));
};
const pathHint =
intake === "nas"
? "/data/nas/采集批次/..."
: defaultInboxPath(form.line, "{batch}", form.task || undefined, form.mode || undefined);
const taskLocked = form.line === "adas_2d" || form.line === "adas_3d" || form.line === "lane";
const modeRequired = form.line === "forward" || form.line === "dms";
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const api = deliveryLineToApi(form.line);
onSubmit({
...form,
project: api.project,
task: form.line === "dms" ? form.task.trim() : api.task,
mode: form.line === "forward" || (form.line === "dms" && form.task === "dam") ? form.mode.trim() : api.mode,
source_type: intake === "nas" ? "platform_delivery" : "inbox_scan",
});
};
const resetClose = () => {
setForm(EMPTY);
setIntake("nas");
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={resetClose}>
<div
className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-500 mt-0.5">
ADAS 2D 3D <code className="text-xs">adas/inbox/</code>2D {" "}
<code className="text-xs">det_7cls</code>3D {" "}
<code className="text-xs">adas/inbox/cuboid_7cls</code>
</p>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
<div>
<label className={labelCls}>线 *</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{LINES.map((l) => (
<button
key={l.id}
type="button"
onClick={() => onLineChange(l.id)}
className={`rounded-lg border p-2.5 text-left text-xs transition-colors ${
form.line === l.id ? "border-blue-400 bg-blue-50" : "border-gray-200 hover:bg-gray-50"
}`}
>
<div className="font-medium text-sm">{l.label}</div>
<div className="text-gray-500 mt-0.5 leading-snug">{l.desc}</div>
</button>
))}
</div>
</div>
<div className="flex gap-2">
{([
{ id: "nas" as const, label: "NAS / 外挂盘", desc: "审批后拷贝入湖" },
{ id: "inbox" as const, label: "已在 inbox", desc: "建议用「扫描数据湖」" },
]).map((t) => (
<button
key={t.id}
type="button"
onClick={() => setIntake(t.id)}
className={`flex-1 rounded-lg border p-3 text-left transition-colors ${
intake === t.id ? "border-blue-400 bg-blue-50" : "border-gray-200 hover:bg-gray-50"
}`}
>
<div className="text-sm font-medium">{t.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{t.desc}</div>
</button>
))}
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelCls}>API </label>
<input className={`${fieldCls} bg-gray-50`} readOnly value={form.project} />
</div>
<div>
<label className={labelCls}> *</label>
<input
className={taskLocked ? `${fieldCls} bg-gray-50` : fieldCls}
readOnly={taskLocked}
placeholder={form.line === "dms" ? "addw / dam …" : ""}
value={form.task}
onChange={(e) => set("task", e.target.value)}
required
/>
</div>
<div>
<label className={labelCls}>{form.line === "forward" ? " *" : ""}</label>
<input
className={fieldCls}
placeholder={form.line === "forward" ? "detect / classify" : "dam: batch_0516"}
value={form.mode}
onChange={(e) => set("mode", e.target.value)}
required={form.line === "forward"}
/>
</div>
</div>
<div>
<label className={labelCls}> *</label>
<input
className={fieldCls}
placeholder="20260601_pilot勿与任务名相同"
value={form.batch_name}
onChange={(e) => set("batch_name", e.target.value)}
required
/>
</div>
<div>
<label className={labelCls}> *</label>
<input
className={`${fieldCls} font-mono text-xs`}
placeholder={pathHint}
value={form.data_path}
onChange={(e) => set("data_path", e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}></label>
<input type="date" className={fieldCls} value={form.collection_start} onChange={(e) => set("collection_start", e.target.value)} />
</div>
<div>
<label className={labelCls}></label>
<input type="date" className={fieldCls} value={form.collection_end} onChange={(e) => set("collection_end", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}></label>
<input type="number" min={0} className={fieldCls} value={form.estimated_count} onChange={(e) => set("estimated_count", e.target.value)} />
</div>
<div>
<label className={labelCls}> / </label>
<input className={fieldCls} value={form.vehicle_scene} onChange={(e) => set("vehicle_scene", e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}></label>
<textarea className={`${fieldCls} resize-none`} rows={2} value={form.remark} onChange={(e) => set("remark", e.target.value)} />
</div>
<div className="flex gap-2 justify-end pt-2 border-t border-gray-100">
<Button type="button" variant="default" size="small" onClick={resetClose}></Button>
<Button type="submit" variant="primary" size="small" loading={saving}>稿</Button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,72 @@
import React from "react";
import { Button } from "@/components/ui/Button";
type DeliveryIntakePanelProps = {
total: number;
inLake: number;
pending: number;
scanPending?: number;
scanning: boolean;
canCreate: boolean;
onScan: () => void;
onCreate: () => void;
};
export const DeliveryIntakePanel: React.FC<DeliveryIntakePanelProps> = ({
total,
inLake,
pending,
scanPending,
scanning,
canCreate,
onScan,
onCreate,
}) => (
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
<div className="card p-4 border-blue-100 bg-blue-50/40">
<div className="text-xs text-blue-600 font-medium mb-1"></div>
<div className="text-2xl font-semibold text-blue-900 tabular-nums">{total}</div>
<div className="text-xs text-gray-500 mt-1"> {inLake} · {pending}</div>
</div>
<button
type="button"
onClick={onScan}
className="card p-4 text-left border-emerald-100 bg-emerald-50/40 hover:border-emerald-300 transition-colors"
>
<div className="text-xs text-emerald-700 font-medium mb-1"> </div>
<div className="text-sm font-semibold text-emerald-900">inbox </div>
<div className="text-xs text-gray-500 mt-2">
{scanPending != null ? `待登记 ${scanPending}` : "发现 inbox 新批次"}
</div>
<Button size="small" variant="primary" className="mt-3" loading={scanning} onClick={(e) => { e.stopPropagation(); onScan(); }}>
</Button>
</button>
<button
type="button"
disabled={!canCreate}
onClick={onCreate}
className="card p-4 text-left border-amber-100 bg-amber-50/40 hover:border-amber-300 transition-colors disabled:opacity-50"
>
<div className="text-xs text-amber-700 font-medium mb-1"> NAS </div>
<div className="text-sm font-semibold text-amber-900"> </div>
<div className="text-xs text-gray-500 mt-2"></div>
{canCreate && (
<Button size="small" variant="default" className="mt-3" onClick={(e) => { e.stopPropagation(); onCreate(); }}>
</Button>
)}
</button>
<div className="card p-4 border-purple-100 bg-purple-50/40">
<div className="text-xs text-purple-700 font-medium mb-1"> </div>
<div className="text-sm font-semibold text-purple-900"></div>
<div className="text-xs text-gray-500 mt-2"> </div>
<a href="/labeling/workbench" className="inline-block mt-3">
<Button size="small" variant="default"> </Button>
</a>
</div>
</div>
);

View File

@@ -0,0 +1,114 @@
import React from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/Button";
import { StatusBadge } from "@/components/ui/Badge";
import { CompactTableShell } from "@/components/CompactTableShell";
import { TruncatedText } from "@/components/TruncatedText";
import { displayProjectFields, displayTaskFields } from "@/lib/labelingDisplay";
import type { BatchDelivery } from "@/lib/types";
const SOURCE_LABELS: Record<string, string> = {
inbox_scan: "inbox扫描",
platform_delivery: "NAS送标",
upload: "页面上传",
feishu_bitable: "飞书",
};
function formatSource(st?: string | null, d?: BatchDelivery): string {
if (!st) return "—";
if (d?.project === "adas" && d?.task === "det_7cls") return "ADAS 2D";
if (d?.project === "dms" && d?.task === "adas") return "ADAS 2D";
if (d?.project === "adas" && d?.task === "cuboid_7cls") return "ADAS 3D";
return SOURCE_LABELS[st] || st;
}
function formatDate(iso?: string | null): string {
if (!iso) return "—";
return iso.slice(0, 10);
}
type DeliveryTableProps = {
deliveries: BatchDelivery[];
canSubmit: boolean;
onSubmit: (id: string) => void;
onDelete: (id: string) => void;
};
const COLS = ["22%", "8%", "8%", "8%", "9%", "10%", "9%", "9%", "7.5rem"];
export const DeliveryTable: React.FC<DeliveryTableProps> = ({
deliveries,
canSubmit,
onSubmit,
onDelete,
}) => (
<CompactTableShell colWidths={COLS}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{deliveries.map((d) => {
const period = d.collection_start
? `${formatDate(d.collection_start)}${d.collection_end ? ` ~ ${formatDate(d.collection_end)}` : ""}`
: "—";
return (
<tr key={d.id} className="align-middle">
<td className="py-2 max-w-0">
<TruncatedText text={d.batch_name || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
</td>
<td className="py-2 whitespace-nowrap text-xs text-gray-600">{formatSource(d.source_type, d)}</td>
<td className="py-2 whitespace-nowrap text-sm text-gray-700">
{displayProjectFields({ project: d.project, task: d.task || undefined })}
</td>
<td className="py-2 max-w-0">
<TruncatedText
text={displayTaskFields({ project: d.project, task: d.task || undefined })}
maxWidthClass="max-w-full"
/>
</td>
<td className="py-2 whitespace-nowrap">
<StatusBadge status={d.status} />
</td>
<td className="py-2 whitespace-nowrap text-xs text-gray-500">{period}</td>
<td className="py-2 text-sm text-gray-600 tabular-nums">{d.estimated_count ?? "—"}</td>
<td className="py-2 whitespace-nowrap text-xs text-gray-500">{formatDate(d.created_at)}</td>
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
<div className="inline-flex items-center justify-center gap-0.5 flex-wrap">
{d.status === "draft" && canSubmit && (
<>
<Button size="small" variant="primary" className="!px-2" onClick={() => onSubmit(d.id)}>
</Button>
<Button size="small" variant="danger" className="!px-2" onClick={() => onDelete(d.id)}>
</Button>
</>
)}
{d.status === "in_lake" && (
<Link to="/labeling/workbench">
<Button size="small" variant="success" className="!px-2"></Button>
</Link>
)}
{d.approval_id && (
<Link to={`/system/audit/${d.approval_id}`}>
<Button size="small" variant="default" className="!px-2"></Button>
</Link>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</CompactTableShell>
);

View File

@@ -0,0 +1,108 @@
import React from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { CompactTableShell } from "@/components/CompactTableShell";
import { TruncatedText } from "@/components/TruncatedText";
import { displayProject, displayTask } from "@/lib/labelingDisplay";
import type { LabelingBatchRow } from "@/lib/types";
type ExportBatchTableProps = {
batches: LabelingBatchRow[];
importingId: string | null;
buildingId: string | null;
onExport: (campaignId: string) => void;
onImportVendor: (campaignId: string) => void;
onSubmitBuild: (batch: LabelingBatchRow) => void;
};
const COLS = ["34%", "14%", "8%", "10%", "auto", "7.5rem"];
export const ExportBatchTable: React.FC<ExportBatchTableProps> = ({
batches,
importingId,
buildingId,
onExport,
onImportVendor,
onSubmitBuild,
}) => (
<CompactTableShell colWidths={COLS}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2">Campaign</th>
<th className="py-2 w-[7.5rem]"></th>
</tr>
</thead>
<tbody>
{batches.map((b) => {
const cid = b.campaign_id || "";
const isExport = b.stage === "labeling_submitted";
return (
<tr key={cid || b.batch} className="align-middle">
<td className="py-2 max-w-0">
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
</td>
<td className="py-2 max-w-0">
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
</td>
<td className="py-2 whitespace-nowrap">
<span className="text-sm text-gray-700">{displayProject(b)}</span>
</td>
<td className="py-2 whitespace-nowrap">
<Badge variant="warning" size="small">
{isExport ? "待导出" : "待入库"}
</Badge>
</td>
<td className="py-2 max-w-0">
<TruncatedText
text={cid || "—"}
className="font-mono text-xs text-gray-500"
maxWidthClass="max-w-full"
/>
</td>
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
{cid && isExport && (
<div className="inline-flex items-center justify-center gap-0.5">
<Button size="small" variant="primary" className="!px-2" onClick={() => onExport(cid)}>
</Button>
<Button
size="small"
variant="default"
className="!px-2"
loading={importingId === cid}
onClick={() => onImportVendor(cid)}
>
</Button>
</div>
)}
{b.stage === "returned" && (
<div className="inline-flex items-center justify-center gap-0.5">
<Button
size="small"
variant="primary"
className="!px-2"
loading={buildingId === (cid || b.batch)}
onClick={() => onSubmitBuild(b)}
>
</Button>
<Link to="/system/audit">
<Button size="small" variant="default" className="!px-2">
</Button>
</Link>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</CompactTableShell>
);

View File

@@ -0,0 +1,170 @@
import React from "react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/Badge";
type ExportPipelineFlowProps = {
pendingExport?: number;
pendingBuild?: number;
};
const Arrow: React.FC<{ className?: string }> = ({ className = "" }) => (
<div className={`flex items-center justify-center shrink-0 text-gray-300 ${className}`} aria-hidden>
<svg className="w-3.5 h-3.5 hidden lg:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<svg className="w-3.5 h-3.5 lg:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
);
type StepProps = {
step: number;
title: string;
subtitle: string;
role: string;
badge?: React.ReactNode;
icon: React.ReactNode;
highlight?: "current" | "done" | "upstream";
link?: string;
footnote?: string;
};
const StepCard: React.FC<StepProps> = ({ step, title, subtitle, role, badge, icon, highlight = "upstream", link, footnote }) => {
const shell =
highlight === "current"
? "border-amber-200 bg-gradient-to-br from-amber-50/90 to-orange-50/50 shadow-sm ring-1 ring-amber-100"
: highlight === "done"
? "border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-teal-50/40 ring-1 ring-emerald-100"
: "border-gray-200 bg-gray-50/60";
const content = (
<div className={`relative flex flex-col rounded-xl border p-3 min-h-[6.75rem] flex-1 min-w-0 transition-shadow hover:shadow-md ${shell}`}>
<div className="flex items-start justify-between gap-2 mb-1.5">
<span
className={`inline-flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-bold ${
highlight === "current"
? "bg-amber-500 text-white"
: highlight === "done"
? "bg-emerald-500 text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{step}
</span>
<span className="text-xs leading-none opacity-75 select-none">{icon}</span>
</div>
<h3 className="text-sm font-semibold text-gray-900 m-0">{title}</h3>
<p className="text-[11px] text-gray-500 mt-1 leading-relaxed flex-1">{subtitle}</p>
<div className="flex flex-wrap items-center gap-1.5 mt-2 pt-2 border-t border-black/5">
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">{role}</span>
{badge}
</div>
{footnote && <p className="text-[10px] text-gray-400 mt-1.5 m-0">{footnote}</p>}
</div>
);
if (link) {
return (
<Link to={link} className="flex-1 min-w-[9rem] max-w-[14rem] no-underline text-inherit group">
<div className="group-hover:scale-[1.02] transition-transform">{content}</div>
</Link>
);
}
return <div className="flex-1 min-w-[9rem] max-w-[14rem]">{content}</div>;
};
export const ExportPipelineFlow: React.FC<ExportPipelineFlowProps> = ({
pendingExport = 0,
pendingBuild = 0,
}) => {
return (
<div className="mb-4 rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
<div className="px-4 py-3 border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-gray-800 m-0"> </h2>
<p className="text-xs text-gray-500 m-0 mt-0.5"> 34 </p>
</div>
<div className="flex items-center gap-2 text-xs">
{pendingExport > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 text-amber-800 px-2.5 py-1 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{pendingExport}
</span>
)}
{pendingBuild > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-800 px-2.5 py-1 font-medium">
{pendingBuild} build
</span>
)}
</div>
</div>
<div className="p-4 lg:p-5">
<div className="flex flex-col lg:flex-row lg:items-stretch gap-1 lg:gap-0">
<StepCard
step={1}
title="提交质检"
subtitle="标注员在标注进度页完成标注后提交"
role="标注员"
icon="📝"
highlight="upstream"
link="/labeling/campaigns"
badge={<Badge variant="default" size="small"></Badge>}
/>
<Arrow className="py-1 lg:py-0 lg:px-1" />
<StepCard
step={2}
title="质检通过"
subtitle="协调员审核合格/可用,退回则返工"
role="协调员"
icon="✓"
highlight="upstream"
link="/labeling/review"
badge={<Badge variant="warning" size="small"></Badge>}
/>
<Arrow className="py-1 lg:py-0 lg:px-1" />
<StepCard
step={3}
title="执行导出"
subtitle="CVAT → 训练格式DMS·YOLO / ADAS·quaternion_json"
role="本页操作"
icon="📤"
highlight="current"
badge={<Badge variant="warning" size="small"> build</Badge>}
footnote={pendingExport > 0 ? `下方 ${pendingExport} 个批次可导出` : undefined}
/>
<Arrow className="py-1 lg:py-0 lg:px-1" />
<StepCard
step={4}
title="提交 build"
subtitle="审核队列批准后 merge 进训练包"
role="审核员"
icon="🏗"
highlight="done"
badge={<Badge variant="success" size="small"></Badge>}
footnote="待 build ≠ 已入库"
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl bg-slate-50 border border-slate-100 px-3 py-2.5 text-[11px] text-gray-500">
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-gray-300" />
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-400" />
/
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
ingested
</span>
<Link to="/system/audit" className="ml-auto text-blue-600 hover:underline font-medium">
</Link>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,59 @@
import React from "react";
type FilterChip = { label: string; value: string };
type LabelingListToolbarProps = {
search: string;
onSearchChange: (value: string) => void;
placeholder?: string;
filters?: FilterChip[];
filterValue?: string;
onFilterChange?: (value: string) => void;
total: number;
extra?: React.ReactNode;
};
export const LabelingListToolbar: React.FC<LabelingListToolbarProps> = ({
search,
onSearchChange,
placeholder = "搜索批次/任务/项目...",
filters,
filterValue = "",
onFilterChange,
total,
extra,
}) => (
<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={placeholder}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{filters && onFilterChange && (
<div className="flex gap-1.5">
{filters.map((f) => (
<button
key={f.value}
type="button"
onClick={() => onFilterChange(f.value)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
filterValue === f.value ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{f.label}
</button>
))}
</div>
)}
{extra}
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{total} </span>
</div>
</div>
);

View File

@@ -0,0 +1,89 @@
import React from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/Button";
import { CompactTableShell } from "@/components/CompactTableShell";
import { TruncatedText } from "@/components/TruncatedText";
import { displayProjectFields, displayTaskFields } from "@/lib/labelingDisplay";
export type MyAssignmentRow = {
campaign_id: string;
batch: string;
task: string;
project?: string;
status?: string;
assigned: number;
completed: number;
pending: number;
campaign_total?: number;
};
type MyTasksTableProps = {
items: MyAssignmentRow[];
highlightId?: string | null;
};
const COLS = ["30%", "12%", "8%", "10%", "14%", "auto", "6rem"];
export const MyTasksTable: React.FC<MyTasksTableProps> = ({ items, highlightId }) => (
<CompactTableShell colWidths={COLS}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2">Campaign</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{items.map((row) => {
const pct = row.assigned > 0 ? Math.round((row.completed / row.assigned) * 100) : 0;
const highlighted = highlightId === row.campaign_id;
return (
<tr
key={row.campaign_id}
className={`align-middle ${highlighted ? "bg-blue-50/60" : ""}`}
>
<td className="py-2 max-w-0">
<TruncatedText text={row.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
</td>
<td className="py-2 max-w-0">
<TruncatedText text={displayTaskFields(row)} maxWidthClass="max-w-full" />
</td>
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProjectFields(row)}</td>
<td className="py-2 whitespace-nowrap">
<span className="text-sm font-medium text-amber-600 tabular-nums">{row.pending}</span>
</td>
<td className="py-2 whitespace-nowrap">
<div className="flex items-center justify-center gap-1.5">
<div className="w-14 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-500 tabular-nums">{row.completed}/{row.assigned}</span>
</div>
</td>
<td className="py-2 max-w-0">
<TruncatedText
text={row.campaign_id || "—"}
className="font-mono text-xs text-gray-500"
maxWidthClass="max-w-full"
/>
</td>
<td className="py-2 px-2 whitespace-nowrap w-[6rem]">
<Link to={`/labeling/annotate/${encodeURIComponent(row.campaign_id)}`}>
<Button size="small" variant="primary" className="!px-2">
{row.pending > 0 ? "标注" : "查看"}
</Button>
</Link>
</td>
</tr>
);
})}
</tbody>
</CompactTableShell>
);

View File

@@ -0,0 +1,110 @@
import React from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/Button";
import { StageBadge } from "@/components/ui/Badge";
import { CompactTableShell } from "@/components/CompactTableShell";
import { TruncatedText } from "@/components/TruncatedText";
import { displayProject, displayTask } from "@/lib/labelingDisplay";
import type { LabelingBatchRow } from "@/lib/types";
type ReviewProgress = {
total: number;
reviewed: number;
good: number;
fine: number;
bad: number;
pass_rate: number;
complete: boolean;
stage?: string;
};
type ReviewBatchTableProps = {
batches: LabelingBatchRow[];
progressMap: Record<string, ReviewProgress>;
};
const COLS = ["30%", "12%", "8%", "10%", "14%", "auto", "7.5rem"];
export const ReviewBatchTable: React.FC<ReviewBatchTableProps> = ({ batches, progressMap }) => (
<CompactTableShell colWidths={COLS}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2">Campaign</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{batches.map((b) => {
const cid = b.campaign_id || "";
const prog = cid ? progressMap[cid] : undefined;
const reviewPct = prog && prog.total > 0 ? Math.round((prog.reviewed / prog.total) * 100) : 0;
const reviewDone = prog?.complete;
const effectiveStage = reviewDone && prog?.stage ? prog.stage : b.stage;
const passed = effectiveStage === "labeling_submitted" || (reviewDone && (prog?.pass_rate ?? 0) >= 80);
return (
<tr key={cid || b.batch} className="align-middle">
<td className="py-2 max-w-0">
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
</td>
<td className="py-2 max-w-0">
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
</td>
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProject(b)}</td>
<td className="py-2 whitespace-nowrap">
<StageBadge stage={effectiveStage || ""} />
</td>
<td className="py-2 whitespace-nowrap">
{prog && prog.total > 0 ? (
<div className="flex items-center justify-center gap-1.5 min-w-0">
<div className="w-14 h-1.5 bg-gray-100 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full ${reviewDone ? "bg-green-500" : "bg-blue-500"}`}
style={{ width: `${reviewPct}%` }}
/>
</div>
<span className="text-xs text-gray-500 tabular-nums">{prog.reviewed}/{prog.total}</span>
{reviewDone && (
<span className={`text-xs ${passed ? "text-green-600" : "text-red-600"}`}>
{prog.pass_rate}%
</span>
)}
</div>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="py-2 max-w-0">
<TruncatedText text={cid || "—"} className="font-mono text-xs text-gray-500" maxWidthClass="max-w-full" />
</td>
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
<div className="flex justify-center">
{effectiveStage === "in_review" && cid && (
<Link to={`/labeling/review/${cid}`}>
<Button size="small" variant="primary" className="!px-2">
{reviewDone ? "查看" : prog && prog.reviewed > 0 ? "继续" : "质检"}
</Button>
</Link>
)}
{effectiveStage === "labeling_submitted" && (
<Link to="/labeling/export">
<Button size="small" variant="success" className="!px-2"></Button>
</Link>
)}
{effectiveStage === "review_rejected" && cid && (
<Link to={`/labeling/review/${cid}`}>
<Button size="small" variant="default" className="!px-2"></Button>
</Link>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</CompactTableShell>
);

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Link } from "react-router-dom";
const BASE =
"inline-flex items-center gap-0.5 font-medium rounded-lg transition-colors whitespace-nowrap shrink-0";
type TableIconActionProps = {
children: React.ReactNode;
title?: string;
disabled?: boolean;
onClick?: () => void;
to?: string;
tone?: "blue" | "gray" | "green" | "purple" | "danger";
/** 略收紧的内边距,仍保留图标+文字 */
dense?: boolean;
};
const TONE: Record<NonNullable<TableIconActionProps["tone"]>, string> = {
blue: "bg-blue-50 text-blue-700 hover:bg-blue-100",
gray: "bg-gray-50 text-gray-600 hover:bg-gray-100",
green: "bg-green-50 text-green-700 hover:bg-green-100",
purple: "bg-purple-50 text-purple-700 hover:bg-purple-100",
danger: "bg-red-50 text-red-700 hover:bg-red-100",
};
const DISABLED = "bg-gray-50 text-gray-300 cursor-not-allowed pointer-events-none";
export const TableIconAction: React.FC<TableIconActionProps> = ({
children,
title,
disabled,
onClick,
to,
tone = "gray",
dense = false,
}) => {
const cls = `${BASE} ${dense ? "px-1.5 py-1 text-[11px] leading-tight" : "px-2 py-1.5 text-xs"} ${
disabled ? DISABLED : TONE[tone]
}`;
if (to && !disabled) {
return (
<Link to={to} className={cls} title={title}>
{children}
</Link>
);
}
return (
<button type="button" className={cls} title={title} disabled={disabled} onClick={onClick}>
{children}
</button>
);
};

View File

@@ -0,0 +1,116 @@
import React, { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
export type TableRowMenuItem = {
key: string;
label: string;
icon?: string;
onClick?: () => void;
to?: string;
disabled?: boolean;
title?: string;
};
const TONE: Record<string, string> = {
blue: "bg-blue-50 text-blue-700 hover:bg-blue-100",
green: "bg-green-50 text-green-700 hover:bg-green-100",
purple: "bg-purple-50 text-purple-700 hover:bg-purple-100",
gray: "bg-gray-50 text-gray-600 hover:bg-gray-100",
};
type TableRowMenuProps = {
primary?: TableRowMenuItem & { tone?: keyof typeof TONE };
items?: TableRowMenuItem[];
};
const PrimaryBtn: React.FC<{ item: TableRowMenuItem & { tone?: keyof typeof TONE } }> = ({ item }) => {
const cls = `inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-lg transition-colors whitespace-nowrap shrink-0 ${
item.disabled ? "bg-gray-50 text-gray-300 cursor-not-allowed pointer-events-none" : TONE[item.tone || "blue"]
}`;
const content = (
<>
{item.icon && <span>{item.icon}</span>}
<span>{item.label}</span>
</>
);
if (item.to && !item.disabled) {
return <Link to={item.to} className={cls} title={item.title}>{content}</Link>;
}
return (
<button type="button" className={cls} title={item.title} disabled={item.disabled} onClick={item.onClick}>
{content}
</button>
);
};
export const TableRowMenu: React.FC<TableRowMenuProps> = ({ primary, items = [] }) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const renderMenuItem = (item: TableRowMenuItem) => {
const cls = `flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${
item.disabled ? "text-gray-300 cursor-not-allowed" : "text-gray-700 hover:bg-gray-50"
}`;
const content = (
<>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.label}</span>
</>
);
if (item.to && !item.disabled) {
return (
<Link key={item.key} to={item.to} className={cls} title={item.title} onClick={() => setOpen(false)}>
{content}
</Link>
);
}
return (
<button
key={item.key}
type="button"
className={cls}
title={item.title}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick?.();
setOpen(false);
}}
>
{content}
</button>
);
};
return (
<div ref={rootRef} className="relative inline-flex items-center justify-end gap-0.5">
{primary && <PrimaryBtn item={primary} />}
{items.length > 0 && (
<>
<button
type="button"
className="inline-flex items-center justify-center w-7 h-7 text-xs font-medium rounded-lg bg-gray-50 text-gray-500 hover:bg-gray-100 shrink-0"
title="更多操作"
onClick={() => setOpen((v) => !v)}
>
</button>
{open && (
<div className="absolute right-0 top-full z-30 mt-1 min-w-[9.5rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{items.map(renderMenuItem)}
</div>
)}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,98 @@
import React from "react";
import { StageBadge } from "@/components/ui/Badge";
import { CompactTableShell } from "@/components/CompactTableShell";
import { TruncatedText } from "@/components/TruncatedText";
import { TableIconAction } from "./TableIconAction";
import { displayProject, displayTask } from "@/lib/labelingDisplay";
import type { LabelingBatchRow } from "@/lib/types";
type WorkbenchBatchTableProps = {
batches: LabelingBatchRow[];
onOpenCampaign: (row: LabelingBatchRow) => void;
onArchive?: (row: LabelingBatchRow) => void;
archivingId?: string | null;
};
const COLS = ["32%", "9%", "7%", "9%", "7%", "7%", "11rem"];
export const WorkbenchBatchTable: React.FC<WorkbenchBatchTableProps> = ({
batches,
onOpenCampaign,
onArchive,
archivingId,
}) => (
<CompactTableShell colWidths={COLS}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2 w-[11rem]"></th>
</tr>
</thead>
<tbody>
{batches.map((b) => {
const cid = b.campaign_id || "";
return (
<tr key={`${b.project}/${b.task}/${b.batch}`} className="align-middle">
<td className="py-2 max-w-0 overflow-hidden">
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
</td>
<td className="py-2 max-w-0 overflow-hidden">
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
</td>
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProject(b)}</td>
<td className="py-2 whitespace-nowrap">
<StageBadge stage={b.stage} />
</td>
<td className="py-2 text-sm text-gray-600 tabular-nums">{b.counts?.images ?? 0}</td>
<td className="py-2 text-sm text-gray-600 tabular-nums">{b.counts?.labels ?? 0}</td>
<td className="py-2 px-0.5 whitespace-nowrap w-[11rem]">
<div className="inline-flex flex-nowrap items-center justify-center gap-0.5">
{b.stage === "raw_pool" && (
<>
<TableIconAction dense tone="blue" onClick={() => onOpenCampaign(b)}>
📂
</TableIconAction>
{onArchive && (
<TableIconAction
dense
tone="danger"
disabled={archivingId === cid}
onClick={() => onArchive(b)}
>
🗑
</TableIconAction>
)}
</>
)}
{b.stage === "out_for_labeling" && cid && (
<>
<TableIconAction
dense
to={`/labeling/annotate/${encodeURIComponent(cid)}`}
tone="blue"
>
</TableIconAction>
<TableIconAction dense to="/labeling/campaigns" tone="gray">
📊
</TableIconAction>
</>
)}
{b.stage === "returned" && (
<TableIconAction dense to="/labeling/export" tone="gray">
🏗
</TableIconAction>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</CompactTableShell>
);

View File

@@ -110,7 +110,12 @@ export const AnnotationPage: React.FC = () => {
}, [status?.cvat_job_url, activeFrame]);
const runSync = useCallback(async (silent = false): Promise<SyncResult | null> => {
if (syncInFlight.current) return null;
if (syncInFlight.current) {
if (!silent) {
setSyncHint("同步进行中,请稍候…");
}
return null;
}
syncInFlight.current = true;
if (!silent) setSyncing(true);
try {
@@ -129,9 +134,15 @@ export const AnnotationPage: React.FC = () => {
const shapes = Number(data.shapes ?? 0);
const hint = shapes > 0
? `已同步 ${saved} 张 · ${shapes} 个标注 · ${new Date().toLocaleTimeString()}`
: `已检查,暂无新标注 · ${new Date().toLocaleTimeString()}`;
: `已检查,暂无新标注(请先在 CVAT 画布 Ctrl+S 保存)· ${new Date().toLocaleTimeString()}`;
setSyncHint(hint);
if (!silent && shapes > 0) alert(`标注已同步(${saved} 张,${shapes} 个对象)`);
if (!silent) {
if (shapes > 0) {
alert(`标注已同步(${saved} 张,${shapes} 个对象)`);
} else {
alert(`同步完成CVAT 侧暂无新标注。\n请确认已在画布中画框并保存Ctrl+S再点「立即同步」。`);
}
}
void loadMyTasks();
return data as SyncResult;
} catch (e) {

View File

@@ -1,9 +1,10 @@
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 { ListPaginationBar } from "@/components/ListPaginationBar";
import { LabelingListToolbar } from "../components/LabelingListToolbar";
import { CampaignBatchTable } from "../components/CampaignBatchTable";
import { AssignUserSelect } from "@/components/AssignUserSelect";
import { AssignCountControl } from "@/components/AssignCountControl";
import type { LabelingBatchRow } from "@/lib/types";
@@ -28,8 +29,9 @@ export const CampaignsPage: React.FC = () => {
const [info, setInfo] = useState<string | null>(null);
const [feishuSyncHint, setFeishuSyncHint] = useState<{ message: string; url?: string } | null>(null);
const [search, setSearch] = useState("");
// assignees & assignment state
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const [assignees, setAssignees] = useState<Assignee[]>([]);
const [assigneesLoading, setAssigneesLoading] = useState(false);
const [expandCampaign, setExpandCampaign] = useState<string | null>(null);
@@ -37,27 +39,21 @@ export const CampaignsPage: React.FC = () => {
const [assigning, setAssigning] = useState(false);
const [progressMap, setProgressMap] = useState<Record<string, Record<string, unknown>>>({});
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 () => {
const load = useCallback(async (newOffset: number, newLimit: number) => {
setLoading(true);
setError(null);
try {
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", limit: 100 });
const q = search.trim() || undefined;
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", offset: newOffset, limit: newLimit, q });
setBatches((res.items || []) as LabelingBatchRow[]);
setTotal(res.total ?? 0);
setOffset(newOffset);
setLimit(newLimit);
} catch (e) {
setError(String(e));
}
setLoading(false);
}, []);
}, [search]);
const loadAssignees = useCallback(async () => {
setAssigneesLoading(true);
@@ -97,20 +93,17 @@ export const CampaignsPage: React.FC = () => {
setAssigneesLoading(false);
}, []);
useEffect(() => {
load();
loadAssignees();
}, [load, loadAssignees]);
useEffect(() => { loadAssignees(); }, [loadAssignees]);
useEffect(() => { void load(0, limit); }, [search]);
// load progress + refresh assignees when expanding assignment panel
useEffect(() => {
if (!expandCampaign) return;
loadAssignees();
hsapApi
.campaignProgress(expandCampaign)
.then((p) => setProgressMap((prev) => ({ ...prev, [expandCampaign!]: p })))
.catch(() => {});
}, [expandCampaign, loadAssignees]);
}, [expandCampaign]);
const handleExport = async (campaignId: string) => {
setInfo(null);
@@ -125,7 +118,7 @@ export const CampaignsPage: React.FC = () => {
const handleSubmit = async (campaignId: string) => {
try {
await hsapApi.submitLabelingCampaign(campaignId);
load();
load(offset, limit);
} catch (e) {
setError(String(e));
}
@@ -188,7 +181,7 @@ export const CampaignsPage: React.FC = () => {
// refresh progress
const p = await hsapApi.campaignProgress(campaignId);
setProgressMap((prev) => ({ ...prev, [campaignId]: p }));
load();
load(offset, limit);
} catch (e) {
setError(String(e));
}
@@ -215,6 +208,98 @@ export const CampaignsPage: React.FC = () => {
return Math.max(0, total - used);
};
const renderAssignPanel = (b: LabelingBatchRow) => {
const prog = (b.campaign_id ? progressMap[b.campaign_id] : null) as Record<string, unknown> | null;
const byUser = (prog?.by_user || []) as { user_id: number; name: string; assigned: number; completed: number; percent: number }[];
if (!b.campaign_id) return null;
return (
<div className="p-4">
{byUser.length > 0 && (
<div className="mb-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2"></h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{byUser.map((u) => (
<div key={u.user_id} className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg text-xs border border-gray-100">
<span className="font-medium text-gray-700 truncate">{u.name}</span>
<span className="text-gray-400">{u.completed}/{u.assigned}</span>
<div className="flex-1 h-1 bg-gray-200 rounded-full max-w-[40px]">
<div className="h-full rounded-full bg-blue-400" style={{ width: `${u.percent}%` }} />
</div>
</div>
))}
</div>
</div>
)}
<div className="rounded-xl border border-gray-100 bg-white p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-800"></h4>
<p className="text-xs text-gray-500 mt-0.5">
{assignees.length > 0 && <span className="text-gray-400"> · {assignees.length} </span>}
</p>
</div>
<button
type="button"
onClick={() => loadAssignees()}
disabled={assigneesLoading}
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
</button>
</div>
{assignees.length === 0 && !assigneesLoading ? (
<div className="text-center py-6 text-sm text-gray-500"></div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-[10rem_1fr_2rem] gap-3 px-3 text-[11px] font-medium text-gray-400 uppercase tracking-wide">
<span></span>
<span></span>
<span />
</div>
{assignLines.map((line, idx) => (
<div key={idx} className="grid grid-cols-[10rem_1fr_2rem] gap-3 items-center px-3 py-2.5 bg-gray-50 rounded-lg border border-gray-200">
<AssignUserSelect
value={line.userId}
options={assignees}
excludedIds={assignLines.filter((_, i) => i !== idx).map((l) => l.userId).filter((id) => id > 0)}
onChange={(userId) => handleLineChange(idx, "userId", userId)}
disabled={assigneesLoading || assignees.length === 0}
/>
<AssignCountControl
value={line.count}
max={maxCountForLine(b.campaign_id!, idx)}
onChange={(count) => handleLineChange(idx, "count", count)}
disabled={assigneesLoading}
/>
{assignLines.length > 1 ? (
<button type="button" onClick={() => handleRemoveLine(idx)} className="text-gray-400 hover:text-red-500">×</button>
) : <span />}
</div>
))}
</div>
)}
<div className="flex items-center gap-2 mt-4">
<button type="button" onClick={handleAddLine} disabled={assignees.length === 0} className="text-xs text-blue-600 hover:underline disabled:text-gray-300">
</button>
{expandCampaign && unassignedCount(expandCampaign) != null && (
<span className="text-xs text-gray-400"> {unassignedCount(expandCampaign)} </span>
)}
<div className="flex-1" />
<Button size="small" variant="primary" loading={assigning} disabled={assignees.length === 0} onClick={() => handleAssign(b.campaign_id!)}>
</Button>
</div>
</div>
</div>
);
};
return (
<div className="page-container">
<div className="page-header flex items-center justify-between">
@@ -222,40 +307,17 @@ export const CampaignsPage: React.FC = () => {
<h1></h1>
<p></p>
</div>
<Button size="small" variant="default" onClick={load}>
<Button size="small" variant="default" onClick={() => load(offset, limit)}>
</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>
<LabelingListToolbar
search={search}
onSearchChange={setSearch}
placeholder="搜索批次/任务/Campaign..."
total={total}
/>
{feishuSyncHint && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 text-sm text-amber-900">
@@ -279,234 +341,26 @@ export const CampaignsPage: React.FC = () => {
</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;
const isExpanded = expandCampaign === b.campaign_id;
const prog = (b.campaign_id ? progressMap[b.campaign_id] : null) as Record<string, unknown> | null;
const byUser = (prog?.by_user || []) as { user_id: number; name: string; assigned: number; completed: number; percent: number }[];
return (
<div
key={b.campaign_id || b.batch}
className={`card hover:shadow-sm transition-shadow ${isExpanded ? "ring-2 ring-blue-300" : ""}`}
>
{/* Main row */}
<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>
{b.assigned_tasks != null && (
<span className="text-xs text-gray-400">
{b.assigned_tasks}
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{b.campaign_id && (
<>
<Link
to={`/labeling/annotate/${encodeURIComponent(b.campaign_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"
>
</Link>
<button
onClick={() => handleExport(b.campaign_id!)}
disabled={!["labeling_submitted", "returned"].includes(b.stage || "")}
title={!["labeling_submitted", "returned"].includes(b.stage || "") ? "质检通过后才可导出" : undefined}
className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
["labeling_submitted", "returned"].includes(b.stage || "")
? "bg-gray-50 text-gray-600 hover:bg-gray-100"
: "bg-gray-50 text-gray-300 cursor-not-allowed"
}`}
>
📤
</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>
<button
onClick={() => toggleExpand(b.campaign_id!)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 hover:bg-purple-100 transition-colors"
>
👥 {isExpanded ? "收起" : "分配"}
</button>
</>
)}
</div>
</div>
{/* Expanded: task assignment panel */}
{isExpanded && b.campaign_id && (
<div className="mt-4 pt-4 border-t border-gray-200">
{/* ── current assignment progress ── */}
{byUser.length > 0 && (
<div className="mb-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2"></h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{byUser.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg text-xs"
>
<span className="font-medium text-gray-700 truncate">{u.name}</span>
<span className="text-gray-400">
{u.completed}/{u.assigned}
</span>
<div className="flex-1 h-1 bg-gray-200 rounded-full max-w-[40px]">
<div
className="h-full rounded-full bg-blue-400"
style={{ width: `${u.percent}%` }}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* ── assign form ── */}
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-800"></h4>
<p className="text-xs text-gray-500 mt-0.5">
{assignees.length > 0 && (
<span className="text-gray-400"> · {assignees.length} </span>
)}
</p>
</div>
<button
type="button"
onClick={() => loadAssignees()}
disabled={assigneesLoading}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-50"
>
<svg className={`w-3.5 h-3.5 ${assigneesLoading ? "animate-spin" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{assignees.length === 0 && !assigneesLoading ? (
<div className="text-center py-8 px-4 bg-white rounded-lg border border-dashed border-gray-200">
<p className="text-sm text-gray-500 mb-2"></p>
<p className="text-xs text-gray-400"></p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-[10rem_1fr_2rem] gap-3 px-3 text-[11px] font-medium text-gray-400 uppercase tracking-wide">
<span></span>
<span></span>
<span />
</div>
{assignLines.map((line, idx) => (
<div
key={idx}
className="grid grid-cols-[10rem_1fr_2rem] gap-3 items-center px-3 py-2.5 bg-white rounded-lg border border-gray-200"
>
<AssignUserSelect
value={line.userId}
options={assignees}
excludedIds={assignLines
.filter((_, i) => i !== idx)
.map((l) => l.userId)
.filter((id) => id > 0)}
onChange={(userId) => handleLineChange(idx, "userId", userId)}
disabled={assigneesLoading || assignees.length === 0}
/>
<AssignCountControl
value={line.count}
max={maxCountForLine(b.campaign_id!, idx)}
onChange={(count) => handleLineChange(idx, "count", count)}
disabled={assigneesLoading}
/>
{assignLines.length > 1 ? (
<button
type="button"
onClick={() => handleRemoveLine(idx)}
className="h-10 w-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
title="移除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
) : (
<span />
)}
</div>
))}
</div>
)}
<div className="flex items-center gap-2 mt-4">
<button
type="button"
onClick={handleAddLine}
disabled={assignees.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg text-blue-600 hover:bg-blue-50 disabled:text-gray-300 disabled:hover:bg-transparent"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{expandCampaign && unassignedCount(expandCampaign) != null && (
<span className="text-xs text-gray-400">
{unassignedCount(expandCampaign)}
</span>
)}
<div className="flex-1" />
<Button
size="small"
variant="primary"
loading={assigning}
disabled={assignees.length === 0}
onClick={() => handleAssign(b.campaign_id!)}
>
</Button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
<PageQueryState loading={loading} error={error} empty={!loading && total === 0} emptyMessage="暂无进行中的标注活动">
{total > 0 && (
<>
<CampaignBatchTable
batches={batches}
expandCampaign={expandCampaign}
onToggleExpand={toggleExpand}
onExport={handleExport}
onSubmit={handleSubmit}
renderExpand={renderAssignPanel}
/>
<ListPaginationBar
total={total}
offset={offset}
limit={limit}
onOffsetChange={(o) => load(o, limit)}
onLimitChange={(l) => load(0, l)}
/>
</>
)}
</PageQueryState>
</div>
);

View File

@@ -40,7 +40,6 @@ function readStoredScope(fallback: string): string {
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");
@@ -61,15 +60,8 @@ export const CatalogPage: React.FC = () => {
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
@@ -151,7 +143,7 @@ export const CatalogPage: React.FC = () => {
};
// DMS/Forward: use existing data paths
const taskEntry: DmsTaskEntry | undefined = isDmsCatalogScope(scope) ? dmsDetail || (dmsData[scope.task] as DmsTaskEntry | undefined) : undefined;
const taskEntry: DmsTaskEntry | undefined = isDmsCatalogScope(scope) ? (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 || "") : [];
@@ -171,7 +163,7 @@ export const CatalogPage: React.FC = () => {
<h1></h1>
<p></p>
</div>
<Button variant="default" size="small" onClick={() => { load(true); loadDmsDetail(true); }} disabled={loading}></Button>
<Button variant="default" size="small" onClick={() => load(true)} disabled={loading}></Button>
</div>
{err && <p className="text-red-500 text-sm mb-3">{err}</p>}

View File

@@ -1,145 +1,364 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, 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 { Badge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
import { ListPaginationBar } from "@/components/ListPaginationBar";
import { CompactTableShell } from "@/components/CompactTableShell";
import { LabelingListToolbar } from "../components/LabelingListToolbar";
import { DeliveryTable } from "../components/DeliveryTable";
import { DeliveryCreateModal, type DeliveryFormValues } from "../components/DeliveryCreateModal";
import { DeliveryIntakePanel } from "../components/DeliveryIntakePanel";
import { displayTaskFields } from "@/lib/labelingDisplay";
import type { BatchDelivery } from "@/lib/types";
import { useAuth } from "@/app/AuthContext";
type ScanItem = {
project: string;
task: string;
batch_name: string;
images: number;
labels: number;
stage_hint: string;
in_ledger: boolean;
in_workbench: boolean;
needs_ledger: boolean;
delivery_status?: string;
collection_start?: string;
data_path: string;
};
const STATUS_FILTERS = [
{ label: "全部", value: "" },
{ label: "草稿", value: "draft" },
{ label: "待审核", value: "pending_review" },
{ label: "已入湖", value: "in_lake" },
{ label: "入湖失败", value: "ingest_failed" },
{ label: "已驳回", value: "rejected" },
];
function parseApiError(e: unknown): string {
const raw = String(e);
try {
const m = raw.match(/\{.*\}/s);
if (m) {
const j = JSON.parse(m[0]) as { detail?: string };
if (j.detail) return j.detail;
}
} catch { /* ignore */ }
return raw;
}
export const DeliveriesPage: React.FC = () => {
const { hasPermission } = useAuth();
const canSubmit = hasPermission("write:delivery_submit");
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 [actionError, setActionError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [scanning, setScanning] = useState(false);
const [registering, setRegistering] = useState(false);
const [saving, setSaving] = useState(false);
const [showScan, setShowScan] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [scanItems, setScanItems] = useState<ScanItem[]>([]);
const [scanMeta, setScanMeta] = useState<{ needs_ledger: number; needs_workbench: number; scanned_at?: string } | null>(null);
const load = async (newOffset = 0, newLimit = 20) => {
setLoading(true); setError(null);
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));
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)); }
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;
const stats = useMemo(() => {
const inLake = deliveries.filter((d) => d.status === "in_lake").length;
const pending = deliveries.filter((d) => ["draft", "pending_review", "ingesting", "ingest_failed", "rejected"].includes(d.status)).length;
return { inLake, pending };
}, [deliveries]);
const handleScan = async () => {
setScanning(true);
setActionError(null);
try {
const res = await hsapApi.scanDeliveries();
const items = (res.items || []) as unknown as ScanItem[];
setScanItems(items);
setScanMeta({
needs_ledger: res.needs_ledger ?? 0,
needs_workbench: res.needs_workbench ?? 0,
scanned_at: res.scanned_at,
});
setShowScan(true);
} catch (e) {
setActionError(parseApiError(e));
}
setScanning(false);
};
const handleRegisterAll = async () => {
const pending = scanItems.filter((i) => i.needs_ledger);
if (pending.length === 0) {
setInfo("所有 inbox 批次均已登记到台账");
return;
}
if (!confirm(
`${pending.length} 个未登记批次写入台账,并同步到送标工作台?\n\n`
+ "已在 inbox 的批次将直接标为「已入湖」,采集时间取目录修改日期。",
)) return;
setRegistering(true);
setActionError(null);
try {
const r = await hsapApi.registerScannedDeliveries(
pending as unknown as Record<string, unknown>[],
true,
);
setInfo(`台账新增 ${r.created} 条,更新 ${r.updated} 条,工作台同步 ${r.synced_workbench}`);
setShowScan(false);
await load(0, limit);
} catch (e) {
setActionError(parseApiError(e));
}
setRegistering(false);
};
const handleCreateSubmit = async (values: DeliveryFormValues) => {
setSaving(true);
setActionError(null);
try {
await hsapApi.createDelivery({
project: "dms",
batch_name: batchName,
data_path: dataPath,
project: values.project,
task: values.project === "lane" ? values.task || "lane_v1" : values.task.trim() || null,
mode: values.mode.trim() || null,
batch_name: values.batch_name.trim(),
data_path: values.data_path.trim(),
source_type: values.source_type,
collection_start: values.collection_start || null,
collection_end: values.collection_end || null,
estimated_count: values.estimated_count ? Number(values.estimated_count) : null,
vehicle_scene: values.vehicle_scene.trim() || null,
remark: values.remark.trim() || null,
});
load(0, limit);
setShowCreate(false);
await load(0, limit);
setInfo("已保存草稿。请核对后点「提交」走审批入湖。");
} catch (e) {
setError(String(e));
setActionError(parseApiError(e));
}
setSaving(false);
};
const handleSubmit = async (id: string) => {
setActionError(null);
try {
await hsapApi.submitDelivery(id);
load(offset, limit);
await load(offset, limit);
setInfo("已提交审批,请在审核管理批准入湖");
} catch (e) {
setError(String(e));
setActionError(parseApiError(e));
}
};
const handleDelete = async (id: string) => {
if (!confirm("确定删除此送标记录?")) return;
setActionError(null);
try {
await hsapApi.deleteDelivery(id);
load(offset, limit);
await load(offset, limit);
} catch (e) {
setError(String(e));
setActionError(parseApiError(e));
}
};
const pendingScan = scanItems.filter((i) => i.needs_ledger);
return (
<div className="page-container">
<div className="page-header flex items-center justify-between">
<div className="page-header flex items-center justify-between gap-4">
<div>
<h1></h1>
<p></p>
<p> · NAS · </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 className="flex items-center gap-2 shrink-0">
<Button variant="primary" size="small" onClick={handleScan} loading={scanning}>
</Button>
{canSubmit && (
<Button variant="default" size="small" onClick={() => setShowCreate(true)}>
</Button>
)}
</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>
<DeliveryIntakePanel
total={total}
inLake={stats.inLake}
pending={stats.pending}
scanPending={scanMeta?.needs_ledger}
scanning={scanning}
canCreate={canSubmit}
onScan={handleScan}
onCreate={() => setShowCreate(true)}
/>
{info && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">
{info}
</div>
)}
{actionError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-700">
{actionError}
</div>
)}
{showScan && (
<div className="card mb-4 border-blue-200 bg-blue-50/30">
<div className="card-header flex items-center justify-between gap-2">
<span>
{scanItems.length}
{scanMeta && (
<span className="text-gray-500 text-sm ml-2">
{scanMeta.needs_ledger} · {scanMeta.needs_workbench}
{scanMeta.scanned_at && ` · ${scanMeta.scanned_at.slice(11, 19)}`}
</span>
)}
</span>
<div className="flex gap-2 shrink-0">
{pendingScan.length > 0 && canSubmit && (
<Button size="small" variant="primary" loading={registering} onClick={handleRegisterAll}>
({pendingScan.length})
</Button>
)}
<Button size="small" variant="default" onClick={() => setShowScan(false)}></Button>
</div>
))}
</div>
{scanItems.length === 0 ? (
<p className="text-sm text-gray-400 p-4">inbox </p>
) : (
<CompactTableShell colWidths={["10%", "24%", "10%", "8%", "8%", "10%", "10%", "10%"]}>
<thead>
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{scanItems.slice(0, 50).map((item) => (
<tr key={`${item.project}/${item.task}/${item.batch_name}`} className="align-middle">
<td className="py-2 text-xs uppercase text-gray-600">{item.project}</td>
<td className="py-2 text-sm font-medium truncate max-w-[12rem]" title={item.batch_name}>{item.batch_name}</td>
<td className="py-2 text-sm">{displayTaskFields(item)}</td>
<td className="py-2 text-sm tabular-nums">{item.images}</td>
<td className="py-2 text-xs text-gray-500">{item.collection_start?.slice(0, 10) || "—"}</td>
<td className="py-2">
{item.in_ledger
? <Badge variant="success" size="small"></Badge>
: <Badge variant="warning" size="small"></Badge>}
</td>
<td className="py-2">
{item.in_workbench
? <Badge variant="success" size="small"></Badge>
: <Badge variant="default" size="small"></Badge>}
</td>
<td className="py-2 text-xs text-gray-500">{item.stage_hint === "returned" ? "待入库" : "待送标"}</td>
</tr>
))}
</tbody>
</CompactTableShell>
)}
{scanItems.length > 50 && (
<p className="text-xs text-gray-400 p-2"> 50 </p>
)}
</div>
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
)}
<LabelingListToolbar
search={search}
onSearchChange={(v) => { setSearch(v); setOffset(0); }}
placeholder="搜索批次/任务/提单人..."
filters={STATUS_FILTERS}
filterValue={statusFilter}
onFilterChange={(v) => { setStatusFilter(v); setOffset(0); }}
total={total}
extra={<Button size="small" variant="default" onClick={() => load(0, limit)}></Button>}
/>
<PageQueryState loading={loading} error={error} empty={false}>
{!loading && deliveries.length === 0 ? (
<div className="card py-16 text-center">
<div className="text-4xl mb-3">📋</div>
<p className="text-gray-600 font-medium mb-1"></p>
<p className="text-sm text-gray-400 mb-6 max-w-md mx-auto">
inbox
NAS
</p>
<div className="flex justify-center gap-3">
<Button variant="primary" onClick={handleScan} loading={scanning}></Button>
{canSubmit && <Button variant="default" onClick={() => setShowCreate(true)}></Button>}
</div>
</div>
) : (
<>
<DeliveryTable
deliveries={deliveries}
canSubmit={canSubmit}
onSubmit={handleSubmit}
onDelete={handleDelete}
/>
<ListPaginationBar
total={total}
offset={offset}
limit={limit}
onOffsetChange={(o) => load(o, limit)}
onLimitChange={(l) => load(0, l)}
/>
</>
)}
</PageQueryState>
<DeliveryCreateModal
open={showCreate}
saving={saving}
onClose={() => setShowCreate(false)}
onSubmit={handleCreateSubmit}
/>
</div>
);
};

View File

@@ -3,75 +3,67 @@ 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 { ListPaginationBar } from "@/components/ListPaginationBar";
import { LabelingListToolbar } from "../components/LabelingListToolbar";
import { ExportPipelineFlow } from "../components/ExportPipelineFlow";
import { ExportBatchTable } from "../components/ExportBatchTable";
import { defaultBuildPack } from "@/lib/labelingDisplay";
import type { LabelingBatchRow } from "@/lib/types";
export const ExportPage: React.FC = () => {
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
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 [info, setInfo] = useState<string | null>(null);
const [importingId, setImportingId] = useState<string | null>(null);
const [statsMap, setStatsMap] = useState<Record<string, Record<string, unknown>>>({});
const [buildingId, setBuildingId] = useState<string | null>(null);
const [fittingId, setFittingId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [stageFilter, setStageFilter] = useState("");
const [pendingExport, setPendingExport] = useState(0);
const [pendingBuild, setPendingBuild] = useState(0);
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 loadStageCounts = useCallback(async () => {
try {
const [sub, ret] = await Promise.all([
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 1, offset: 0 }),
hsapApi.labelingBatches({ stage: "returned", limit: 1, offset: 0 }),
]);
setPendingExport(sub.total ?? 0);
setPendingBuild(ret.total ?? 0);
} catch { /* optional */ }
}, []);
const load = useCallback(async () => {
const load = useCallback(async (newOffset: number, newLimit: number) => {
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);
const adasReturned = results.filter((b) => b.stage === "returned" && b.project === "adas" && b.campaign_id);
if (adasReturned.length) {
const entries = await Promise.allSettled(
adasReturned.map(async (b) => {
const s = await hsapApi.labelingExportStats(b.campaign_id!);
return [b.campaign_id!, s] as const;
}),
);
const map: Record<string, Record<string, unknown>> = {};
for (const e of entries) {
if (e.status === "fulfilled") map[e.value[0]] = e.value[1];
}
setStatsMap(map);
}
const q = search.trim() || undefined;
const res = await hsapApi.labelingBatches(
stageFilter
? { stage: stageFilter, offset: newOffset, limit: newLimit, q }
: { stages: ["labeling_submitted", "returned"], offset: newOffset, limit: newLimit, q },
);
setBatches((res.items || []) as LabelingBatchRow[]);
setTotal(res.total ?? 0);
setOffset(newOffset);
setLimit(newLimit);
await loadStageCounts();
} catch (e) { setError(String(e)); }
setLoading(false);
}, []);
}, [search, stageFilter, loadStageCounts]);
useEffect(() => { load(); }, [load]);
useEffect(() => { void load(0, limit); }, [search, stageFilter]);
const reloadCurrent = useCallback(() => load(offset, limit), [load, offset, limit]);
const handleExport = async (campaignId: string) => {
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); load(); }
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); reloadCurrent(); }
catch (e) { setError(String(e)); }
};
const handleCuboidFit = async (campaignId: string) => {
setFittingId(campaignId);
try {
await hsapApi.cuboidFit(campaignId);
setInfo("3D 拟合任务已提交");
load();
} catch (e) { setError(String(e)); }
setFittingId(null);
};
const handleSubmitBuild = async (b: LabelingBatchRow) => {
if (!b.task || !b.batch) return;
setBuildingId(b.campaign_id || b.batch);
@@ -81,12 +73,12 @@ export const ExportPage: React.FC = () => {
project: b.project || "dms",
task: b.task,
batch: b.batch,
pack: b.pack || (b.project === "adas" ? "adas_moon3d_v1" : "dms_v2"),
pack: defaultBuildPack(b),
location: b.location || "inbox",
note: `入库 ${b.batch}`,
});
setInfo("build 已提交至审核队列");
load();
setInfo("入库任务已提交至审核队列");
reloadCurrent();
} catch (e) {
setError(String(e));
}
@@ -100,125 +92,65 @@ export const ExportPage: React.FC = () => {
const f = (e.target as HTMLInputElement).files?.[0];
if (!f) return;
setImportingId(campaignId);
try { await hsapApi.importVendorZip(campaignId, f); load(); }
try { await hsapApi.importVendorZip(campaignId, f); reloadCurrent(); }
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>build </p>
</div>
{info && <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm text-green-700">{info}</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">
{["全部", "待导出", "待 build"].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 className="page-header flex items-center justify-between gap-4">
<div>
<h1></h1>
<p>build </p>
</div>
<Button size="small" variant="default" onClick={() => reloadCurrent()} disabled={loading}>
</Button>
</div>
<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> </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> </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> CVAT DMSYOLOADASquaternion_json
{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-green-100 text-green-700 flex items-center justify-center text-xs font-bold">4</span>
<span>
<strong> build</strong> <Badge variant="warning" size="small"> build</Badge> build <Badge variant="success" size="small"></Badge>
<span className="block text-xs text-gray-400 mt-0.5"> buildingested </span>
</span>
</div>
</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>}
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-600">{error}</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.project}/{b.task || "—"}</span>
<Badge variant={b.stage === "returned" ? "warning" : "warning"}>{b.stage === "labeling_submitted" ? "待导出" : "待 build"}</Badge>
</div>
<div className="text-xs text-gray-400 font-mono mt-1">{b.campaign_id?.slice(0, 16) || "—"}</div>
{b.stage === "returned" && b.project === "adas" && b.campaign_id && statsMap[b.campaign_id] && (
<div className="text-xs text-gray-500 mt-1">
quaternion: {String(statsMap[b.campaign_id].quaternion_files ?? "—")} ·
fit_ok: {((Number(statsMap[b.campaign_id].fit_ok_ratio) || 0) * 100).toFixed(0)}% ·
pack: adas_moon3d_v1
</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" && (
<>
{b.project === "adas" && b.campaign_id && (
<Button size="small" variant="default" loading={fittingId === b.campaign_id} onClick={() => handleCuboidFit(b.campaign_id!)}>
3D
</Button>
)}
<Button
size="small"
variant="primary"
loading={buildingId === (b.campaign_id || b.batch)}
onClick={() => handleSubmitBuild(b)}
>
🏗 build
</Button>
<Link to="/system/audit" className="text-xs text-blue-600 hover:underline"> </Link>
</>
)}
</div>
</div>
</div>
))}
</div>
<ExportPipelineFlow pendingExport={pendingExport} pendingBuild={pendingBuild} />
<LabelingListToolbar
search={search}
onSearchChange={setSearch}
filters={[
{ label: "全部", value: "" },
{ label: "待导出", value: "labeling_submitted" },
{ label: "待入库", value: "returned" },
]}
filterValue={stageFilter}
onFilterChange={setStageFilter}
total={total}
/>
<PageQueryState loading={loading} error={null} empty={!loading && total === 0}>
{total > 0 ? (
<>
<ExportBatchTable
batches={batches}
importingId={importingId}
buildingId={buildingId}
onExport={handleExport}
onImportVendor={handleImportVendor}
onSubmitBuild={handleSubmitBuild}
/>
<ListPaginationBar
total={total}
offset={offset}
limit={limit}
onOffsetChange={(o) => load(o, limit)}
onLimitChange={(l) => load(0, l)}
/>
</>
) : (
<div className="card text-center py-12">
<p className="text-gray-400 text-lg mb-3"> build </p>
<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>

View File

@@ -1,21 +1,10 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
import { PageQueryState } from "@/components/PageQueryState";
interface MyAssignmentRow {
campaign_id: string;
batch: string;
task: string;
project?: string;
status?: string;
assigned: number;
completed: number;
pending: number;
campaign_total?: number;
annotate_url?: string;
}
import { MyTasksTable } from "../components/MyTasksTable";
import type { MyAssignmentRow } from "../components/MyTasksTable";
export const MyTasksPage: React.FC = () => {
const location = useLocation();
@@ -66,59 +55,7 @@ export const MyTasksPage: React.FC = () => {
empty={items.length === 0}
emptyMessage="暂无分配给您的任务,请联系协调员分配或等待飞书通知"
>
<div className="space-y-3">
{items.map((row) => {
const pct = row.assigned > 0 ? Math.round((row.completed / row.assigned) * 100) : 0;
const highlighted = highlightId === row.campaign_id;
return (
<div
key={row.campaign_id}
className={`card transition-shadow ${
highlighted ? "ring-2 ring-blue-400 shadow-md" : "hover:shadow-sm"
}`}
>
<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">{row.batch}</span>
<span className="text-xs text-gray-400 font-mono">{row.task}</span>
{row.project && (
<span className="text-xs text-gray-400 uppercase">{row.project}</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span>
<strong className="text-amber-600">{row.pending}</strong>
</span>
<span> {row.completed}</span>
<span> {row.assigned}</span>
{row.campaign_total != null && (
<span className="text-gray-400"> {row.campaign_total} </span>
)}
</div>
<div className="mt-2 flex items-center gap-2 max-w-xs">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-400">{pct}%</span>
</div>
</div>
<Link
to={`/labeling/annotate/${encodeURIComponent(row.campaign_id)}`}
className="shrink-0"
>
<Button size="small" variant="primary">
{row.pending > 0 ? "继续标注" : "查看"}
</Button>
</Link>
</div>
</div>
);
})}
</div>
<MyTasksTable items={items} highlightId={highlightId} />
</PageQueryState>
</div>
);

View File

@@ -1,14 +1,17 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import React, { useEffect, useState, useCallback } 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 { ListPaginationBar } from "@/components/ListPaginationBar";
import { LabelingListToolbar } from "../components/LabelingListToolbar";
import { ReviewBatchTable } from "../components/ReviewBatchTable";
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;
type ReviewProgress = ScoreCounts & { total: number; reviewed: number; pass_rate: number; complete: boolean; stage?: string };
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" },
@@ -17,45 +20,82 @@ const SCORE_CFG: Record<ScoreKey, { label: string; color: string; bg: string; ic
pending: { label: "未评", color: "text-gray-400", bg: "bg-gray-300", icon: "○", btn: "" },
};
const NAV_BTN =
"inline-flex items-center justify-center gap-1.5 min-w-[5.5rem] px-3 py-2 rounded-lg text-sm font-medium transition-all " +
"bg-gray-700/90 text-gray-100 border border-gray-600 " +
"hover:bg-gray-600 hover:border-gray-500 active:scale-[0.98] " +
"disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-gray-700/90 disabled:active:scale-100";
const STAGE_FILTERS = [
{ label: "全部", value: "" },
{ label: "质检中", value: "in_review" },
{ label: "已通过", value: "labeling_submitted" },
{ label: "已退回", value: "review_rejected" },
];
// ── List ──
const ReviewListPage: React.FC = () => {
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
const [filtered, setFiltered] = useState<LabelingBatchRow[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const [progressMap, setProgressMap] = useState<Record<string, ReviewProgress>>({});
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 [rebuilding, setRebuilding] = useState(false);
const [indexedAt, setIndexedAt] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true); setError(null);
const loadProgress = useCallback(async (rows: LabelingBatchRow[]) => {
const ids = rows.map((b) => b.campaign_id).filter(Boolean) as string[];
if (!ids.length) {
setProgressMap({});
return;
}
try {
const results: LabelingBatchRow[] = [];
const [inReview, approved, rejected] = await Promise.allSettled([
hsapApi.labelingBatches({ stage: "in_review", limit: 100 }),
hsapApi.labelingBatches({ stage: "labeling_submitted", 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);
const res = await hsapApi.reviewProgressBatch(ids);
setProgressMap((res.items || {}) as Record<string, ReviewProgress>);
} catch {
setProgressMap({});
}
}, []);
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));
const load = useCallback(async (newOffset: number, newLimit: number) => {
setLoading(true);
setError(null);
try {
const q = search.trim() || undefined;
const res = await hsapApi.labelingBatches(
stageFilter
? { stage: stageFilter, offset: newOffset, limit: newLimit, q }
: { stages: ["in_review", "labeling_submitted", "review_rejected"], offset: newOffset, limit: newLimit, q },
);
const results = (res.items || []) as LabelingBatchRow[];
setBatches(results);
setTotal(res.total ?? 0);
setOffset(newOffset);
setLimit(newLimit);
setIndexedAt(res.updated_at || null);
await loadProgress(results);
} catch (e) {
setError(String(e));
}
if (stageFilter) result = result.filter((b) => b.stage === stageFilter);
result.sort((a, b) => (a[sort] || "").localeCompare(b[sort] || ""));
setFiltered(result);
}, [batches, search, stageFilter, sort]);
setLoading(false);
}, [search, stageFilter, loadProgress]);
useEffect(() => { void load(0, limit); }, [search, stageFilter]);
const handleRebuild = async () => {
setRebuilding(true);
try {
await hsapApi.rebuildBatchIndex();
await load(offset, limit);
} catch (e) {
setError(String(e));
}
setRebuilding(false);
};
return (
<div className="page-container">
@@ -64,72 +104,36 @@ const ReviewListPage: React.FC = () => {
<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", "labeling_submitted", "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 className="flex items-center gap-2">
{indexedAt && <span className="text-xs text-gray-400"> {indexedAt.slice(11, 19)}</span>}
<Button size="small" variant="default" onClick={() => load(offset, limit)} disabled={loading}></Button>
<Button size="small" variant="default" onClick={handleRebuild} loading={rebuilding}></Button>
</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 === "labeling_submitted" && (
<Link to="/labeling/export"><Button size="small" variant="default"> </Button></Link>
)}
{b.stage === "review_rejected" && <span className="text-red-600 text-sm font-medium"> 退</span>}
</div>
</div>
</div>
);
})}
</div>
<LabelingListToolbar
search={search}
onSearchChange={setSearch}
placeholder="搜索批次/任务/项目..."
filters={STAGE_FILTERS}
filterValue={stageFilter}
onFilterChange={setStageFilter}
total={total}
/>
<PageQueryState loading={loading} error={error} empty={!loading && total === 0} emptyMessage="暂无待质检的批次">
{total > 0 && (
<>
<ReviewBatchTable batches={batches} progressMap={progressMap} />
<ListPaginationBar
total={total}
offset={offset}
limit={limit}
onOffsetChange={(o) => load(o, limit)}
onLimitChange={(l) => load(0, l)}
/>
</>
)}
</PageQueryState>
</div>
);
@@ -138,64 +142,105 @@ const ReviewListPage: React.FC = () => {
// ── Detail ──
const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
const [items, setItems] = useState<ReviewItem[]>([]);
const [totalImages, setTotalImages] = useState(0);
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 [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
const [imgLoading, setImgLoading] = useState(false);
const [finishState, setFinishState] = useState<"approved" | "rejected" | null>(null);
const load = useCallback(async () => {
const loadPage = useCallback(async (idx: number) => {
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);
const data = await hsapApi.reviewQueue(campaignId, { offset: idx, limit: 1 });
setItems((data.items || []) as ReviewItem[]);
setTotalImages(Number(data.total) || 0);
if (data.scores) setScores(data.scores as ScoreCounts);
} catch (e) { setError(String(e)); }
setLoading(false);
}, [campaignId]);
useEffect(() => { load(); }, [load]);
useEffect(() => { loadPage(currentIdx); }, [currentIdx, loadPage]);
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";
useEffect(() => {
if (!currentImage) {
setImageBlobUrl(null);
return;
}
let cancelled = false;
setImgLoading(true);
setImgError(false);
hsapApi.fetchReviewImageBlob(campaignId, currentImage.image_path)
.then((url) => {
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setImageBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
setImgLoading(false);
})
.catch(() => {
if (!cancelled) {
setImgError(true);
setImageBlobUrl(null);
setImgLoading(false);
}
});
return () => {
cancelled = true;
setImageBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
}, [campaignId, currentImage?.image_path]);
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(() => {});
hsapApi.reviewSubmit(campaignId, [{ image_path: currentImage.image_path, score }])
.then((data) => {
if (data.auto_advanced) {
setFinishState(data.stage === "review_approved" ? "approved" : "rejected");
}
})
.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;
if (oldScore === "pending" && next.pending > 0) next.pending--;
return next;
});
if (currentIdx < items.length - 1) setTimeout(() => setCurrentIdx((i) => i + 1), 250);
if (currentIdx < totalImages - 1) setTimeout(() => setCurrentIdx((i) => i + 1), 250);
};
const reviewComplete = scores.pending === 0 && totalImages > 0;
const reviewPassed = finishState === "approved" || (reviewComplete && (scores.good + scores.fine) / totalImages >= 0.8);
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 === "ArrowRight") setCurrentIdx((i) => Math.min(totalImages - 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]);
}, [currentIdx, totalImages]);
const reviewed = scores.good + scores.fine + scores.bad;
const total = reviewed + scores.pending;
@@ -203,7 +248,7 @@ const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
const passRate = reviewed > 0 ? ((scores.good + scores.fine) / reviewed) * 100 : 0;
return (
<div className="flex flex-col h-screen bg-gray-900 text-white">
<div className="flex flex-col flex-1 min-h-0 h-full overflow-hidden 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>
@@ -225,36 +270,78 @@ const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
</span>
</header>
<PageQueryState loading={loading} error={error} empty={items.length === 0} emptyMessage="该批次暂无图片">
{(reviewComplete || finishState) && (
<div className={`shrink-0 px-4 py-2.5 flex items-center justify-between gap-3 text-sm ${
reviewPassed ? "bg-green-900/40 border-b border-green-700/50" : "bg-red-900/40 border-b border-red-700/50"
}`}>
<span className={reviewPassed ? "text-green-300" : "text-red-300"}>
{reviewPassed
? `质检完成:合格+可用 ${scores.good + scores.fine}/${totalImages}(通过率 ${Math.round(((scores.good + scores.fine) / totalImages) * 100)}%),可进入导出入库`
: `质检完成:退回 ${scores.bad} 张,批次已退回标注员修正`}
</span>
{reviewPassed ? (
<Link to="/labeling/export">
<Button size="small" variant="success"> </Button>
</Link>
) : (
<Link to="/labeling/review">
<Button size="small" variant="ghost"></Button>
</Link>
)}
</div>
)}
<PageQueryState loading={loading} error={error} empty={!loading && totalImages === 0} emptyMessage="该批次暂无图片">
{currentImage && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Image */}
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
{imgError ? (
<div className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-hidden">
{imgLoading ? (
<div className="text-gray-400 text-center"><p className="text-lg mb-2">...</p></div>
) : imgError || !imageBlobUrl ? (
<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)} />
<img src={imageBlobUrl} alt={currentImage.fileName} className="max-w-full max-h-full object-contain rounded-lg shadow-2xl" />
)}
</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>
{/* Bottom bar — 始终贴在可视区域底部 */}
<div className="shrink-0 bg-gray-800 border-t border-gray-700 px-4 py-3 safe-area-pb">
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3 max-w-5xl mx-auto">
<div className="inline-flex items-center gap-1 p-1 rounded-xl bg-gray-900/70 border border-gray-700/80 shadow-inner">
<button
type="button"
className={NAV_BTN}
onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}
disabled={currentIdx <= 0}
aria-label="上一张"
>
<span aria-hidden></span>
</button>
<span className="px-3 py-1.5 text-sm font-semibold text-gray-200 tabular-nums min-w-[4.5rem] text-center select-none">
{currentIdx + 1}<span className="text-gray-500 font-normal mx-1">/</span>{totalImages}
</span>
<button
type="button"
className={NAV_BTN}
onClick={() => setCurrentIdx((i) => Math.min(totalImages - 1, i + 1))}
disabled={currentIdx >= totalImages - 1}
aria-label="下一张"
>
<span aria-hidden></span>
</button>
</div>
<div className="flex-1" />
<div className="hidden sm:block flex-1" />
<div className="flex flex-wrap items-center justify-center gap-2 w-full sm:w-auto">
{(["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} ${
className={`px-5 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>
<div className="text-center mt-1.5 text-xs text-gray-500">
{currentImage.fileName} · : {currentImage.has_label ? "有" : "无"} · 快捷键: G/F/B

View File

@@ -1,91 +1,66 @@
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 { ListPaginationBar } from "@/components/ListPaginationBar";
import { LabelingListToolbar } from "../components/LabelingListToolbar";
import { WorkbenchBatchTable } from "../components/WorkbenchBatchTable";
import { displayTaskFields } from "@/lib/labelingDisplay";
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 [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 [info, setInfo] = useState<string | null>(null);
const [indexedAt, setIndexedAt] = useState<string | null>(null);
const [rebuilding, setRebuilding] = useState(false);
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);
});
const STAGE_FILTERS = [
{ label: "待送标", value: "raw_pool" },
{ label: "标中", value: "out_for_labeling" },
{ label: "待入库", value: "returned" },
];
// 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);
const load = useCallback(async (filterStage: string, newOffset: number, newLimit: number) => {
setLoading(true);
setError(null);
try {
const res = await hsapApi.labelingBatches({ stage: filterStage, limit: 100 });
const q = search.trim() || undefined;
const res = await hsapApi.labelingBatches({ stage: filterStage, offset: newOffset, limit: newLimit, q });
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, adas] = await Promise.all([
hsapApi.scanInbox("dms"),
hsapApi.scanInbox("adas"),
]);
const items = [
...((dms.items || []) as unknown as ScanItem[]),
...((adas.items || []) as unknown as ScanItem[]),
];
setScanItems(items);
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 */ }
setTotal(res.total ?? 0);
setOffset(newOffset);
setLimit(newLimit);
setIndexedAt(res.updated_at || null);
} catch (e) {
setError(String(e));
}
setScanItems([]); setShowScan(false);
load(stage);
setInfo(`已登记 ${count} 个批次`);
setLoading(false);
}, [search]);
const [archivingId, setArchivingId] = useState<string | null>(null);
const handleRebuildIndex = async () => {
setRebuilding(true);
setError(null);
try {
const r = await hsapApi.rebuildBatchIndex();
setInfo(`索引已更新 ${r.count} 条(${r.elapsed_ms}ms`);
await load(stage, offset, limit);
} catch (e) {
setError(String(e));
}
setRebuilding(false);
};
useEffect(() => { void load(stage, 0, limit); }, [stage, search]);
const handleOpenCampaign = async (row: LabelingBatchRow) => {
if (!row.task) return;
try {
@@ -93,15 +68,31 @@ export const WorkbenchPage: React.FC = () => {
project: row.project, task: row.task, batch: row.batch,
mode: row.mode || null, pack: row.pack || null, location: row.location,
});
load(stage);
load(stage, offset, limit);
} catch (e) { setError(String(e)); }
};
const STAGES = [
{ key: "raw_pool", label: "待送标" },
{ key: "out_for_labeling", label: "标中" },
{ key: "returned", label: "待入库" },
];
const handleArchive = async (row: LabelingBatchRow) => {
const cid = row.campaign_id;
if (!cid) return;
const label = `${displayTaskFields(row)} / ${row.batch}`;
const ok = window.confirm(
`确定从工作台移除「${label}」?\n\n`
+ "不会删除磁盘上的图片与标注文件,仅从列表隐藏。\n"
+ "之后可在批次台账重新扫描登记。",
);
if (!ok) return;
setArchivingId(cid);
setError(null);
try {
await hsapApi.archiveLabelingBatch(cid);
setInfo(`已移除: ${row.batch}`);
await load(stage, offset, limit);
} catch (e) {
setError(String(e));
}
setArchivingId(null);
};
return (
<div className="page-container">
@@ -110,105 +101,48 @@ export const WorkbenchPage: React.FC = () => {
<h1></h1>
<p></p>
</div>
<Button variant="primary" size="small" onClick={handleScan} loading={scanning}>
</Button>
<div className="flex items-center gap-2">
<Button variant="default" size="small" onClick={handleRebuildIndex} loading={rebuilding}>
</Button>
{indexedAt && <span className="text-xs text-gray-400"> {indexedAt.slice(11, 19)}</span>}
</div>
</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 className="bg-blue-50 border border-blue-100 rounded-lg px-4 py-2 mb-4 text-xs text-blue-800">
<a href="/labeling/deliveries" className="underline font-medium"></a> NAS
</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} />
{(b as any).annotation_types?.map((t: string) => (
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-medium">
{{bbox: "2D框", keypoint: "关键点", polyline: "车道线", cuboid: "3D框"}[t] || t}
</span>
))}
</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 && (
<Link to={`/labeling/annotate/${b.campaign_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">
</Link>
)}
{b.stage === "returned" && (
<Link to="/labeling/export"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-50 text-orange-700 hover:bg-orange-100 transition-colors">
🏗 build
</Link>
)}
</div>
</div>
</div>
))}
</div>
<LabelingListToolbar
search={search}
onSearchChange={setSearch}
placeholder="搜索批次/任务..."
filters={STAGE_FILTERS}
filterValue={stage}
onFilterChange={setStage}
total={total}
/>
<PageQueryState loading={loading} error={error} empty={!loading && total === 0}>
{total > 0 && (
<>
<WorkbenchBatchTable
batches={batches}
onOpenCampaign={handleOpenCampaign}
onArchive={handleArchive}
archivingId={archivingId}
/>
<ListPaginationBar
total={total}
offset={offset}
limit={limit}
onOffsetChange={(o) => load(stage, o, limit)}
onLimitChange={(l) => load(stage, 0, l)}
/>
</>
)}
</PageQueryState>
</div>
);

View File

@@ -2,41 +2,44 @@ import React, { useState } from "react";
import { useAuth } from "@/app/AuthContext";
import { Button } from "@/components/ui/Button";
const LOGIN_BG = "/login-bg.png";
export const LoginPage: React.FC = () => {
const { authConfig, loginDev, loginFeishu, loading } = useAuth();
const [devName, setDevName] = useState("开发用户");
const [loggingIn, setLoggingIn] = useState(false);
const shellCls =
"min-h-screen relative flex items-center justify-end bg-cover bg-center bg-no-repeat px-6 py-10 md:px-16";
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-400 animate-pulse">...</div>
<div className={shellCls} style={{ backgroundImage: `url(${LOGIN_BG})` }}>
<div className="absolute inset-0 bg-slate-900/25" />
<div className="relative z-10 text-white/90 animate-pulse">...</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 w-full max-w-sm">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">HSAP</h1>
<p className="text-sm text-gray-500 mt-1"> Sentinel </p>
<div className={shellCls} style={{ backgroundImage: `url(${LOGIN_BG})` }}>
<div className="absolute inset-0 bg-gradient-to-l from-slate-900/55 via-slate-900/20 to-transparent" />
<div className="relative z-10 w-full max-w-md rounded-2xl border border-white/20 bg-white/92 backdrop-blur-md shadow-2xl p-8">
<div className="mb-6">
<p className="text-xs font-medium tracking-widest text-blue-600 uppercase">HSAP Platform</p>
<h1 className="text-2xl font-bold text-gray-900 mt-1"> Sentinel</h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
{/* Feishu login */}
{authConfig?.feishu_enabled && (
<Button
variant="primary"
className="w-full mb-3"
onClick={loginFeishu}
>
<Button variant="primary" className="w-full mb-3" onClick={loginFeishu}>
</Button>
)}
{/* Dev login */}
{authConfig?.dev_auth_enabled && (
<div className="border-t pt-4 mt-4">
<div className="border-t border-gray-200 pt-4 mt-4">
<p className="text-xs text-gray-400 mb-2 text-center"></p>
<input
className="form-input mb-2"

View File

@@ -26,8 +26,8 @@ html, body, #root {
/* ---- Sidebar ---- */
.sidebar {
width: 240px;
min-width: 240px;
width: 188px;
min-width: 188px;
background: #ffffff;
border-right: 1px solid #e5e7eb;
display: flex;
@@ -37,11 +37,19 @@ html, body, #root {
}
.sidebar-brand {
padding: 16px;
padding: 12px 14px;
border-bottom: 1px solid #e5e7eb;
font-weight: 700;
font-size: 16px;
font-size: 14px;
color: var(--lsf-primary-emphasis);
line-height: 1.35;
}
.sidebar-brand .text-gray-400 {
display: block;
font-size: 11px;
font-weight: 500;
margin-top: 2px;
}
.sidebar-nav {
@@ -51,9 +59,9 @@ html, body, #root {
}
.sidebar-footer {
padding: 12px 16px;
padding: 10px 12px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
font-size: 11px;
}
/* ---- Module Group ---- */
@@ -64,10 +72,10 @@ html, body, #root {
.module-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #4b5563;
border: none;
@@ -110,8 +118,8 @@ html, body, #root {
.module-group-items .nav-item {
display: block;
padding: 7px 16px 7px 40px;
font-size: 13px;
padding: 6px 12px 6px 32px;
font-size: 12px;
color: #6b7280;
text-decoration: none;
transition: background 0.15s, color 0.15s;
@@ -147,6 +155,21 @@ html, body, #root {
flex: 1;
overflow-y: auto;
padding: 0;
min-height: 0;
}
.main-content > .module-area:has(.labeling-immersive) {
display: flex;
flex-direction: column;
overflow: hidden;
}
.labeling-immersive {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- Module Tab Bar ---- */
@@ -155,13 +178,13 @@ html, body, #root {
gap: 0;
border-bottom: 2px solid #e5e7eb;
background: #ffffff;
padding: 0 24px;
padding: 0 16px;
overflow-x: auto;
}
.module-tab {
padding: 12px 20px;
font-size: 14px;
padding: 10px 14px;
font-size: 13px;
color: #6b7280;
text-decoration: none;
border-bottom: 2px solid transparent;
@@ -182,8 +205,8 @@ html, body, #root {
/* ---- Shared UI ---- */
.page-container {
padding: 24px;
max-width: 1400px;
padding: 20px;
max-width: none;
}
.page-header {