feat: CVAT 标注引擎、我的标注收件箱与 ADAS Cuboid 送标

- 统一标注引擎为 CVAT:客户端/配置/格式转换、iframe 标注页、docker-compose.cvat.yml 与 no_auth 补丁
- 移除 Label Studio 相关配置与构建脚本,清理 embedded.bak 备份与误提交的 node_modules
- 新增「我的标注」:跨 Campaign 收件箱、逐张清单、CVAT frame 跳转
- 飞书任务分配:通讯录同步选人、按量分配、分配后 DM 通知(含 my-tasks 链接)
- ADAS cuboid_7cls 数据湖接入:workflow 路径、register-batch、开标上传与标注同步
- 数据湖挂载 AS_DATA_LAKE_ROOT、datasets/adas 符号链接、reset_labeling 运维脚本
- 补充 docs/HANDOVER.md 项目交接文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:25:28 +08:00
parent e72bc061c5
commit 672ef61e17
5281 changed files with 5194 additions and 1315918 deletions

View File

@@ -1,44 +1,64 @@
import React from "react";
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
import { Switch, Route, Redirect, NavLink, useRouteMatch, useLocation } from "react-router-dom";
import { ModuleGuard } from "@/components/ModuleGuard";
import { useAuth } from "@/app/AuthContext";
import { WorkbenchPage } from "./pages/WorkbenchPage";
import { MyTasksPage } from "./pages/MyTasksPage";
import { CampaignsPage } from "./pages/CampaignsPage";
import { ExportPage } from "./pages/ExportPage";
import { DeliveriesPage } from "./pages/DeliveriesPage";
import { CatalogPage } from "./pages/CatalogPage";
import { QualityReviewPage } from "./pages/QualityReviewPage";
import { SimulationStudioPage } from "./pages/SimulationStudioPage";
import { AnnotationPage } from "./pages/AnnotationPage";
const TABS = [
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending" },
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending" },
{ to: "/labeling/my-tasks", label: "我的标注", perm: "read:pending" },
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending", coordinatorOnly: true },
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending", coordinatorOnly: true },
{ to: "/labeling/review", label: "标注质检", perm: "write:approval_review" },
{ to: "/labeling/export", label: "导出与入库", perm: "read:pending" },
{ to: "/labeling/export", label: "导出与入库", perm: "read:pending", coordinatorOnly: true },
{ to: "/labeling/deliveries", label: "批次台账", perm: "read:deliveries" },
{ to: "/labeling/catalog", label: "数据目录", perm: "read:catalog" },
{ to: "/labeling/simulate", label: "仿真工坊", perm: "read:pending" },
{ to: "/labeling/simulate", label: "仿真工坊", perm: "read:pending", coordinatorOnly: true },
];
export const LabelingShell: React.FC = () => {
const { path } = useRouteMatch();
const location = useLocation();
const { hasPermission } = useAuth();
const isCoordinator = hasPermission("write:labeling_assign");
const isAnnotate = /\/labeling\/annotate\//.test(location.pathname);
const visibleTabs = TABS.filter((tab) => {
if (tab.coordinatorOnly && !isCoordinator) return false;
if (tab.perm && !hasPermission(tab.perm)) return false;
return true;
});
const defaultPath = isCoordinator ? "/labeling/workbench" : "/labeling/my-tasks";
return (
<ModuleGuard requiredPerms={["read:pending", "read:deliveries", "read:catalog", "write:delivery_submit"]}>
<div>
<nav className="module-tabs">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className="module-tab"
activeClassName="active"
>
{tab.label}
</NavLink>
))}
</nav>
<div className={isAnnotate ? "h-[calc(100vh-0px)] flex flex-col min-h-0" : undefined}>
{!isAnnotate && (
<nav className="module-tabs">
{visibleTabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className="module-tab"
activeClassName="active"
>
{tab.label}
</NavLink>
))}
</nav>
)}
<Switch>
<Route exact path={`${path}`} render={() => <Redirect to="/labeling/workbench" />} />
<Route exact path={`${path}`} render={() => <Redirect to={defaultPath} />} />
<Route path={`${path}/annotate/:campaignId`} component={AnnotationPage} />
<Route path={`${path}/my-tasks`} component={MyTasksPage} />
<Route path={`${path}/workbench`} component={WorkbenchPage} />
<Route path={`${path}/review/:campaignId`} component={QualityReviewPage} />
<Route path={`${path}/review`} component={QualityReviewPage} />

View File

