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:
@@ -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} />
|
||||
|
||||
321
platform/web/src/modules/labeling/pages/AnnotationPage.tsx
Normal file
321
platform/web/src/modules/labeling/pages/AnnotationPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
125
platform/web/src/modules/labeling/pages/MyTasksPage.tsx
Normal file
125
platform/web/src/modules/labeling/pages/MyTasksPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user