@@ -0,0 +1,321 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useHistory } from "react-router-dom";
import { hsapApi } from "@/app/hsap-api";
import { useAuth } from "@/app/AuthContext";
interface CVATStatus {
cvat_available: boolean;
cvat_job_url?: string;
cvat_status?: string;
campaign_id: string;
error?: string;
}
interface MyTaskItem {
task_id: string;
filename: string;
relative_path: string;
completed: boolean;
frame_index: number;
}
interface SyncResult {
saved?: number;
shapes?: number;
}
const AUTO_SYNC_MS = 45_000;
function cvatUrlWithFrame(baseUrl: string, frameIndex: number): string {
if (frameIndex < 0) return baseUrl;
const sep = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${sep}frame=${frameIndex}`;
}
export const AnnotationPage: React.FC = () => {
const { campaignId } = useParams<{ campaignId: string }>();
const history = useHistory();
const { hasPermission } = useAuth();
const isCoordinator = hasPermission("write:labeling_assign");
const backPath = isCoordinator ? "/labeling/campaigns" : "/labeling/my-tasks";
const [status, setStatus] = useState<CVATStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [syncHint, setSyncHint] = useState<string>("CVAT 保存后约 45 秒自动同步");
const syncInFlight = useRef(false);
const [myTasks, setMyTasks] = useState<MyTaskItem[]>([]);
const [myStats, setMyStats] = useState({ assigned: 0, completed: 0, pending: 0 });
const [sidebarOpen, setSidebarOpen] = useState(!isCoordinator);
const [activeFrame, setActiveFrame] = useState<number>(-1);
const [iframeSrc, setIframeSrc] = useState<string>("");
const loadMyTasks = useCallback(async () => {
if (!campaignId) return;
try {
const res = await hsapApi.campaignMyTasks(campaignId);
setMyTasks(res.items || []);
setMyStats({
assigned: res.assigned ?? 0,
completed: res.completed ?? 0,
pending: res.pending ?? 0,
});
} catch {
setMyTasks([]);
setMyStats({ assigned: 0, completed: 0, pending: 0 });
}
}, [campaignId]);
useEffect(() => {
void loadMyTasks();
}, [loadMyTasks]);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const res = await fetch(`/api/v1/labeling/cvat/status/${campaignId}`, {
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
});
const data = await res.json();
if (!res.ok) {
if (!cancelled) {
setFetchError(typeof data.detail === "string" ? data.detail : `HTTP ${res.status}`);
setLoading(false);
}
return;
}
if (!cancelled) {
setFetchError(null);
setStatus(data as CVATStatus);
setLoading(false);
}
} catch (e) {
if (!cancelled) {
setFetchError(String(e));
setLoading(false);
}
}
};
poll();
const interval = setInterval(poll, 10000);
return () => { cancelled = true; clearInterval(interval); };
}, [campaignId]);
useEffect(() => {
if (!status?.cvat_job_url) return;
setIframeSrc(cvatUrlWithFrame(status.cvat_job_url, activeFrame));
}, [status?.cvat_job_url, activeFrame]);
const runSync = useCallback(async (silent = false): Promise<SyncResult | null> => {
if (syncInFlight.current) return null;
syncInFlight.current = true;
if (!silent) setSyncing(true);
try {
const res = await fetch(`/api/v1/labeling/cvat/sync/${campaignId}`, {
method: "POST",
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = typeof data.detail === "string" ? data.detail : `HTTP ${res.status}`;
if (!silent) alert(`同步失败: ${msg}`);
else setSyncHint(`自动同步失败: ${msg}`);
return null;
}
const saved = Number(data.saved ?? 0);
const shapes = Number(data.shapes ?? 0);
const hint = shapes > 0
? `已同步 ${saved} 张 · ${shapes} 个标注 · ${new Date().toLocaleTimeString()}`
: `已检查,暂无新标注 · ${new Date().toLocaleTimeString()}`;
setSyncHint(hint);
if (!silent && shapes > 0) alert(`标注已同步(${saved} 张,${shapes} 个对象)`);
void loadMyTasks();
return data as SyncResult;
} catch (e) {
if (!silent) alert(`同步失败: ${e}`);
else setSyncHint(`自动同步失败: ${e}`);
return null;
} finally {
syncInFlight.current = false;
if (!silent) setSyncing(false);
}
}, [campaignId, loadMyTasks]);
useEffect(() => {
if (!status?.cvat_available || !status.cvat_job_url) return;
const tick = () => {
if (document.visibilityState === "visible") {
void runSync(true);
}
};
const interval = setInterval(tick, AUTO_SYNC_MS);
return () => clearInterval(interval);
}, [status?.cvat_available, status?.cvat_job_url, runSync]);
const handleBack = async () => {
await runSync(true);
history.push(backPath);
};
const openInNewTab = () => {
const url = iframeSrc || status?.cvat_job_url;
if (url) window.open(url, "_blank", "noopener,noreferrer");
};
const handleTaskClick = (item: MyTaskItem) => {
if (item.frame_index < 0 || !status?.cvat_job_url) return;
setActiveFrame(item.frame_index);
setIframeSrc(cvatUrlWithFrame(status.cvat_job_url, item.frame_index));
};
const showSidebar = myStats.assigned > 0;
if (loading) {
return (
<div className="flex items-center justify-center flex-1 min-h-[60vh] bg-gray-900 text-white">
<div className="text-center">
<div className="animate-spin text-3xl mb-4"></div>
<p> CVAT ...</p>
</div>
</div>
);
}
if (fetchError) {
return (
<div className="flex items-center justify-center flex-1 min-h-[60vh] bg-gray-900 text-white">
<div className="text-center max-w-md">
<div className="text-4xl mb-4">🔒</div>
<p className="mb-2"></p>
<p className="text-red-400 text-sm">{fetchError}</p>
<button onClick={() => history.push(backPath)} className="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-700">
</button>
</div>
</div>
);
}
if (!status?.cvat_available) {
return (
<div className="flex items-center justify-center flex-1 min-h-[60vh] bg-gray-900 text-white">
<div className="text-center max-w-md">
<div className="text-4xl mb-4"></div>
<p className="mb-2">CVAT </p>
{status?.error && <p className="text-red-400 text-sm">{status.error}</p>}
<button onClick={() => history.push(backPath)} className="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-700">
</button>
</div>
</div>
);
}
if (!status.cvat_job_url) {
return (
<div className="flex items-center justify-center flex-1 min-h-[60vh] bg-gray-900 text-white">
<div className="text-center max-w-md">
<div className="text-4xl mb-4"></div>
<p className="mb-2">CVAT Job </p>
<button onClick={() => history.push(backPath)} className="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-700">
</button>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0 bg-gray-900">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={handleBack}
className="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 rounded text-white shrink-0"
>
</button>
{showSidebar && (
<button
onClick={() => setSidebarOpen((v) => !v)}
className="px-2 py-1 text-xs bg-gray-700 hover:bg-gray-600 rounded text-gray-200 shrink-0"
>
{sidebarOpen ? "收起清单" : "我的清单"}
</button>
)}
<span className="text-white font-medium truncate">
{myStats.assigned > 0 && (
<span className="text-gray-400 font-normal text-sm ml-2">
{myStats.completed}/{myStats.assigned}
</span>
)}
</span>
<span className="text-xs text-gray-400 hidden lg:inline truncate">{syncHint}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={openInNewTab}
className="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 rounded text-white"
>
</button>
<button
onClick={() => runSync(false)}
disabled={syncing}
className="px-4 py-1 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded text-white"
>
{syncing ? "同步中..." : "立即同步"}
</button>
</div>
</div>
<div className="flex flex-1 min-h-0">
{showSidebar && sidebarOpen && (
<aside className="w-72 shrink-0 border-r border-gray-700 flex flex-col min-h-0 bg-gray-800">
<div className="px-3 py-2 border-b border-gray-700 text-xs text-gray-400">
· {myStats.pending}
</div>
<ul className="flex-1 overflow-y-auto py-1">
{myTasks.length === 0 ? (
<li className="px-3 py-4 text-sm text-gray-500 text-center">
{isCoordinator ? "您在本批无个人分配" : "暂无分配给您的图,请联系协调员"}
</li>
) : (
myTasks.map((item) => (
<li key={item.task_id}>
<button
type="button"
onClick={() => handleTaskClick(item)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-700 ${
activeFrame === item.frame_index ? "bg-gray-700 text-blue-300" : "text-gray-200"
}`}
>
<span className={`shrink-0 w-4 text-center ${item.completed ? "text-green-400" : "text-gray-500"}`}>
{item.completed ? "✓" : "○"}
</span>
<span className="truncate flex-1" title={item.filename}>
{item.filename || item.task_id.slice(0, 8)}
</span>
</button>
</li>
))
)}
</ul>
</aside>
)}
<iframe
key={iframeSrc}
src={iframeSrc || status.cvat_job_url}
className="flex-1 w-full min-h-0 border-0"
title="CVAT Annotation"
allow="autoplay; camera; microphone"
/>
</div>
</div>
);
};

View File

@@ -4,41 +4,215 @@ import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
import { StageBadge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
import { AssignUserSelect } from "@/components/AssignUserSelect";
import { AssignCountControl } from "@/components/AssignCountControl";
import type { LabelingBatchRow } from "@/lib/types";
interface Assignee {
id: number;
name: string;
roles: string[];
department_names?: string[];
avatar_url?: string;
}
interface AssignLine {
userId: number;
count: number;
}
export const CampaignsPage: React.FC = () => {
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [feishuSyncHint, setFeishuSyncHint] = useState<{ message: string; url?: string } | null>(null);
const [search, setSearch] = useState("");
// assignees & assignment state
const [assignees, setAssignees] = useState<Assignee[]>([]);
const [assigneesLoading, setAssigneesLoading] = useState(false);
const [expandCampaign, setExpandCampaign] = useState<string | null>(null);
const [assignLines, setAssignLines] = useState<AssignLine[]>([{ userId: 0, count: 0 }]);
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);
return (
(b.batch || "").toLowerCase().includes(q) ||
(b.task || "").toLowerCase().includes(q) ||
(b.campaign_id || "").toLowerCase().includes(q)
);
});
const load = useCallback(async () => {
setLoading(true); setError(null);
setLoading(true);
setError(null);
try {
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", limit: 100 });
setBatches((res.items || []) as LabelingBatchRow[]);
} catch (e) { setError(String(e)); }
} catch (e) {
setError(String(e));
}
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const loadAssignees = useCallback(async () => {
setAssigneesLoading(true);
try {
const res = await hsapApi.labelingAssignees();
setAssignees((res.items || []) as Assignee[]);
const sync = (res as {
sync?: {
error?: string;
total?: number;
feishu_configured?: boolean;
contact_scope_url?: string;
publish_url?: string;
};
}).sync;
const total = sync?.total ?? (res.items || []).length;
if (sync?.feishu_configured && sync.error && total === 0) {
setFeishuSyncHint({
message: sync.error,
url: sync.publish_url || sync.contact_scope_url,
});
} else if (
sync?.feishu_configured &&
sync.error &&
(sync.error.includes("通讯录") || sync.error.includes("全部员工") || total <= 5)
) {
setFeishuSyncHint({
message: sync.error,
url: sync.publish_url || sync.contact_scope_url,
});
} else if (total > 0) {
setFeishuSyncHint(null);
}
} catch (e) {
setError(String(e));
}
setAssigneesLoading(false);
}, []);
useEffect(() => {
load();
loadAssignees();
}, [load, loadAssignees]);
// 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]);
const handleExport = async (campaignId: string) => {
setInfo(null);
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); }
catch (e) { setError(String(e)); }
try {
await hsapApi.labelingExport(campaignId);
setInfo("导出任务已提交");
} catch (e) {
setError(String(e));
}
};
const handleSubmit = async (campaignId: string) => {
try { await hsapApi.submitLabelingCampaign(campaignId); load(); }
catch (e) { setError(String(e)); }
try {
await hsapApi.submitLabelingCampaign(campaignId);
load();
} catch (e) {
setError(String(e));
}
};
const handleAddLine = () => {
setAssignLines((prev) => [...prev, { userId: 0, count: 0 }]);
};
const handleRemoveLine = (idx: number) => {
setAssignLines((prev) => prev.filter((_, i) => i !== idx));
};
const handleLineChange = (idx: number, field: "userId" | "count", value: number) => {
setAssignLines((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)));
};
const handleAssign = async (campaignId: string) => {
const valid = assignLines.filter((l) => l.userId > 0 && l.count > 0);
if (valid.length === 0) {
setError("请至少选择一个用户并设置数量");
return;
}
setAssigning(true);
setError(null);
try {
const items = valid.map((l) => ({ user_id: l.userId, count: l.count }));
const res = await hsapApi.assignTasksQuantized(campaignId, items);
const assigned = (res as { assigned?: number }).assigned ?? 0;
const notifications = (res as {
notifications?: {
ok?: boolean;
name?: string;
message?: string;
help_url?: string;
help_text?: string;
channel?: string;
}[];
}).notifications ?? [];
const sent = notifications.filter((n) => n.ok);
const failed = notifications.filter((n) => !n.ok);
let msg = `已分配 ${assigned} 个任务`;
if (sent.length > 0) {
msg += `,已通知 ${sent.map((n) => n.name).join("、")}`;
}
if (failed.length > 0) {
const hint = failed[0]?.help_text || failed[0]?.message || "发送失败";
msg += `;通知未送达(${hint}`;
if (failed[0]?.help_url) {
setFeishuSyncHint({
message: hint,
url: failed[0].help_url,
});
}
} else if (notifications.length === 0 && valid.some((l) => assignees.find((a) => a.id === l.userId))) {
msg += ";被分配人未绑定飞书账号,无法通知";
}
setInfo(msg);
setAssignLines([{ userId: 0, count: 0 }]);
// refresh progress
const p = await hsapApi.campaignProgress(campaignId);
setProgressMap((prev) => ({ ...prev, [campaignId]: p }));
load();
} catch (e) {
setError(String(e));
}
setAssigning(false);
};
const toggleExpand = (cid: string) => {
setExpandCampaign((prev) => (prev === cid ? null : cid));
setAssignLines([{ userId: 0, count: 0 }]);
setError(null);
};
const unassignedCount = (campaignId: string) => {
const prog = progressMap[campaignId] as { unassigned_tasks?: number } | undefined;
return prog?.unassigned_tasks ?? null;
};
const maxCountForLine = (campaignId: string, lineIdx: number) => {
const total = unassignedCount(campaignId);
if (total == null) return 99;
const used = assignLines
.filter((_, i) => i !== lineIdx)
.reduce((sum, l) => sum + (l.count || 0), 0);
return Math.max(0, total - used);
};
return (
@@ -48,31 +222,80 @@ export const CampaignsPage: React.FC = () => {
<h1></h1>
<p></p>
</div>
<Button size="small" variant="default" onClick={load}></Button>
<Button size="small" variant="default" onClick={load}>
</Button>
</div>
{/* Search */}
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
<div className="flex items-center gap-3">
<div className="flex-1 min-w-[200px] relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<svg
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)} />
<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>
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">
{filtered.length}
</span>
</div>
</div>
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
{feishuSyncHint && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 text-sm text-amber-900">
<p>{feishuSyncHint.message}</p>
{feishuSyncHint.url && (
<a
href={feishuSyncHint.url}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-800 underline mt-1 inline-block"
>
</a>
)}
</div>
)}
{info && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">
{info}
</div>
)}
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无进行中的标注活动">
<div className="space-y-3">
{filtered.map((b) => {
const pct = b.total_tasks && b.total_tasks > 0 ? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100) : 0;
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">
<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">
@@ -81,37 +304,199 @@ export const CampaignsPage: React.FC = () => {
<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.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
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>
<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 && (
<>
<a href={`/labeling/campaigns/${encodeURIComponent(b.campaign_id)}/annotate`} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">
<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"
>
</a>
<button onClick={() => handleExport(b.campaign_id!)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors">
</Link>
<button
onClick={() => handleExport(b.campaign_id!)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors"
>
📤
</button>
<button onClick={() => handleSubmit(b.campaign_id!)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition-colors">
<button
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>
);
})}

View File

@@ -0,0 +1,125 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link, 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;
}
export const MyTasksPage: React.FC = () => {
const location = useLocation();
const highlightId = new URLSearchParams(location.search).get("campaign");
const [items, setItems] = useState<MyAssignmentRow[]>([]);
const [totalPending, setTotalPending] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await hsapApi.myAssignments();
setItems((res.items || []) as MyAssignmentRow[]);
setTotalPending(res.total_pending ?? 0);
} catch (e) {
setError(String(e));
}
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
return (
<div className="page-container">
<div className="page-header flex items-center justify-between">
<div>
<h1></h1>
<p>
{totalPending > 0 && (
<span className="text-amber-600 font-medium"> · {totalPending} </span>
)}
</p>
</div>
<Button size="small" variant="default" onClick={load}>
</Button>
</div>
<PageQueryState
loading={loading}
error={error}
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>
</PageQueryState>
</div>
);
};

View File

@@ -42,8 +42,15 @@ export const WorkbenchPage: React.FC = () => {
const handleScan = async () => {
setScanning(true); setError(null);
try {
const dms = await hsapApi.scanInbox("dms");
setScanItems((dms.items || []) as unknown as ScanItem[]);
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);
@@ -170,6 +177,11 @@ export const WorkbenchPage: React.FC = () => {
<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>
@@ -181,10 +193,10 @@ export const WorkbenchPage: React.FC = () => {
<Button size="small" variant="primary" onClick={() => handleOpenCampaign(b)}></Button>
)}
{b.stage === "out_for_labeling" && b.campaign_id && (
<a href={`/labeling/campaigns/${b.campaign_id}/annotate`} target="_blank" rel="noopener noreferrer"
<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">
</a>
</Link>
)}
</div>
</div>