feat: initial HSAP platform

Huaxu Sentinel Active Safety Platform with embedded algorithm code,
Docker Compose setup, and vendored dataset scaffolds for clone-and-run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 16:59:59 +08:00
commit 7c43b44c57
1619 changed files with 373355 additions and 0 deletions

3
platform/web/env.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# 将 Node.js~/.local/node加入 PATH供 npm run dev 使用
export PATH="${HOME}/.local/node/bin:${PATH}"

15
platform/web/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HSAP · Huaxu Sentinel Active Safety Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1878
platform/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
platform/web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "huaxu-as-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}

47
platform/web/src/App.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { RequireAuth } from "./auth/RequireAuth";
import { Layout } from "./components/Layout";
import { ToastProvider } from "./components/Toast";
import { AuthCallbackPage } from "./pages/AuthCallback";
import { LoginPage } from "./pages/Login";
import { LabelingPage } from "./pages/Labeling";
import { CatalogPage } from "./pages/Catalog";
import { AuditDetailPage, AuditPage } from "./pages/Audit";
import { JobsPage } from "./pages/Jobs";
import { TrainingPage } from "./pages/Training";
import { LogsPage } from "./pages/Logs";
export default function App() {
return (
<ToastProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/labeling" replace />} />
<Route path="labeling" element={<LabelingPage />} />
<Route path="catalog" element={<CatalogPage />} />
<Route path="audit" element={<AuditPage />} />
<Route path="audit/:id" element={<AuditDetailPage />} />
<Route path="jobs" element={<JobsPage />} />
<Route path="uploads" element={<Navigate to="/labeling" replace />} />
<Route path="training" element={<TrainingPage />} />
<Route path="iterate" element={<Navigate to="/training" replace />} />
<Route path="logs" element={<LogsPage />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
</ToastProvider>
);
}

View File

@@ -0,0 +1,378 @@
const API_BASE = import.meta.env.VITE_API_BASE || "";
let _token: string | null = localStorage.getItem("as_access_token");
function authHeaders(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (_token) h.Authorization = `Bearer ${_token}`;
return h;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
cache: "no-store",
headers: { ...authHeaders(), ...(init?.headers as Record<string, string>) },
});
if (res.status === 401) throw new Error("UNAUTHORIZED");
if (!res.ok) throw new Error(await res.text() || res.statusText);
return (await res.json()) as T;
}
async function postJson<T = unknown>(url: string, body?: unknown): Promise<T> {
return fetchJson<T>(url, {
method: "POST",
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
function uploadWithProgress(
url: string,
formData: FormData,
onProgress?: (percent: number) => void
): Promise<{ candidate: DataCandidate; job: JobRecord }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url, true);
if (_token) xhr.setRequestHeader("Authorization", `Bearer ${_token}`);
xhr.upload.onprogress = (evt) => {
if (!onProgress || !evt.lengthComputable) return;
const pct = Math.round((evt.loaded / evt.total) * 100);
onProgress(pct);
};
xhr.onerror = () => reject(new Error("上传失败"));
xhr.onload = () => {
if (xhr.status === 401) return reject(new Error("UNAUTHORIZED"));
if (xhr.status < 200 || xhr.status >= 300) return reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
try {
const parsed = JSON.parse(xhr.responseText || "{}");
resolve(parsed);
} catch (e) {
reject(new Error("上传响应解析失败"));
}
};
xhr.send(formData);
});
}
export type AuthUser = {
id: number;
name: string;
email?: string;
avatar_url?: string;
roles: { code: string; name: string }[];
permissions: string[];
};
export const api = {
base: API_BASE || window.location.origin,
setToken(token: string | null) {
_token = token;
},
authConfig: () => fetchJson<{ feishu_enabled: boolean; dev_auth_enabled: boolean }>(`${API_BASE}/api/v1/auth/config`),
me: () => fetchJson<AuthUser>(`${API_BASE}/api/v1/auth/me`),
devLogin: (name?: string) => postJson<{ access_token: string; user: AuthUser }>(`${API_BASE}/api/v1/auth/dev/login`, { name: name || "开发用户" }),
health: () =>
fetchJson<{
status: string;
workspace?: string;
database?: string;
db_connected?: string;
redis_connected?: string;
}>(`${API_BASE}/api/v1/health`),
pending: () => fetchJson<PendingReport>(`${API_BASE}/api/v1/pending`),
catalog: (refresh = false) =>
fetchJson<CatalogReport>(`${API_BASE}/api/v1/catalog${refresh ? "?refresh=true" : ""}`),
listApprovals: (status?: string) => {
const q = status ? `?status=${encodeURIComponent(status)}` : "";
return fetchJson<{ items: ApprovalRecord[] }>(`${API_BASE}/api/v1/approvals${q}`);
},
getApproval: (id: string) => fetchJson<ApprovalRecord>(`${API_BASE}/api/v1/approvals/${encodeURIComponent(id)}`),
getApprovalPreview: (id: string) =>
fetchJson<AuditPreview>(`${API_BASE}/api/v1/approvals/${encodeURIComponent(id)}/preview`),
listApprovalImages: (id: string, offset = 0, limit = 60) =>
fetchJson<{ total: number; offset: number; limit: number; items: AuditImageItem[] }>(
`${API_BASE}/api/v1/approvals/${encodeURIComponent(id)}/images?offset=${offset}&limit=${limit}`
),
fetchApprovalImageBlob: async (approvalId: string, imageId: string, thumb = true): Promise<string> => {
const q = thumb ? "?thumb=true" : "?thumb=false";
const res = await fetch(
`${API_BASE}/api/v1/approvals/${encodeURIComponent(approvalId)}/images/${encodeURIComponent(imageId)}${q}`,
{ headers: authHeaders(), cache: "no-store" }
);
if (!res.ok) throw new Error(await res.text() || res.statusText);
return URL.createObjectURL(await res.blob());
},
submitApproval: (action: string, params: Record<string, unknown>, note?: string) =>
postJson(`${API_BASE}/api/v1/approvals/submit`, { action, params, note }),
submitBuildBatch: (body: Record<string, unknown>) =>
postJson(`${API_BASE}/api/v1/approvals/submit-build-batch`, body),
approve: (id: string, comment?: string) =>
postJson(`${API_BASE}/api/v1/approvals/${id}/approve`, { comment }),
reject: (id: string, comment?: string) =>
postJson(`${API_BASE}/api/v1/approvals/${id}/reject`, { comment }),
registerBatch: (body: Record<string, unknown>) =>
postJson(`${API_BASE}/api/v1/register-batch`, body),
listJobs: (status?: string) => {
const q = status ? `?status=${encodeURIComponent(status)}` : "";
return fetchJson<{ items: JobRecord[] }>(`${API_BASE}/api/v1/jobs${q}`).catch(() => ({ items: [] }));
},
listTrainingRecords: (opts?: {
project?: string;
kind?: string;
status?: string;
task?: string;
limit?: number;
}) => {
const params = new URLSearchParams();
if (opts?.project) params.set("project", opts.project);
if (opts?.kind) params.set("kind", opts.kind);
if (opts?.status) params.set("status", opts.status);
if (opts?.task) params.set("task", opts.task);
if (opts?.limit) params.set("limit", String(opts.limit));
const q = params.toString();
return fetchJson<{ items: TrainingRecord[]; total: number; summary: TrainingSummary }>(
`${API_BASE}/api/v1/training/records${q ? `?${q}` : ""}`
);
},
getTrainingRecord: (jobId: string) =>
fetchJson<TrainingRecord>(`${API_BASE}/api/v1/training/records/${encodeURIComponent(jobId)}`),
getModelRegistry: (project = "dms", task?: string) => {
const params = new URLSearchParams({ project });
if (task) params.set("task", task);
return fetchJson<ModelRegistry>(`${API_BASE}/api/v1/training/models?${params.toString()}`);
},
createTrainingRecord: (action: string, params: Record<string, unknown>, note?: string) =>
postJson<ApprovalRecord>(`${API_BASE}/api/v1/training/records`, { action, params, note }),
uploadDatasetFile: (
file: File,
project: string,
task?: string,
onProgress?: (percent: number) => void
) => {
const formData = new FormData();
formData.append("project", project);
if (task) formData.append("task", task);
formData.append("file", file);
return uploadWithProgress(`${API_BASE}/api/v1/data/upload/file`, formData, onProgress);
},
listDataCandidates: (limit = 50) =>
fetchJson<{ items: DataCandidate[] }>(`${API_BASE}/api/v1/data/candidates?limit=${limit}`),
getDataCandidate: (candidateId: string) =>
fetchJson<DataCandidate>(`${API_BASE}/api/v1/data/candidates/${encodeURIComponent(candidateId)}`),
inspectUploadPath: (project: "dms" | "lane", sourcePath: string, task?: string) =>
postJson<InspectUploadResponse>(`${API_BASE}/api/v1/data/inspect-upload`, {
project,
task: task || undefined,
source_path: sourcePath,
}),
};
export type BatchRecord = {
project: string;
task?: string;
batch: string;
pack?: string;
stage: string;
location: string;
path?: string;
counts?: { images?: number; labels?: number };
engineer?: string;
format?: string;
next_cli?: string;
};
export type PendingReport = {
workspace: string;
batches: BatchRecord[];
projects?: {
dms?: {
active_packs?: string[];
not_enabled?: string[];
task_defs?: Record<string, { type: string; nc?: number }>;
tasks?: Record<string, { inbox?: unknown[]; sources?: Record<string, unknown[]> }>;
recent_ingest?: { task: string; pack?: string; ts?: string; added?: number }[];
};
lane?: { packs?: Record<string, { path: string; train_lines?: number; enabled?: boolean }> };
};
};
export type CatalogReport = {
_cache?: {
cached?: boolean;
cache_source?: string;
cache_age_sec?: number;
build_source?: string;
};
dms?: Record<string, {
type: string;
nc?: number;
class_counts?: Record<string, number>;
packs?: {
name: string;
enabled: boolean;
train_images?: number;
val_images?: number;
test_images?: number;
class_counts?: Record<string, number>;
label_files?: number;
total_boxes?: number;
sampled?: boolean;
bbox_points?: [number, number][];
path?: string;
role?: string;
frozen?: boolean;
}[];
drop_paths?: { inbox?: string };
}>;
lane?: Record<string, {
path?: string;
drop_path?: string;
train_lines?: number;
val_lines?: number;
test_lines?: number;
enabled?: boolean;
add_template?: string;
quality?: {
analyzed_frames?: number;
lane_count_hist?: Record<string, number>;
length_hist?: { left: number; right: number; count: number }[];
curvature_hist?: { left: number; right: number; count: number }[];
};
}>;
};
export type ApprovalRecord = {
id: string;
action: string;
action_label?: string;
status: string;
params?: Record<string, unknown>;
note?: string | null;
submitted_by?: string;
submitted_at?: string;
review_comment?: string;
result?: { error?: string; ok?: boolean };
};
export type AuditImageItem = {
id: string;
batch: string;
location: string;
split: string;
filename: string;
has_label: boolean;
box_count: number;
missing_label: boolean;
};
export type AuditPreview = {
approval: ApprovalRecord;
scope_label?: string;
task?: string;
pack?: string;
class_names?: Record<number, string>;
batches?: { batch?: string; location?: string; path?: string; exists?: boolean }[];
};
export type JobRecord = {
id: string;
action: string;
status: string;
approval_id?: string;
params?: Record<string, unknown>;
created_at?: string;
started_at?: string;
finished_at?: string;
result?: { error?: string; ok?: boolean; [key: string]: unknown };
};
export type TrainingSummary = {
total: number;
running: number;
queued: number;
succeeded: number;
failed: number;
};
export type TrainingRecord = JobRecord & {
action_label?: string;
project?: string;
kind?: string;
task?: string | null;
track?: string;
weight_path?: string | null;
metrics?: Record<string, unknown>;
error?: string | null;
duration_sec?: number | null;
approval?: ApprovalRecord | null;
};
export type ModelRegistry = {
project: string;
task?: string;
version?: Record<string, unknown>;
tasks?: Record<string, Record<string, unknown>>;
eval_history?: Record<string, unknown>[];
};
export type DataCandidate = {
id: string;
project: string;
task?: string;
status: string;
source_type: string;
original_name?: string;
upload_path: string;
analyzed_source_path?: string;
format_id?: string;
split_counts?: Record<string, number>;
error_message?: string;
upload_size_bytes?: number;
submitted_by_name?: string;
analysis_job_id?: string;
created_at?: string;
updated_at?: string;
};
export type InspectUploadResponse = {
ok: boolean;
normalized: {
format_id: string;
project: string;
task?: string;
source_path: string;
split_counts?: Record<string, number>;
sample_count?: number;
annotation_count?: number;
artifacts?: string[];
warnings?: string[];
extra?: Record<string, unknown>;
};
};

View File

@@ -0,0 +1,120 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api, type AuthUser } from "../api/client";
const TOKEN_KEY = "as_access_token";
type AuthState = {
user: AuthUser | null;
loading: boolean;
token: string | null;
authConfig: { feishu_enabled: boolean; dev_auth_enabled: boolean } | null;
loginFeishu: () => void;
loginDev: (name?: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
hasPermission: (code: string) => boolean;
};
const AuthCtx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [authConfig, setAuthConfig] = useState<{ feishu_enabled: boolean; dev_auth_enabled: boolean } | null>(null);
useEffect(() => {
// 支持后端回调重定向到 /?token=...
const url = new URL(window.location.href);
const tokenFromQuery = url.searchParams.get("token");
if (tokenFromQuery) {
localStorage.setItem(TOKEN_KEY, tokenFromQuery);
api.setToken(tokenFromQuery);
url.searchParams.delete("token");
window.history.replaceState({}, "", url.toString());
setToken(tokenFromQuery);
}
}, []);
const applyToken = useCallback((t: string | null) => {
setToken(t);
if (t) localStorage.setItem(TOKEN_KEY, t);
else localStorage.removeItem(TOKEN_KEY);
api.setToken(t);
}, []);
const refreshUser = useCallback(async () => {
if (!token) {
setUser(null);
return;
}
try {
const me = await api.me();
setUser(me);
} catch {
applyToken(null);
setUser(null);
}
}, [token, applyToken]);
useEffect(() => {
api.authConfig().then(setAuthConfig).catch(() => setAuthConfig({ feishu_enabled: false, dev_auth_enabled: true }));
}, []);
useEffect(() => {
api.setToken(token);
if (token) {
refreshUser().finally(() => setLoading(false));
} else {
setUser(null);
setLoading(false);
}
}, [token, refreshUser]);
const loginFeishu = () => {
window.location.href = "/api/v1/auth/feishu/authorize";
};
const loginDev = async (name?: string) => {
const res = await api.devLogin(name);
applyToken(res.access_token);
setUser(res.user);
};
const logout = () => {
applyToken(null);
setUser(null);
};
const hasPermission = useCallback(
(code: string) => {
if (!user) return false;
const perms = user.permissions || [];
return perms.includes("*") || perms.includes(code);
},
[user]
);
const value = useMemo(
() => ({
user,
loading,
token,
loginFeishu,
loginDev,
logout,
refreshUser,
hasPermission,
authConfig,
}),
[user, loading, token, refreshUser, hasPermission, authConfig]
);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth outside AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import type { ReactNode } from "react";
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <p className="empty-state"></p>;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export function RequirePermission({ code, children }: { code: string; children: ReactNode }) {
const { hasPermission } = useAuth();
if (!hasPermission(code)) {
return <p className="empty-state">访 {code}</p>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,111 @@
import { NavLink, Outlet } from "react-router-dom";
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { useAuth } from "../auth/AuthContext";
const NAV = [
{ to: "/labeling", icon: "◎", label: "送标工作台", badge: "pending" as const, perm: "read:pending" },
{ to: "/catalog", icon: "▤", label: "数据目录", perm: "read:catalog" },
{ to: "/audit", icon: "✓", label: "审核管理", badge: "audit" as const, perm: "read:audit" },
{ to: "/jobs", icon: "◷", label: "任务监控", perm: "read:jobs" },
{ to: "/training", icon: "▶", label: "模型训练", perm: "write:approval_submit" },
{ to: "/logs", icon: "☰", label: "审计日志", perm: "read:audit" },
];
export function Layout() {
const { user, logout, hasPermission } = useAuth();
const [apiOk, setApiOk] = useState<boolean | null>(null);
const [pendingN, setPendingN] = useState(0);
const [auditN, setAuditN] = useState(0);
const refreshMeta = async () => {
try {
await api.health();
setApiOk(true);
if (hasPermission("read:pending")) {
const pending = await api.pending();
const actionable = (pending.batches || []).filter((b) =>
["returned", "raw_pool", "out_for_labeling"].includes(b.stage)
);
setPendingN(actionable.length);
}
if (hasPermission("read:audit")) {
const aud = await api.listApprovals("pending");
setAuditN(aud.items?.length || 0);
}
} catch {
setApiOk(false);
}
};
useEffect(() => {
refreshMeta();
const t = setInterval(refreshMeta, 30000);
return () => clearInterval(t);
}, [user]);
const path = location.pathname.replace(/^\//, "").split("/")[0] || "labeling";
const title = NAV.find((n) => n.to.slice(1) === path)?.label || "送标工作台";
const roleLabel = user?.roles?.map((r) => r.name).join(" · ") || "";
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<div className="brand-logo" aria-hidden="true">
<svg viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="10" fill="url(#g)" />
<path d="M12 28V12h6l4 10 4-10h6v16h-5V18l-4 10h-4l-4-10v10H12z" fill="white" fillOpacity="0.95" />
<defs>
<linearGradient id="g" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#0ea5e9" />
<stop offset="1" stopColor="#06b6d4" />
</linearGradient>
</defs>
</svg>
</div>
<div className="brand-text">
<span className="brand-company">Huaxu Sentinel</span>
<span className="brand-product">HSAP · </span>
</div>
</div>
<nav className="nav">
{NAV.filter((n) => hasPermission(n.perm)).map((n) => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => "nav-item" + (isActive ? " active" : "")}>
<span className="nav-icon">{n.icon}</span> {n.label}
{n.badge === "pending" && pendingN > 0 && <span className="nav-badge">{pendingN}</span>}
{n.badge === "audit" && auditN > 0 && <span className="nav-badge">{auditN}</span>}
</NavLink>
))}
</nav>
<div className="sidebar-footer">
<div className="user-chip">
{user?.avatar_url ? <img src={user.avatar_url} alt="" className="user-avatar" /> : <span className="user-avatar user-avatar-ph">{user?.name?.[0]}</span>}
<div>
<div className="user-name">{user?.name}</div>
<div className="user-role text-dim">{roleLabel}</div>
</div>
</div>
<div className="env-chip">
<span className="env-dot" style={{ background: apiOk ? "var(--success)" : apiOk === false ? "var(--warning)" : undefined }} />
<span>{apiOk ? "算法服务运行中" : apiOk === false ? "算法服务离线" : "服务检测中…"}</span>
</div>
<button type="button" className="btn btn-sm btn-ghost btn-logout" onClick={logout}>退</button>
</div>
</aside>
<div className="main-wrap">
<header className="topbar">
<div className="topbar-title">
<h1>{title}</h1>
</div>
<div className="topbar-actions">
<button type="button" className="btn btn-ghost" onClick={() => refreshMeta()}></button>
</div>
</header>
<main className="content">
<Outlet context={{ refreshMeta }} />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
type Toast = { id: number; msg: string; err?: boolean };
const ToastCtx = createContext<(msg: string, err?: boolean) => void>(() => {});
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((msg: string, err?: boolean) => {
const id = Date.now();
setToasts((t) => [...t, { id, msg, err }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4000);
}, []);
return (
<ToastCtx.Provider value={toast}>
{children}
<div className="toast-container">
{toasts.map((t) => (
<div key={t.id} className={"toast" + (t.err ? " toast-err" : "")}>
{t.msg}
</div>
))}
</div>
</ToastCtx.Provider>
);
}
export function useToast() {
return useContext(ToastCtx);
}

View File

@@ -0,0 +1,2 @@
/** 车道线数据可视化(暂时关闭;恢复时改为 import.meta.env.VITE_LANE_DATA_VIZ === "1" */
export const LANE_DATA_VIZ_ENABLED = false;

View File

@@ -0,0 +1,21 @@
export const LabelingStates: Record<
string,
{ label: string; badge: string; hint: string }
> = {
raw_pool: { label: "原图池", badge: "badge-pending", hint: "仅有图像,待送标或等待标注回传" },
out_for_labeling: { label: "送标中", badge: "badge-training", hint: "已导出清单,等待标注方回传" },
returned: { label: "回传待入库", badge: "badge-staged", hint: "数据已落盘,可执行 build / add" },
ingested: { label: "已入库", badge: "badge-evaluated", hint: "已完成 ingest 或建包" },
};
export function forStage(stage: string) {
return LabelingStates[stage] ?? { label: stage, badge: "badge-idle", hint: "" };
}
export const DropPaths = {
dms_inbox: (ws: string, task: string, batch: string) =>
`${ws}/datasets/dms/inbox/${task}/${batch}/`,
dms_sources: (ws: string, pack: string, task: string, batch: string) =>
`${ws}/datasets/dms/packs/${pack}/${task}/sources/${batch}/`,
lane_add: (ws: string) => `${ws}/datasets/lane/ # archive 含 train_val_gt.txt`,
};

10
platform/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/main.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,367 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useOutletContext, useParams } from "react-router-dom";
import { api, type ApprovalRecord, type AuditImageItem, type AuditPreview } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { useToast } from "../components/Toast";
const STATUS: Record<string, { label: string; badge: string }> = {
pending: { label: "待审核", badge: "badge-pending" },
approved: { label: "已通过", badge: "badge-staged" },
rejected: { label: "已驳回", badge: "badge-idle" },
executed: { label: "已执行", badge: "badge-evaluated" },
failed: { label: "执行失败", badge: "badge-pending" },
running: { label: "执行中", badge: "badge-training" },
};
type Ctx = { refreshMeta: () => void };
function AuthImage({
approvalId,
imageId,
thumb = true,
alt,
className,
onClick,
}: {
approvalId: string;
imageId: string;
thumb?: boolean;
alt: string;
className?: string;
onClick?: () => void;
}) {
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let revoked = false;
let objectUrl: string | null = null;
setFailed(false);
setSrc(null);
api.fetchApprovalImageBlob(approvalId, imageId, thumb).then(
(url) => {
if (revoked) {
URL.revokeObjectURL(url);
return;
}
objectUrl = url;
setSrc(url);
},
() => {
if (!revoked) setFailed(true);
}
);
return () => {
revoked = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [approvalId, imageId, thumb]);
if (failed) {
return <div className={`audit-img-placeholder ${className || ""}`}></div>;
}
if (!src) {
return <div className={`audit-img-placeholder audit-img-loading ${className || ""}`}></div>;
}
return <img src={src} alt={alt} className={className} onClick={onClick} loading="lazy" />;
}
export function AuditDetailPage() {
const { id } = useParams<{ id: string }>();
const { refreshMeta } = useOutletContext<Ctx>();
const toast = useToast();
const { hasPermission } = useAuth();
const canReview = hasPermission("write:approval_review");
const [preview, setPreview] = useState<AuditPreview | null>(null);
const [images, setImages] = useState<AuditImageItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [lightbox, setLightbox] = useState<AuditImageItem | null>(null);
const [batchFilter, setBatchFilter] = useState("");
const sentinelRef = useRef<HTMLDivElement>(null);
const approvalId = id || "";
const loadPreview = useCallback(async () => {
if (!approvalId) return;
const data = await api.getApprovalPreview(approvalId);
setPreview(data);
}, [approvalId]);
const loadImages = useCallback(
async (append = false) => {
if (!approvalId) return;
if (append) setLoadingMore(true);
else setLoading(true);
try {
const offset = append ? images.length : 0;
const data = await api.listApprovalImages(approvalId, offset, 60);
setTotal(data.total);
setImages((prev) => (append ? [...prev, ...data.items] : data.items));
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[approvalId, images.length]
);
useEffect(() => {
loadPreview().catch((e) => toast(String(e), true));
loadImages(false).catch((e) => toast(String(e), true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [approvalId]);
useEffect(() => {
const el = sentinelRef.current;
if (!el || loading || loadingMore || images.length >= total) return;
const obs = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && images.length < total) {
loadImages(true).catch((e) => toast(String(e), true));
}
},
{ rootMargin: "200px" }
);
obs.observe(el);
return () => obs.disconnect();
}, [images.length, total, loading, loadingMore, loadImages, toast]);
const rec: ApprovalRecord | undefined = preview?.approval;
const st = rec ? STATUS[rec.status] || { label: rec.status, badge: "badge-idle" } : null;
const batches = [...new Set(images.map((i) => i.batch))].sort();
const shown = batchFilter ? images.filter((i) => i.batch === batchFilter) : images;
return (
<>
<div className="audit-detail-head panel panel-compact">
<div className="panel-body">
<div className="audit-detail-nav">
<Link to="/audit" className="btn btn-sm btn-ghost"> </Link>
{rec && st && (
<span className={`badge ${st.badge}`}>{st.label}</span>
)}
</div>
{rec && (
<>
<h2 className="audit-detail-title">{rec.action_label || rec.action}</h2>
<p className="text-dim audit-detail-meta">
{rec.id} · {rec.submitted_by || "—"} · {rec.submitted_at?.slice(0, 19)}
</p>
{preview?.scope_label && (
<p className="audit-scope-label">{preview.scope_label}</p>
)}
{preview?.batches?.length ? (
<div className="audit-batch-tags">
{preview.batches.map((b) => (
<span key={`${b.location}:${b.batch}`} className={`audit-batch-tag ${b.exists ? "" : "missing"}`}>
{b.location}/{b.batch}
{!b.exists && " (目录不存在)"}
</span>
))}
</div>
) : null}
{rec.status === "pending" && canReview && (
<div className="audit-detail-actions">
<button
type="button"
className="btn btn-primary"
onClick={async () => {
try {
await api.approve(rec.id, "批准");
toast("已批准");
refreshMeta();
loadPreview();
} catch (e) {
toast(String(e), true);
}
}}
>
</button>
<button
type="button"
className="btn btn-ghost"
onClick={async () => {
const reason = prompt("驳回原因");
if (reason === null) return;
await api.reject(rec.id, reason);
toast("已驳回");
refreshMeta();
loadPreview();
}}
>
</button>
</div>
)}
</>
)}
</div>
</div>
<div className="panel">
<div className="panel-header">
<h2> · GT </h2>
<div className="audit-gallery-toolbar">
<span className="text-dim">
{images.length} / {total}
</span>
{batches.length > 1 && (
<select
className="audit-batch-select"
value={batchFilter}
onChange={(e) => setBatchFilter(e.target.value)}
>
<option value=""></option>
{batches.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
)}
</div>
</div>
<div className="panel-body">
{loading && !images.length ? (
<p className="empty-state"></p>
) : total === 0 ? (
<p className="empty-state"></p>
) : (
<>
<div className="audit-gallery">
{shown.map((item) => (
<button
key={item.id}
type="button"
className={`audit-gallery-item ${item.missing_label ? "no-label" : ""}`}
onClick={() => setLightbox(item)}
title={item.filename}
>
<AuthImage
approvalId={approvalId}
imageId={item.id}
thumb
alt={item.filename}
className="audit-gallery-img"
/>
<div className="audit-gallery-cap">
<span className="mono">{item.filename}</span>
<span>
{item.batch} · {item.split}
{item.box_count > 0 ? ` · ${item.box_count}` : item.missing_label ? " · 无标注" : ""}
</span>
</div>
</button>
))}
</div>
{loadingMore && <p className="empty-state"></p>}
<div ref={sentinelRef} className="audit-sentinel" />
</>
)}
</div>
</div>
{lightbox && (
<div className="audit-lightbox" onClick={() => setLightbox(null)} role="presentation">
<div className="audit-lightbox-inner" onClick={(e) => e.stopPropagation()}>
<button type="button" className="audit-lightbox-close btn btn-sm btn-ghost" onClick={() => setLightbox(null)}>
</button>
<AuthImage
approvalId={approvalId}
imageId={lightbox.id}
thumb={false}
alt={lightbox.filename}
className="audit-lightbox-img"
/>
<div className="audit-lightbox-meta">
<strong>{lightbox.filename}</strong>
<span>{lightbox.batch} · {lightbox.split} · {lightbox.box_count} </span>
</div>
</div>
</div>
)}
</>
);
}
export function AuditPage() {
const navigate = useNavigate();
const { refreshMeta } = useOutletContext<Ctx>();
const toast = useToast();
const { hasPermission } = useAuth();
const canReview = hasPermission("write:approval_review");
const [filter, setFilter] = useState("pending");
const [items, setItems] = useState<ApprovalRecord[]>([]);
const load = async () => {
const data = await api.listApprovals(filter || undefined);
setItems(data.items || []);
};
useEffect(() => { load(); }, [filter]);
return (
<div className="panel">
<div className="panel-header">
<h2></h2>
<div className="audit-filters">
{["pending", "executed", "rejected", "failed", ""].map((f) => (
<button key={f || "all"} type="button" className={`btn btn-sm ${filter === f ? "btn-primary" : "btn-ghost"}`} onClick={() => setFilter(f)}>
{f === "" ? "全部" : STATUS[f]?.label || f}
</button>
))}
</div>
</div>
<div className="panel-body table-wrap">
<table className="data-table audit-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{items.map((r) => {
const st = STATUS[r.status] || { label: r.status, badge: "badge-idle" };
return (
<tr
key={r.id}
className="audit-row-clickable"
onClick={() => navigate(`/audit/${r.id}`)}
>
<td className="mono text-sm">{r.id}</td>
<td>{r.action_label || r.action}</td>
<td><span className={`badge ${st.badge}`}>{st.label}</span></td>
<td>{r.submitted_by || "—"}</td>
<td className="text-sm">{r.submitted_at?.slice(0, 19)}</td>
<td className="text-sm"><pre className="params-pre">{JSON.stringify(r.params || {})}</pre></td>
<td onClick={(e) => e.stopPropagation()}>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => navigate(`/audit/${r.id}`)}>
</button>
{r.status === "pending" && canReview ? (
<>
<button type="button" className="btn btn-sm btn-primary" onClick={async () => {
try { await api.approve(r.id, "批准"); toast("已批准"); load(); refreshMeta(); }
catch (e) { toast(String(e), true); }
}}></button>
<button type="button" className="btn btn-sm btn-ghost" onClick={async () => {
await api.reject(r.id, prompt("驳回原因") || "");
load(); refreshMeta();
}}></button>
</>
) : r.status === "pending" ? (
<span className="text-dim"></span>
) : (
r.review_comment || r.result?.error || ""
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { api } from "../api/client";
export function AuthCallbackPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const token = params.get("token");
if (token) {
localStorage.setItem("as_access_token", token);
api.setToken(token);
navigate("/labeling", { replace: true });
window.location.reload();
} else {
navigate("/login", { replace: true });
}
}, [params, navigate]);
return <p className="empty-state"></p>;
}

View File

@@ -0,0 +1,651 @@
import { useEffect, useMemo, useState } from "react";
import { api, type CatalogReport, type InspectUploadResponse } from "../api/client";
import { LANE_DATA_VIZ_ENABLED } from "../config/featureFlags";
type CatalogVersion = {
name: string;
enabled: boolean;
train_images?: number;
val_images?: number;
test_images?: number;
class_counts?: Record<string, number>;
path?: string;
role?: string;
frozen?: boolean;
label_files?: number;
total_boxes?: number;
sampled?: boolean;
bbox_points?: [number, number][];
lane_quality?: {
analyzed_frames?: number;
lane_count_hist?: Record<string, number>;
length_hist?: { left: number; right: number; count: number }[];
curvature_hist?: { left: number; right: number; count: number }[];
};
};
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg - 90) * (Math.PI / 180);
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
}
function radarPoints(values: number[], cx = 110, cy = 110, radius = 76): string {
if (!values.length) return "";
return values
.map((v, i) => {
const angle = (-90 + (360 / values.length) * i) * (Math.PI / 180);
const r = Math.max(0, Math.min(1, v)) * radius;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
return `${x},${y}`;
})
.join(" ");
}
export function CatalogPage() {
const [cat, setCat] = useState<CatalogReport | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [cacheHint, setCacheHint] = useState("");
const [project, setProject] = useState<"dms" | "lane">("dms");
const [selectedTask, setSelectedTask] = useState<string>("");
const [selectedVersion, setSelectedVersion] = useState<string>("");
const [inspectProject, setInspectProject] = useState<"dms" | "lane">("dms");
const [inspectTask, setInspectTask] = useState<string>("dam");
const [inspectPath, setInspectPath] = useState<string>("");
const [inspecting, setInspecting] = useState(false);
const [inspectResult, setInspectResult] = useState<InspectUploadResponse["normalized"] | null>(null);
const [inspectError, setInspectError] = useState<string>("");
const load = async (refresh = false) => {
setLoading(true);
setLoadError("");
try {
const data = await api.catalog(refresh);
setCat(data);
const meta = data._cache;
if (meta?.cached) {
const src = meta.build_source === "reports" ? "报表缓存" : "扫描缓存";
setCacheHint(`${src} · ${Math.round(meta.cache_age_sec || 0)}s 前更新`);
} else if (refresh) {
setCacheHint("已强制刷新");
} else {
setCacheHint("已重新扫描");
}
} catch (e) {
setLoadError(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
};
const inspectSource = async () => {
const sourcePath = inspectPath.trim();
if (!sourcePath) {
setInspectError("请先输入目录路径");
return;
}
setInspecting(true);
setInspectError("");
try {
const res = await api.inspectUploadPath(
inspectProject,
sourcePath,
inspectProject === "dms" ? inspectTask : undefined
);
setInspectResult(res.normalized);
} catch (e) {
setInspectResult(null);
setInspectError(e instanceof Error ? e.message : "目录分析失败");
} finally {
setInspecting(false);
}
};
useEffect(() => { load(); }, []);
useEffect(() => {
if (!cat) return;
const dmsTasks = Object.keys(cat.dms || {});
const lanePacks = Object.keys(cat.lane || {});
if (project === "dms") {
const first = selectedTask || dmsTasks[0] || "";
setSelectedTask(first);
const firstPack = (cat.dms?.[first]?.packs?.[0]?.name) || "";
setSelectedVersion(firstPack);
} else {
const first = selectedTask || lanePacks[0] || "";
setSelectedTask(first);
setSelectedVersion(first);
}
}, [cat, project]);
const dmsVersions = useMemo<CatalogVersion[]>(() => {
if (!cat || project !== "dms" || !selectedTask) return [];
return cat.dms?.[selectedTask]?.packs || [];
}, [cat, project, selectedTask]);
const laneVersions = useMemo<CatalogVersion[]>(() => {
if (!cat || project !== "lane") return [];
return Object.entries(cat.lane || {}).map(([name, info]) => ({
name,
enabled: Boolean(info.enabled),
train_images: info.train_lines || 0,
val_images: info.val_lines || 0,
test_images: info.test_lines || 0,
class_counts: {},
path: info.path,
lane_quality: info.quality,
}));
}, [cat, project]);
const versions: CatalogVersion[] = project === "dms" ? dmsVersions : laneVersions;
const current = versions.find((v) => v.name === selectedVersion) || versions[0];
const classPairs = Object.entries((current?.class_counts || {}) as Record<string, number>)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const classMax = classPairs[0]?.[1] || 1;
const classTotal = classPairs.reduce((acc, [, v]) => acc + v, 0);
const versionFeatures = useMemo(() => {
if (project !== "dms") return [] as Array<{
name: string;
samples: number;
boxes: number;
density: number;
avg_area: number;
small_ratio: number;
bbox_points: [number, number][];
}>;
return dmsVersions.map((v) => {
const samples = (v.train_images || 0) + (v.val_images || 0) + (v.test_images || 0);
const boxes = v.total_boxes || 0;
const points = (v.bbox_points || []).filter((p) => p.length >= 2) as [number, number][];
const areas = points.map(([w, h]) => w * h);
const avg_area = areas.length ? areas.reduce((a, b) => a + b, 0) / areas.length : 0;
const small_ratio = areas.length ? areas.filter((a) => a < 0.02).length / areas.length : 0;
return {
name: v.name,
samples,
boxes,
density: samples > 0 ? boxes / samples : 0,
avg_area,
small_ratio,
bbox_points: points,
};
});
}, [project, dmsVersions]);
const featureMax = useMemo(() => {
const samples = Math.max(1, ...versionFeatures.map((v) => v.samples));
const density = Math.max(1, ...versionFeatures.map((v) => v.density));
const boxes = Math.max(1, ...versionFeatures.map((v) => v.boxes));
const avgArea = Math.max(1e-6, ...versionFeatures.map((v) => v.avg_area));
const smallRatio = Math.max(1e-6, ...versionFeatures.map((v) => v.small_ratio));
return { samples, density, boxes, avgArea, smallRatio };
}, [versionFeatures]);
const colorPalette = ["#22d3ee", "#a78bfa", "#34d399", "#f59e0b", "#60a5fa", "#f472b6", "#fb7185", "#94a3b8"];
const currentFeature = versionFeatures.find((v) => v.name === current?.name);
const whBins = useMemo(() => {
const bins = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => 0));
const points = currentFeature?.bbox_points || [];
for (const [w, h] of points) {
const xi = Math.min(9, Math.max(0, Math.floor(w * 10)));
const yi = Math.min(9, Math.max(0, Math.floor(h * 10)));
bins[9 - yi][xi] += 1;
}
let max = 0;
for (const row of bins) for (const v of row) max = Math.max(max, v);
return { bins, max };
}, [currentFeature]);
const radarAxes = ["样本量", "标签框", "框密度", "平均面积", "小目标占比"];
const radarMetrics = useMemo(() => {
return versionFeatures.slice(0, 4).map((v) => ({
name: v.name,
values: [
v.samples / featureMax.samples,
v.boxes / featureMax.boxes,
v.density / featureMax.density,
v.avg_area / featureMax.avgArea,
v.small_ratio / featureMax.smallRatio,
],
}));
}, [versionFeatures, featureMax]);
const laneCountPairs = useMemo(() => {
const hist = current?.lane_quality?.lane_count_hist || {};
const entries = Object.entries(hist);
entries.sort((a, b) => {
const av = a[0] === "8+" ? 999 : Number(a[0]);
const bv = b[0] === "8+" ? 999 : Number(b[0]);
return av - bv;
});
return entries;
}, [current]);
const laneLengthHist = current?.lane_quality?.length_hist || [];
const laneCurvHist = current?.lane_quality?.curvature_hist || [];
const laneLengthMax = Math.max(1, ...laneLengthHist.map((x) => x.count));
const laneCurvMax = Math.max(1, ...laneCurvHist.map((x) => x.count));
const kpis = useMemo(() => {
const allDms = Object.values(cat?.dms || {}) as NonNullable<CatalogReport["dms"]>[string][];
const allDmsPacks = allDms.flatMap((x) => x.packs || []);
const allLane = Object.values(cat?.lane || {}) as NonNullable<CatalogReport["lane"]>[string][];
const totalVersions = allDmsPacks.length + allLane.length;
const enabledVersions = allDmsPacks.filter((x) => x.enabled).length + allLane.filter((x) => x.enabled).length;
const totalSamples = allDmsPacks.reduce((acc, x) => acc + (x.train_images || 0) + (x.val_images || 0) + (x.test_images || 0), 0)
+ allLane.reduce((acc, x) => acc + (x.train_lines || 0) + (x.val_lines || 0) + (x.test_lines || 0), 0);
const totalBoxes = allDmsPacks.reduce((acc, x) => acc + (x.total_boxes || 0), 0);
return { totalVersions, enabledVersions, totalSamples, totalBoxes };
}, [cat]);
const inspectSplits = useMemo(() => {
const raw = inspectResult?.split_counts || {};
return [
["train", raw.train || 0],
["val", raw.val || 0],
["test", raw.test || 0],
] as Array<[string, number]>;
}, [inspectResult]);
const inspectTotal = inspectSplits.reduce((acc, [, v]) => acc + v, 0);
const fmt = (n: number) => n.toLocaleString("zh-CN");
if (loading && !cat) return <p className="empty-state"> catalog </p>;
if (loadError && !cat) return <p className="empty-state">{loadError}</p>;
if (!cat) return <p className="empty-state"></p>;
return (
<>
{cacheHint && <p className="audit-note" style={{ marginBottom: 8 }}>{cacheHint}</p>}
<div className="kpi-strip">
<div className="kpi"><span className="kpi-val">{fmt(kpis.totalVersions)}</span><span className="kpi-lbl"></span></div>
<div className="kpi"><span className="kpi-val">{fmt(kpis.enabledVersions)}</span><span className="kpi-lbl"></span></div>
<div className="kpi"><span className="kpi-val">{fmt(kpis.totalSamples)}</span><span className="kpi-lbl"></span></div>
<div className="kpi"><span className="kpi-val">{fmt(kpis.totalBoxes)}</span><span className="kpi-lbl">DMS </span></div>
</div>
<div className="panel">
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
<div className="crud-form">
<label className="field">
<span>Project</span>
<select value={inspectProject} onChange={(e) => setInspectProject(e.target.value as "dms" | "lane")}>
<option value="dms">dms</option>
<option value="lane">lane</option>
</select>
</label>
{inspectProject === "dms" && (
<label className="field">
<span>Task</span>
<input value={inspectTask} onChange={(e) => setInspectTask(e.target.value)} placeholder="如 dam" />
</label>
)}
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span></span>
<input
value={inspectPath}
onChange={(e) => setInspectPath(e.target.value)}
placeholder="例如 /data/workspace/DMS/inbox/dam/batch_xxx"
/>
</label>
<div className="crud-actions" style={{ gridColumn: "1 / -1" }}>
<button type="button" className="btn btn-primary" onClick={() => inspectSource()} disabled={inspecting}>
{inspecting ? "分析中..." : "分析目录"}
</button>
</div>
</div>
{inspectError && <p className="empty-state">{inspectError}</p>}
{inspectResult && (
<div className="feature-grid" style={{ marginTop: 12 }}>
<div className="feature-card">
<h4>Split </h4>
<div className="donut-wrap">
<svg viewBox="0 0 200 200" className="donut-svg" role="img" aria-label="目录 split 分布">
<circle cx="100" cy="100" r="70" fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="28" />
{(() => {
let angle = 0;
return inspectSplits.map(([name, value], idx) => {
const sweep = inspectTotal > 0 ? (value / inspectTotal) * 360 : 0;
const d = describeArc(100, 100, 70, angle, angle + sweep);
angle += sweep;
return <path key={name} d={d} fill="none" stroke={colorPalette[idx % colorPalette.length]} strokeWidth="28" />;
});
})()}
<text x="100" y="96" textAnchor="middle" className="donut-center-title">{inspectResult.format_id}</text>
<text x="100" y="116" textAnchor="middle" className="donut-center-sub">{fmt(inspectTotal)} samples</text>
</svg>
<div className="donut-legend">
{inspectSplits.map(([name, value], idx) => (
<div key={`inspect-${name}`} className="legend-item">
<i style={{ background: colorPalette[idx % colorPalette.length] }} />
<span>{name}</span>
<strong>{fmt(value)}</strong>
</div>
))}
</div>
</div>
</div>
<div className="feature-card">
<h4></h4>
<p className="audit-note">source: <code>{inspectResult.source_path}</code></p>
<p className="audit-note">annotations: {fmt(inspectResult.annotation_count || 0)}</p>
<p className="audit-note">artifacts: {(inspectResult.artifacts || []).join(", ") || "—"}</p>
{(inspectResult.warnings || []).length > 0 ? (
<ul className="audit-note">
{(inspectResult.warnings || []).map((w, idx) => <li key={`warn-${idx}`}>{w}</li>)}
</ul>
) : (
<p className="audit-note">warnings: none</p>
)}
</div>
</div>
)}
</div>
</div>
<div className="grid-2-equal">
<div className="panel">
<div className="panel-header">
<h2></h2>
<div className="audit-filters">
<button type="button" className={`btn btn-sm ${project === "dms" ? "btn-primary" : "btn-ghost"}`} onClick={() => setProject("dms")}>DMS</button>
<button type="button" className={`btn btn-sm ${project === "lane" ? "btn-primary" : "btn-ghost"}`} onClick={() => setProject("lane")}>Lane</button>
</div>
</div>
<div className="panel-body">
<div className="catalog-toolbar">
<label className="field">
<span>{project === "dms" ? "任务" : "包"}</span>
<select
value={selectedTask}
onChange={(e) => {
const value = e.target.value;
setSelectedTask(value);
const firstPack = project === "dms" ? (cat.dms?.[value]?.packs?.[0]?.name || "") : value;
setSelectedVersion(firstPack);
}}
>
{(project === "dms" ? Object.keys(cat.dms || {}) : Object.keys(cat.lane || {})).map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
</div>
<div className="version-table">
<table className="data-table compact">
<thead><tr><th></th><th></th><th>Train</th><th>Val</th><th>Test</th><th></th></tr></thead>
<tbody>
{versions.map((v) => {
const total = (v.train_images || 0) + (v.val_images || 0) + (v.test_images || 0);
return (
<tr
key={v.name}
className={current?.name === v.name ? "row-active" : ""}
onClick={() => setSelectedVersion(v.name)}
style={{ cursor: "pointer" }}
>
<td><strong>{v.name}</strong></td>
<td><span className={`badge ${v.enabled ? "badge-promoted" : "badge-idle"}`}>{v.enabled ? "启用中" : "未启用"}</span></td>
<td>{fmt(v.train_images || 0)}</td>
<td>{fmt(v.val_images || 0)}</td>
<td>{fmt(v.test_images || 0)}</td>
<td>{fmt(total)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
<div className="panel">
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
{current ? (
<div>
<p className="catalog-intro">
<strong>{current.name}</strong> ·
Train {fmt(current.train_images || 0)} / Val {fmt(current.val_images || 0)} / Test {fmt(current.test_images || 0)}
</p>
{classPairs.length > 0 ? (
<div className="dist-list">
{classPairs.map(([name, value]) => (
<div key={name} className="dist-row">
<div className="dist-meta"><span>{name}</span><strong>{value}</strong></div>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${Math.max(6, (value / classMax) * 100)}%` }} /></div>
</div>
))}
</div>
) : (
<p className="empty-state">Lane </p>
)}
</div>
) : <p className="empty-state"></p>}
</div>
</div>
</div>
<div className="panel">
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
{project === "dms" ? (
<>
<div className="feature-grid">
<div className="feature-card">
<h4></h4>
{classPairs.length > 0 ? (
<div className="donut-wrap">
<svg viewBox="0 0 200 200" className="donut-svg" role="img" aria-label="类别结构环形图">
<circle cx="100" cy="100" r="70" fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="28" />
{(() => {
let angle = 0;
return classPairs.map(([name, value], idx) => {
const sweep = classTotal > 0 ? (value / classTotal) * 360 : 0;
const d = describeArc(100, 100, 70, angle, angle + sweep);
angle += sweep;
return <path key={name} d={d} fill="none" stroke={colorPalette[idx % colorPalette.length]} strokeWidth="28" strokeLinecap="butt" />;
});
})()}
<text x="100" y="96" textAnchor="middle" className="donut-center-title">{current?.name || "-"}</text>
<text x="100" y="116" textAnchor="middle" className="donut-center-sub">{fmt(classTotal)} boxes</text>
</svg>
<div className="donut-legend">
{classPairs.slice(0, 6).map(([name, value], idx) => (
<div key={`legend-${name}`} className="legend-item">
<i style={{ background: colorPalette[idx % colorPalette.length] }} />
<span>{name}</span>
<strong>{((value / Math.max(1, classTotal)) * 100).toFixed(1)}%</strong>
</div>
))}
</div>
</div>
) : <p className="empty-state"></p>}
</div>
<div className="feature-card">
<h4></h4>
<svg viewBox="0 0 320 220" className="scatter-svg" role="img" aria-label="目标宽高散点密度分布">
<rect x="40" y="20" width="250" height="170" fill="rgba(148,163,184,0.05)" stroke="rgba(148,163,184,0.25)" />
{whBins.bins.map((row, yi) =>
row.map((v, xi) => {
if (!v) return null;
const alpha = Math.min(0.85, v / Math.max(1, whBins.max));
return (
<rect
key={`bin-${xi}-${yi}`}
x={40 + xi * 25}
y={20 + yi * 17}
width={25}
height={17}
fill={`rgba(34,211,238,${alpha.toFixed(3)})`}
/>
);
})
)}
{(currentFeature?.bbox_points || []).slice(0, 400).map(([w, h], idx) => (
<circle
key={`wh-${idx}`}
cx={40 + w * 250}
cy={190 - h * 170}
r={1.6}
fill="rgba(255,255,255,0.35)"
/>
))}
<text x="294" y="205" textAnchor="end" className="scatter-axis"> w</text>
<text x="12" y="20" className="scatter-axis"> h</text>
</svg>
</div>
<div className="feature-card">
<h4></h4>
<div className="radar-wrap">
<svg viewBox="0 0 220 220" className="radar-svg" role="img" aria-label="版本多指标雷达图">
{[0.25, 0.5, 0.75, 1].map((lv) => (
<polygon
key={`grid-${lv}`}
points={radarPoints([lv, lv, lv, lv, lv], 110, 110, 76)}
fill="none"
stroke="rgba(148,163,184,0.2)"
strokeWidth="1"
/>
))}
{radarAxes.map((label, i) => {
const pt = radarPoints([1, 1, 1, 1, 1], 110, 110, 90).split(" ")[i];
const [x, y] = pt.split(",");
return <text key={label} x={Number(x)} y={Number(y)} className="radar-axis">{label}</text>;
})}
{radarMetrics.map((m, idx) => (
<polygon
key={`radar-${m.name}`}
points={radarPoints(m.values, 110, 110, 76)}
fill={colorPalette[idx % colorPalette.length]}
fillOpacity="0.16"
stroke={colorPalette[idx % colorPalette.length]}
strokeWidth="2"
/>
))}
</svg>
<div className="donut-legend">
{radarMetrics.map((m, idx) => (
<div key={`radar-leg-${m.name}`} className="legend-item">
<i style={{ background: colorPalette[idx % colorPalette.length] }} />
<span>{m.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div className="version-table" style={{ marginTop: 14 }}>
<table className="data-table compact">
<thead><tr><th></th><th>Train</th><th>Val</th><th>Test</th><th></th><th>/</th><th></th></tr></thead>
<tbody>
{versionFeatures.map((v) => {
const raw = dmsVersions.find((x) => x.name === v.name);
return (
<tr key={v.name}>
<td>{v.name}</td>
<td>{fmt(raw?.train_images || 0)}</td>
<td>{fmt(raw?.val_images || 0)}</td>
<td>{fmt(raw?.test_images || 0)}</td>
<td>{fmt(v.boxes)}</td>
<td>{v.density.toFixed(2)}</td>
<td>{(v.avg_area * 100).toFixed(2)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : LANE_DATA_VIZ_ENABLED ? (
<>
<div className="feature-grid">
<div className="feature-card">
<h4>线</h4>
<div className="donut-wrap">
<svg viewBox="0 0 200 200" className="donut-svg" role="img" aria-label="每帧车道线数量分布">
<circle cx="100" cy="100" r="70" fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="28" />
{(() => {
const total = laneCountPairs.reduce((a, [, c]) => a + c, 0);
let angle = 0;
return laneCountPairs.map(([bucket, cnt], idx) => {
const sweep = total > 0 ? (cnt / total) * 360 : 0;
const d = describeArc(100, 100, 70, angle, angle + sweep);
angle += sweep;
return <path key={bucket} d={d} fill="none" stroke={colorPalette[idx % colorPalette.length]} strokeWidth="28" />;
});
})()}
<text x="100" y="100" textAnchor="middle" className="donut-center-title">线</text>
</svg>
<div className="donut-legend">
{laneCountPairs.map(([bucket, cnt], idx) => (
<div key={`lc-${bucket}`} className="legend-item">
<i style={{ background: colorPalette[idx % colorPalette.length] }} />
<span>{bucket} </span>
<strong>{fmt(cnt)}</strong>
</div>
))}
</div>
</div>
</div>
<div className="feature-card">
<h4>线线</h4>
<svg viewBox="0 0 320 200" className="scatter-svg" role="img" aria-label="线长度分布曲线">
<line x1="36" y1="170" x2="300" y2="170" stroke="rgba(148,163,184,0.35)" />
<line x1="36" y1="20" x2="36" y2="170" stroke="rgba(148,163,184,0.35)" />
{laneLengthHist.length > 0 && (
<polyline
fill="none"
stroke="#22d3ee"
strokeWidth="2"
points={laneLengthHist.map((b, i) => {
const x = 36 + (i / Math.max(1, laneLengthHist.length - 1)) * 264;
const y = 170 - (b.count / laneLengthMax) * 140;
return `${x},${y}`;
}).join(" ")}
/>
)}
<text x="300" y="188" textAnchor="end" className="scatter-axis"></text>
<text x="8" y="20" className="scatter-axis"></text>
</svg>
</div>
<div className="feature-card">
<h4>线</h4>
<svg viewBox="0 0 320 200" className="scatter-svg" role="img" aria-label="曲率分布曲线">
<line x1="36" y1="170" x2="300" y2="170" stroke="rgba(148,163,184,0.35)" />
<line x1="36" y1="20" x2="36" y2="170" stroke="rgba(148,163,184,0.35)" />
{laneCurvHist.length > 0 && (
<polyline
fill="none"
stroke="#a78bfa"
strokeWidth="2"
points={laneCurvHist.map((b, i) => {
const x = 36 + (i / Math.max(1, laneCurvHist.length - 1)) * 264;
const y = 170 - (b.count / laneCurvMax) * 140;
return `${x},${y}`;
}).join(" ")}
/>
)}
<text x="300" y="188" textAnchor="end" className="scatter-axis"></text>
<text x="8" y="20" className="scatter-axis"></text>
</svg>
</div>
</div>
</>
) : (
<p className="empty-state">线/</p>
)}
<div className="crud-actions" style={{ marginTop: 14 }}>
<button type="button" className="btn btn-ghost" onClick={() => load(true)} disabled={loading}>
{loading ? "刷新中..." : "刷新数据"}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setSelectedVersion(versions[0]?.name || "")}></button>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { api, type CatalogReport, type JobRecord, type PendingReport } from "../api/client";
import { useToast } from "../components/Toast";
import { LANE_DATA_VIZ_ENABLED } from "../config/featureFlags";
type Ctx = { refreshMeta: () => void };
type IterAction = "train_dms" | "train_lane" | "eval_dms" | "eval_lane" | "visualize_dms" | "visualize_lane";
const ITERATE_ACTIONS = new Set<IterAction>([
"train_dms",
"train_lane",
"eval_dms",
"eval_lane",
"visualize_dms",
"visualize_lane",
]);
const ACTION_META: Record<IterAction, { label: string; project: "DMS" | "Lane"; kind: "训练" | "评估" | "可视化" }> = {
train_dms: { label: "DMS 训练", project: "DMS", kind: "训练" },
train_lane: { label: "Lane 训练", project: "Lane", kind: "训练" },
eval_dms: { label: "DMS 评估", project: "DMS", kind: "评估" },
eval_lane: { label: "Lane 评估", project: "Lane", kind: "评估" },
visualize_dms: { label: "DMS 可视化", project: "DMS", kind: "可视化" },
visualize_lane: { label: "Lane 可视化", project: "Lane", kind: "可视化" },
};
function statusBadgeClass(status: string): string {
if (status === "succeeded") return "badge-promoted";
if (status === "running") return "badge-training";
if (status === "failed") return "badge-pending";
return "badge-idle";
}
function fmtTime(ts?: string): string {
if (!ts) return "—";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
return d.toLocaleString("zh-CN", { hour12: false });
}
function extractWeight(job: JobRecord): string | null {
const res = (job.result || {}) as Record<string, unknown>;
const params = (job.params || {}) as Record<string, unknown>;
const candidates = [res.best_weights, res.candidate, res.model_path, res.weights, params.weights, params.model_path];
for (const v of candidates) {
if (typeof v === "string" && v.trim()) return v;
}
return null;
}
export function IteratePage() {
const { refreshMeta } = useOutletContext<Ctx>();
const toast = useToast();
const [pending, setPending] = useState<PendingReport | null>(null);
const [cat, setCat] = useState<CatalogReport | null>(null);
const [jobs, setJobs] = useState<JobRecord[]>([]);
const [dmsTask, setDmsTask] = useState("dam");
const [dmsTrack, setDmsTrack] = useState<"platform" | "local">("platform");
const [dmsWeights, setDmsWeights] = useState("");
const [laneTrack, setLaneTrack] = useState<"platform" | "local">("platform");
const [laneModelPath, setLaneModelPath] = useState("");
const [laneDataRoot, setLaneDataRoot] = useState("");
const [laneTestList, setLaneTestList] = useState("list/test_gt.txt");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const loadAll = async () => {
const [p, c, j] = await Promise.all([api.pending(), api.catalog(), api.listJobs()]);
setPending(p);
setCat(c);
setJobs(j.items || []);
};
useEffect(() => { loadAll(); }, []);
useEffect(() => {
const tasks = Object.keys(pending?.projects?.dms?.task_defs || {});
if (tasks.length > 0 && !tasks.includes(dmsTask)) {
setDmsTask(tasks[0]);
}
}, [pending]);
const submit = async (action: string, params: Record<string, unknown>) => {
try {
await api.submitApproval(action, params);
toast(`已提交 ${action}`);
refreshMeta();
await loadAll();
} catch (e) {
toast(String(e), true);
}
};
const packRows = [
...(pending?.projects?.dms?.active_packs || []).map((n) => ({ project: "dms", name: n, enabled: true })),
...(pending?.projects?.dms?.not_enabled || []).map((n) => ({ project: "dms", name: n, enabled: false })),
...Object.entries(cat?.lane || {}).map(([n, info]) => ({ project: "lane", name: n, enabled: info.enabled })),
];
const iterJobs = useMemo(() => {
return jobs.filter((j) => ITERATE_ACTIONS.has(j.action as IterAction)).slice(0, 30);
}, [jobs]);
const milestones = useMemo(() => [...iterJobs].reverse(), [iterJobs]);
const selectedJob = useMemo(
() => milestones.find((j) => j.id === selectedJobId) || milestones[milestones.length - 1] || null,
[milestones, selectedJobId]
);
useEffect(() => {
if (!selectedJobId && milestones.length > 0) {
setSelectedJobId(milestones[milestones.length - 1].id);
}
}, [milestones, selectedJobId]);
const dmsTasks = Object.keys(pending?.projects?.dms?.task_defs || {});
return (
<>
<div className="grid-2">
<div className="panel">
<div className="panel-header"><h2>DMSYOLO//</h2></div>
<div className="panel-body">
<div className="crud-form">
<label className="field">
<span></span>
<select value={dmsTask} onChange={(e) => setDmsTask(e.target.value)}>
{(dmsTasks.length > 0 ? dmsTasks : [dmsTask]).map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label className="field">
<span></span>
<select value={dmsTrack} onChange={(e) => setDmsTrack(e.target.value as "platform" | "local")}>
<option value="platform">platform</option>
<option value="local">local</option>
</select>
</label>
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span>/</span>
<input value={dmsWeights} onChange={(e) => setDmsWeights(e.target.value)} placeholder="/path/to/best.pt" />
</label>
</div>
<div className="audit-quick">
<button type="button" className="btn btn-sm btn-primary" onClick={() => submit("train_dms", { task: dmsTask, mode: "full", track: dmsTrack })}></button>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => submit("eval_dms", { task: dmsTask, ...(dmsWeights ? { weights: dmsWeights } : {}) })}></button>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => submit("visualize_dms", { task: dmsTask, ...(dmsWeights ? { weights: dmsWeights } : {}) })}></button>
</div>
</div>
</div>
<div className="panel">
<div className="panel-header"><h2>LaneUFLD/{LANE_DATA_VIZ_ENABLED ? "/可视化" : ""}</h2></div>
<div className="panel-body">
<div className="crud-form">
<label className="field">
<span></span>
<select value={laneTrack} onChange={(e) => setLaneTrack(e.target.value as "platform" | "local")}>
<option value="platform">platform</option>
<option value="local">local</option>
</select>
</label>
<label className="field">
<span>test_list</span>
<input value={laneTestList} onChange={(e) => setLaneTestList(e.target.value)} placeholder="list/test_gt.txt" />
</label>
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span>/</span>
<input value={laneModelPath} onChange={(e) => setLaneModelPath(e.target.value)} placeholder="/path/to/best.pth" />
</label>
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span>data_root</span>
<input value={laneDataRoot} onChange={(e) => setLaneDataRoot(e.target.value)} placeholder="/path/to/lane/dataset/root" />
</label>
</div>
<div className="audit-quick">
<button type="button" className="btn btn-sm btn-primary" onClick={() => submit("train_lane", { track: laneTrack })}></button>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => submit("eval_lane", { model_path: laneModelPath, ...(laneDataRoot ? { data_root: laneDataRoot } : {}), test_list: laneTestList })}></button>
{LANE_DATA_VIZ_ENABLED && (
<button type="button" className="btn btn-sm btn-ghost" onClick={() => submit("visualize_lane", { model_path: laneModelPath, ...(laneDataRoot ? { data_root: laneDataRoot } : {}), test_list: laneTestList })}></button>
)}
</div>
</div>
</div>
</div>
<div className="panel">
<div className="panel-header"><h2></h2></div>
<div className="panel-body pack-list">
{packRows.map((p) => (
<div key={`${p.project}/${p.name}`} className={`pack-row ${p.enabled ? "active-pack" : ""}`}>
<span className="pack-name">{p.project}/{p.name}</span>
<span className={`badge ${p.enabled ? "badge-staged" : "badge-idle"}`}>{p.enabled ? "已启用" : "未启用"}</span>
<code className="text-sm">python as.py enable {p.project} {p.name}</code>
</div>
))}
</div>
</div>
<div className="panel">
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
{milestones.length === 0 ? (
<div className="empty-state">/</div>
) : (
<div className="milestone-layout">
<div className="milestone-track">
{milestones.map((j) => {
const action = j.action as IterAction;
const meta = ACTION_META[action];
const weight = extractWeight(j);
return (
<button
type="button"
key={j.id}
className={`milestone-node ${selectedJob?.id === j.id ? "active" : ""}`}
onClick={() => setSelectedJobId(j.id)}
>
<div className="milestone-dot" />
<div className="milestone-body">
<div className="milestone-title">{meta?.label || j.action}</div>
<div className="milestone-sub">{fmtTime(j.started_at || j.created_at)}</div>
<div className="milestone-sub">
<span className={`badge ${statusBadgeClass(j.status)}`}>{j.status}</span>
<span>{meta?.project || "未知"}</span>
<span>{meta?.kind || "动作"}</span>
</div>
<div className="milestone-sub mono">{weight ? `权重: ${weight}` : "权重: —"}</div>
</div>
</button>
);
})}
</div>
{selectedJob && (
<div className="milestone-detail">
<div className="milestone-detail-head">
<h3>{ACTION_META[selectedJob.action as IterAction]?.label || selectedJob.action}</h3>
<span className={`badge ${statusBadgeClass(selectedJob.status)}`}>{selectedJob.status}</span>
</div>
<dl className="detail-dl">
<dt>Job ID</dt>
<dd className="mono">{selectedJob.id}</dd>
<dt></dt>
<dd>{fmtTime(selectedJob.created_at)}</dd>
<dt></dt>
<dd>{fmtTime(selectedJob.started_at)}</dd>
<dt></dt>
<dd>{fmtTime(selectedJob.finished_at)}</dd>
<dt></dt>
<dd className="path-dd">{extractWeight(selectedJob) || "—"}</dd>
<dt></dt>
<dd><pre className="params-pre">{JSON.stringify(selectedJob.params || {}, null, 2)}</pre></dd>
<dt></dt>
<dd><pre className="params-pre milestone-result">{JSON.stringify(selectedJob.result || {}, null, 2)}</pre></dd>
</dl>
</div>
)}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { api, type JobRecord } from "../api/client";
export function JobsPage() {
const [items, setItems] = useState<JobRecord[]>([]);
useEffect(() => { api.listJobs().then((d) => setItems(d.items || [])); }, []);
const badge = (s: string) =>
({ queued: "badge-pending", running: "badge-training", succeeded: "badge-evaluated", failed: "badge-pending" }[s] || "badge-idle");
return (
<div className="panel">
<div className="panel-header"><h2>Job </h2><span className="text-dim">{items.length} </span></div>
<div className="panel-body table-wrap">
<table className="data-table">
<thead><tr><th>Job ID</th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{items.map((j) => (
<tr key={j.id}>
<td className="mono text-sm">{j.id}</td>
<td>{j.action}</td>
<td><span className={`badge ${badge(j.status)}`}>{j.status}</span></td>
<td>{j.approval_id || "—"}</td>
<td className="text-sm">{j.started_at?.slice(0, 19)}</td>
<td className="text-sm">{j.result?.error || (j.result?.ok ? "ok" : "")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useMemo, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { api, type BatchRecord, type PendingReport } from "../api/client";
import { forStage } from "../lib/labeling";
import { useToast } from "../components/Toast";
import { UploadsPage } from "./Uploads";
type Ctx = { refreshMeta: () => void };
export function LabelingPage() {
const { refreshMeta } = useOutletContext<Ctx>();
const toast = useToast();
const [pending, setPending] = useState<PendingReport | null>(null);
const [project, setProject] = useState<"dms" | "lane">("dms");
const [selectedTask, setSelectedTask] = useState<string | null>(null);
const [selectedPack, setSelectedPack] = useState<string | null>(null);
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const load = async () => {
setPending(await api.pending());
};
useEffect(() => {
load();
}, []);
const batches = useMemo(() => {
const all = pending?.batches || [];
if (project === "dms" && selectedTask) return all.filter((b) => b.project === "dms" && b.task === selectedTask);
if (project === "lane" && selectedPack) {
return all.filter((b) => b.project === "lane" && (b.pack === selectedPack || b.batch === selectedPack));
}
return all;
}, [pending, project, selectedTask, selectedPack]);
const selected = batches.find((b) => `${b.location}:${b.batch}` === selectedBatchId) || batches[0];
if (!pending) return <p className="empty-state"></p>;
const bs = pending?.batches || [];
const dmsTasks = Object.keys(pending.projects?.dms?.task_defs || {});
const lanePacks = Object.keys(pending.projects?.lane?.packs || {});
return (
<>
<div className="kpi-strip">
<div className="kpi"><span className="kpi-val">{bs.filter((b) => b.stage === "returned").length}</span><span className="kpi-lbl"></span></div>
<div className="kpi"><span className="kpi-val">{bs.filter((b) => b.stage === "raw_pool").length}</span><span className="kpi-lbl"></span></div>
<div className="kpi"><span className="kpi-val">{bs.filter((b) => b.stage === "out_for_labeling").length}</span><span className="kpi-lbl"></span></div>
<div className="kpi"><span className="kpi-val">{(pending?.projects?.dms?.active_packs || []).length}</span><span className="kpi-lbl"></span></div>
</div>
<div className="panel">
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
<UploadsPage embedded />
</div>
</div>
<div className="panel">
<div className="panel-header">
<h2></h2>
<div className="audit-filters">
<button type="button" className={`btn btn-sm ${project === "dms" ? "btn-primary" : "btn-ghost"}`} onClick={() => { setProject("dms"); setSelectedPack(null); }}>DMS</button>
<button type="button" className={`btn btn-sm ${project === "lane" ? "btn-primary" : "btn-ghost"}`} onClick={() => { setProject("lane"); setSelectedTask(null); }}>Lane</button>
</div>
</div>
<div className="panel-body">
<div className="catalog-toolbar">
<label className="field">
<span>{project === "dms" ? "任务" : "数据包"}</span>
<select
value={project === "dms" ? (selectedTask || "") : (selectedPack || "")}
onChange={(e) => {
const value = e.target.value;
if (project === "dms") setSelectedTask(value || null);
else setSelectedPack(value || null);
setSelectedBatchId(null);
}}
>
<option value=""></option>
{(project === "dms" ? dmsTasks : lanePacks).map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
</div>
<div className="grid-2-equal">
<div className="panel panel-compact">
<div className="panel-header"><h2></h2><span className="text-dim">{batches.length} </span></div>
<div className="panel-body table-wrap">
<table className="data-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{batches.map((b) => {
const st = forStage(b.stage);
const id = `${b.location}:${b.batch}`;
return (
<tr key={id} className={selected === b ? "row-active" : ""} onClick={() => setSelectedBatchId(id)} style={{ cursor: "pointer" }}>
<td><strong>{b.batch}</strong></td>
<td><span className={`badge ${st.badge}`}>{st.label}</span></td>
<td>{b.location}</td>
<td>{b.counts?.images ?? "—"}</td>
<td>{b.counts?.labels ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="panel panel-compact">
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
{selected
? <BatchDetail b={selected} onDone={() => { load(); refreshMeta(); toast("已提交"); }} />
: <p className="empty-state"></p>}
</div>
</div>
</div>
</div>
</div>
</>
);
}
function BatchDetail({ b, onDone }: { b: BatchRecord; onDone: () => void }) {
const st = forStage(b.stage);
return (
<div className="batch-detail">
<div className="batch-detail-header">
<h3>{b.batch}</h3>
<span className={`badge ${st.badge}`}>{st.label}</span>
</div>
<dl className="detail-dl">
<dt></dt><dd className="mono path-dd">{b.path || "由上传解析后生成"}</dd>
<dt></dt><dd>{b.location}{b.pack ? ` · ${b.pack}` : ""}</dd>
<dt> / </dt><dd>{b.counts?.images ?? "—"} / {b.counts?.labels ?? "—"}</dd>
</dl>
{b.next_cli && (
<div className="cli-box">
<code>{b.next_cli}</code>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => navigator.clipboard.writeText(b.next_cli!)}></button>
</div>
)}
<div className="batch-actions">
{b.project === "dms" && b.stage === "returned" && (
<button type="button" className="btn btn-sm btn-primary" onClick={async () => {
await api.submitBuildBatch({ task: b.task, batch: b.batch, pack: b.pack || "dms_v2", location: b.location });
onDone();
}}></button>
)}
<button type="button" className="btn btn-sm btn-ghost" onClick={async () => {
await api.registerBatch({ project: b.project, task: b.task, batch: b.batch, pack: b.pack, location: b.location });
onDone();
}}> meta</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export function LoginPage() {
const { user, loginFeishu, loginDev, authConfig } = useAuth();
const navigate = useNavigate();
const [err, setErr] = useState("");
const [name, setName] = useState("开发用户");
useEffect(() => {
if (user) navigate("/labeling", { replace: true });
}, [user, navigate]);
const onDevLogin = async () => {
setErr("");
try {
await loginDev(name);
navigate("/labeling");
} catch (e) {
setErr(String(e));
}
};
return (
<div className="login-page">
<div className="login-shell simple">
<section className="login-card panel" id="access">
<h2></h2>
<p className="text-dim">广 · AEB </p>
{authConfig?.feishu_enabled && (
<button type="button" className="btn btn-primary btn-feishu" onClick={loginFeishu}>
使
</button>
)}
{authConfig?.dev_auth_enabled && (
<div className="dev-login">
<p className="text-sm text-dim"> App</p>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="显示名称" />
<button type="button" className="btn btn-ghost" onClick={onDevLogin}></button>
</div>
)}
{!authConfig?.feishu_enabled && !authConfig?.dev_auth_enabled && (
<p className="empty-state"></p>
)}
{err && <p className="login-err">{err}</p>}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { api, type PendingReport } from "../api/client";
export function LogsPage() {
const [pending, setPending] = useState<PendingReport | null>(null);
useEffect(() => { api.pending().then(setPending); }, []);
const recent = pending?.projects?.dms?.recent_ingest || [];
return (
<>
<div className="panel">
<div className="panel-header"><h2>ingest_log.jsonl</h2></div>
<div className="panel-body log-list">
{recent.length ? recent.map((l, i) => (
<div key={i} className="log-entry">
<span className="log-time">{l.ts}</span>
<span className="log-type ingest">ingest</span>
<span>task={l.task} pack={l.pack} added={l.added ?? "—"}</span>
</div>
)) : <p className="empty-state"></p>}
</div>
</div>
<div className="panel">
<div className="panel-header"><h2>approval_queue.jsonl</h2></div>
<div className="panel-body"><p className="mono text-sm">HSAP/manifests/approval_queue.jsonl</p></div>
</div>
</>
);
}

View File

@@ -0,0 +1,588 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useOutletContext } from "react-router-dom";
import {
api,
type ModelRegistry,
type PendingReport,
type TrainingRecord,
} from "../api/client";
import { useToast } from "../components/Toast";
import { LANE_DATA_VIZ_ENABLED } from "../config/featureFlags";
type Ctx = { refreshMeta: () => void };
type TrainAction =
| "train_dms"
| "train_lane"
| "eval_dms"
| "eval_lane"
| "visualize_dms"
| "visualize_lane"
| "promote_dms";
const CREATE_ACTIONS: { id: TrainAction; label: string; project: "dms" | "lane"; kind: string }[] = [
{ id: "train_dms", label: "DMS 训练", project: "dms", kind: "train" },
{ id: "eval_dms", label: "DMS 评估", project: "dms", kind: "eval" },
{ id: "visualize_dms", label: "DMS 可视化", project: "dms", kind: "visualize" },
{ id: "promote_dms", label: "DMS 晋级", project: "dms", kind: "promote" },
{ id: "train_lane", label: "Lane 训练", project: "lane", kind: "train" },
{ id: "eval_lane", label: "Lane 评估", project: "lane", kind: "eval" },
...(LANE_DATA_VIZ_ENABLED
? [{ id: "visualize_lane" as const, label: "Lane 可视化", project: "lane" as const, kind: "visualize" }]
: []),
];
function statusBadge(status: string): string {
if (status === "succeeded") return "badge-promoted";
if (status === "running") return "badge-training";
if (status === "failed") return "badge-pending";
if (status === "queued") return "badge-pending";
return "badge-idle";
}
function kindLabel(kind: string): string {
return (
{
train: "训练",
eval: "评估",
visualize: "可视化",
promote: "晋级",
pipeline: "流水线",
}[kind] || kind
);
}
function fmtTime(ts?: string | null): string {
if (!ts) return "—";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
return d.toLocaleString("zh-CN", { hour12: false });
}
function fmtDuration(sec?: number | null): string {
if (sec == null) return "—";
if (sec < 60) return `${sec.toFixed(1)}s`;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `${m}m ${s}s`;
}
function fmtMetric(metrics: Record<string, unknown>): string {
const parts: string[] = [];
if (metrics.map50 != null) parts.push(`mAP50=${metrics.map50}`);
if (metrics.delta_map50 != null) parts.push(`Δ=${metrics.delta_map50}`);
return parts.length > 0 ? parts.join(" · ") : "—";
}
export function TrainingPage() {
const { refreshMeta } = useOutletContext<Ctx>();
const toast = useToast();
const [pending, setPending] = useState<PendingReport | null>(null);
const [records, setRecords] = useState<TrainingRecord[]>([]);
const [summary, setSummary] = useState({ total: 0, running: 0, queued: 0, succeeded: 0, failed: 0 });
const [models, setModels] = useState<ModelRegistry | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [filterProject, setFilterProject] = useState<"" | "dms" | "lane">("");
const [filterKind, setFilterKind] = useState("");
const [filterStatus, setFilterStatus] = useState("");
const [filterTask, setFilterTask] = useState("");
const [createAction, setCreateAction] = useState<TrainAction>("train_dms");
const [createNote, setCreateNote] = useState("");
const [dmsTask, setDmsTask] = useState("dam");
const [dmsTrack, setDmsTrack] = useState<"platform" | "local">("platform");
const [dmsWeights, setDmsWeights] = useState("");
const [laneTrack, setLaneTrack] = useState<"platform" | "local">("platform");
const [laneModelPath, setLaneModelPath] = useState("");
const [laneDataRoot, setLaneDataRoot] = useState("");
const [laneTestList, setLaneTestList] = useState("list/test_gt.txt");
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [p, list, reg] = await Promise.all([
api.pending(),
api.listTrainingRecords({
project: filterProject || undefined,
kind: filterKind || undefined,
status: filterStatus || undefined,
task: filterTask || undefined,
limit: 200,
}),
api.getModelRegistry("dms", filterTask || undefined),
]);
setPending(p);
setRecords(list.items || []);
setSummary(list.summary || { total: 0, running: 0, queued: 0, succeeded: 0, failed: 0 });
setModels(reg);
} catch (e) {
toast(String(e), true);
} finally {
setLoading(false);
}
}, [filterProject, filterKind, filterStatus, filterTask, toast]);
useEffect(() => {
loadAll();
}, [loadAll]);
useEffect(() => {
const tasks = Object.keys(pending?.projects?.dms?.task_defs || {});
if (tasks.length > 0 && !tasks.includes(dmsTask)) {
setDmsTask(tasks[0]);
}
}, [pending, dmsTask]);
useEffect(() => {
const hasRunning = records.some((r) => r.status === "running" || r.status === "queued");
if (!hasRunning) return;
const t = setInterval(loadAll, 5000);
return () => clearInterval(t);
}, [records, loadAll]);
const selected = useMemo(
() => records.find((r) => r.id === selectedId) || records[0] || null,
[records, selectedId]
);
useEffect(() => {
if (!selectedId && records.length > 0) {
setSelectedId(records[0].id);
}
}, [records, selectedId]);
const dmsTasks = Object.keys(pending?.projects?.dms?.task_defs || {});
const createMeta = CREATE_ACTIONS.find((a) => a.id === createAction);
const buildCreateParams = (): Record<string, unknown> => {
switch (createAction) {
case "train_dms":
return { task: dmsTask, mode: "full", track: dmsTrack };
case "eval_dms":
return { task: dmsTask, ...(dmsWeights ? { weights: dmsWeights } : {}) };
case "visualize_dms":
return { task: dmsTask, ...(dmsWeights ? { weights: dmsWeights } : {}) };
case "promote_dms":
return { task: dmsTask };
case "train_lane":
return { track: laneTrack };
case "eval_lane":
return {
model_path: laneModelPath,
test_list: laneTestList,
...(laneDataRoot ? { data_root: laneDataRoot } : {}),
};
case "visualize_lane":
return {
model_path: laneModelPath,
test_list: laneTestList,
...(laneDataRoot ? { data_root: laneDataRoot } : {}),
};
default:
return {};
}
};
const handleCreate = async () => {
if (createAction === "eval_lane" && !laneModelPath.trim()) {
toast("Lane 评估需填写模型路径", true);
return;
}
try {
const approval = await api.createTrainingRecord(createAction, buildCreateParams(), createNote || undefined);
toast(`已提交审核单 ${approval.id}`);
refreshMeta();
await loadAll();
setCreateNote("");
} catch (e) {
toast(String(e), true);
}
};
const currentTaskVersion =
filterTask && models?.tasks?.[filterTask]
? models.tasks[filterTask]
: dmsTask && models?.tasks?.[dmsTask]
? models.tasks[dmsTask]
: null;
return (
<>
<div className="kpi-strip">
<div className="kpi">
<span className="kpi-val">{summary.total}</span>
<span className="kpi-lbl"></span>
</div>
<div className="kpi">
<span className="kpi-val">{summary.running + summary.queued}</span>
<span className="kpi-lbl"></span>
</div>
<div className="kpi">
<span className="kpi-val">{summary.succeeded}</span>
<span className="kpi-lbl"></span>
</div>
<div className="kpi">
<span className="kpi-val">{summary.failed}</span>
<span className="kpi-lbl"></span>
</div>
<div className="kpi">
<span className="kpi-val text-sm mono">{currentTaskVersion?.current ? "有线上" : "—"}</span>
<span className="kpi-lbl">DMS </span>
</div>
</div>
<div className="panel">
<div className="panel-header">
<h2> / </h2>
</div>
<div className="panel-body">
<div className="crud-form">
<label className="field">
<span></span>
<select value={createAction} onChange={(e) => setCreateAction(e.target.value as TrainAction)}>
{CREATE_ACTIONS.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
</option>
))}
</select>
</label>
<label className="field">
<span></span>
<input
value={createNote}
onChange={(e) => setCreateNote(e.target.value)}
placeholder="可选:版本说明、实验目的"
/>
</label>
{createMeta?.project === "dms" && (
<>
<label className="field">
<span></span>
<select value={dmsTask} onChange={(e) => setDmsTask(e.target.value)}>
{(dmsTasks.length > 0 ? dmsTasks : [dmsTask]).map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
{createAction === "train_dms" && (
<label className="field">
<span></span>
<select value={dmsTrack} onChange={(e) => setDmsTrack(e.target.value as "platform" | "local")}>
<option value="platform">platform</option>
<option value="local">local</option>
</select>
</label>
)}
{["eval_dms", "visualize_dms"].includes(createAction) && (
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span></span>
<input
value={dmsWeights}
onChange={(e) => setDmsWeights(e.target.value)}
placeholder="/path/to/best.pt"
/>
</label>
)}
</>
)}
{createMeta?.project === "lane" && (
<>
{createAction === "train_lane" && (
<label className="field">
<span></span>
<select value={laneTrack} onChange={(e) => setLaneTrack(e.target.value as "platform" | "local")}>
<option value="platform">platform</option>
<option value="local">local</option>
</select>
</label>
)}
{createAction !== "train_lane" && (
<>
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span></span>
<input
value={laneModelPath}
onChange={(e) => setLaneModelPath(e.target.value)}
placeholder="/path/to/best.pth"
/>
</label>
<label className="field">
<span>test_list</span>
<input value={laneTestList} onChange={(e) => setLaneTestList(e.target.value)} />
</label>
<label className="field">
<span>data_root</span>
<input value={laneDataRoot} onChange={(e) => setLaneDataRoot(e.target.value)} />
</label>
</>
)}
</>
)}
<div className="crud-actions">
<button type="button" className="btn btn-primary" onClick={handleCreate}>
</button>
<span className="text-dim"></span>
</div>
</div>
</div>
</div>
<div className="panel">
<div className="panel-header">
<h2></h2>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => loadAll()}>
</button>
</div>
<div className="panel-body">
<div className="catalog-toolbar">
<label className="field">
<span></span>
<select value={filterProject} onChange={(e) => setFilterProject(e.target.value as "" | "dms" | "lane")}>
<option value=""></option>
<option value="dms">DMS</option>
<option value="lane">Lane</option>
</select>
</label>
<label className="field">
<span></span>
<select value={filterKind} onChange={(e) => setFilterKind(e.target.value)}>
<option value=""></option>
<option value="train"></option>
<option value="eval"></option>
<option value="visualize"></option>
<option value="promote"></option>
</select>
</label>
<label className="field">
<span></span>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value=""></option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="succeeded">succeeded</option>
<option value="failed">failed</option>
</select>
</label>
<label className="field">
<span>DMS </span>
<select value={filterTask} onChange={(e) => setFilterTask(e.target.value)}>
<option value=""></option>
{dmsTasks.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
</div>
{loading && records.length === 0 ? (
<p className="empty-state"></p>
) : records.length === 0 ? (
<p className="empty-state"></p>
) : (
<div className="grid-2-equal">
<div className="panel panel-compact">
<div className="panel-header">
<h2></h2>
<span className="text-dim">{records.length} </span>
</div>
<div className="panel-body table-wrap">
<table className="data-table compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr
key={r.id}
className={selected?.id === r.id ? "row-active" : ""}
style={{ cursor: "pointer" }}
onClick={() => setSelectedId(r.id)}
>
<td className="text-sm">{fmtTime(r.started_at || r.created_at).slice(0, 16)}</td>
<td>
<div>{r.action_label || r.action}</div>
<div className="text-dim">
{r.project} · {kindLabel(r.kind || "")}
</div>
</td>
<td>{r.task || "—"}</td>
<td>
<span className={`badge ${statusBadge(r.status)}`}>{r.status}</span>
</td>
<td className="text-sm">{fmtMetric(r.metrics || {})}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="panel panel-compact">
<div className="panel-header">
<h2></h2>
</div>
<div className="panel-body">
{selected ? (
<TrainingDetail record={selected} />
) : (
<p className="empty-state"></p>
)}
</div>
</div>
</div>
)}
</div>
</div>
{models && Object.keys(models.tasks || {}).length > 0 && (
<div className="panel">
<div className="panel-header">
<h2>DMS </h2>
</div>
<div className="panel-body table-wrap">
<table className="data-table compact">
<thead>
<tr>
<th></th>
<th> (candidate)</th>
<th>线 (current)</th>
<th></th>
</tr>
</thead>
<tbody>
{Object.entries(models.tasks || {}).map(([task, ver]) => {
const v = ver as Record<string, unknown>;
const lastEval = (v.last_eval || {}) as Record<string, unknown>;
return (
<tr key={task}>
<td>
<strong>{task}</strong>
</td>
<td className="mono text-sm path-dd">{String(v.candidate || "—")}</td>
<td className="mono text-sm path-dd">{String(v.current || "—")}</td>
<td className="text-sm">
{lastEval.map50 != null ? `mAP50=${lastEval.map50}` : "—"}
{lastEval.weights ? (
<div className="text-dim mono">{String(lastEval.weights)}</div>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{(models?.eval_history?.length || 0) > 0 && (
<div className="panel">
<div className="panel-header">
<h2></h2>
</div>
<div className="panel-body table-wrap">
<table className="data-table compact">
<thead>
<tr>
<th></th>
<th></th>
<th>mAP50</th>
<th>Δ</th>
<th></th>
</tr>
</thead>
<tbody>
{(models?.eval_history || []).map((row, i) => (
<tr key={`${row.ts}-${i}`}>
<td className="text-sm">{fmtTime(row.ts as string)}</td>
<td>{String(row.task || "—")}</td>
<td>{row.map50 != null ? String(row.map50) : "—"}</td>
<td>{row.delta_map50 != null ? String(row.delta_map50) : "—"}</td>
<td className="mono text-sm path-dd">{String(row.weights || "—")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
function TrainingDetail({ record }: { record: TrainingRecord }) {
const approval = record.approval;
return (
<div className="batch-detail">
<div className="batch-detail-header">
<h3>{record.action_label || record.action}</h3>
<span className={`badge ${statusBadge(record.status)}`}>{record.status}</span>
</div>
<dl className="detail-dl">
<dt>Job ID</dt>
<dd className="mono">{record.id}</dd>
<dt> / </dt>
<dd>
{record.project} · {kindLabel(record.kind || "")}
{record.task ? ` · ${record.task}` : ""}
{record.track ? ` · ${record.track}` : ""}
</dd>
<dt> / / </dt>
<dd>
{fmtTime(record.created_at)} {fmtTime(record.started_at)} {fmtTime(record.finished_at)}
</dd>
<dt></dt>
<dd>{fmtDuration(record.duration_sec)}</dd>
<dt> / </dt>
<dd className="path-dd mono">{record.weight_path || "—"}</dd>
<dt></dt>
<dd>{fmtMetric(record.metrics || {})}</dd>
{record.error && (
<>
<dt></dt>
<dd className="text-sm" style={{ color: "var(--danger)" }}>
{record.error}
</dd>
</>
)}
{approval && (
<>
<dt></dt>
<dd>
<Link to={`/audit/${approval.id}`}>{approval.id}</Link>
<span className="text-dim"> · {approval.status}</span>
{approval.note ? <div className="text-sm">{approval.note}</div> : null}
</dd>
</>
)}
<dt></dt>
<dd>
<pre className="params-pre">{JSON.stringify(record.params || {}, null, 2)}</pre>
</dd>
<dt></dt>
<dd>
<pre className="params-pre milestone-result">{JSON.stringify(record.result || {}, null, 2)}</pre>
</dd>
</dl>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useMemo, useState } from "react";
import { api, type DataCandidate } from "../api/client";
import { useToast } from "../components/Toast";
export function UploadsPage({ embedded = false }: { embedded?: boolean }) {
const toast = useToast();
const [project, setProject] = useState<"dms" | "lane">("dms");
const [task, setTask] = useState("dam");
const [tasks, setTasks] = useState<string[]>([]);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [candidates, setCandidates] = useState<DataCandidate[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const loadCandidates = async () => {
const res = await api.listDataCandidates(80);
setCandidates(res.items || []);
};
useEffect(() => {
loadCandidates();
}, []);
useEffect(() => {
const loadTasks = async () => {
try {
const pending = await api.pending();
const defs = pending.projects?.dms?.task_defs || {};
const names = Object.keys(defs);
setTasks(names);
if (names.length > 0 && !names.includes(task)) {
setTask(names[0]);
}
} catch {
// keep manual fallback value
}
};
loadTasks();
}, []);
useEffect(() => {
if (!activeId) return;
const t = setInterval(async () => {
try {
const latest = await api.getDataCandidate(activeId);
setCandidates((prev) => [latest, ...prev.filter((x) => x.id !== latest.id)]);
if (latest.status === "analyzed" || latest.status === "failed") {
setActiveId(null);
await loadCandidates();
}
} catch {
// keep polling loop running; backend errors are surfaced in status list
}
}, 2000);
return () => clearInterval(t);
}, [activeId]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
toast("请先选择压缩包文件");
return;
}
setUploading(true);
setProgress(0);
try {
const res = await api.uploadDatasetFile(file, project, project === "dms" ? task : undefined, setProgress);
setActiveId(res.candidate.id);
setCandidates((prev) => [res.candidate, ...prev.filter((x) => x.id !== res.candidate.id)]);
toast(`上传成功,开始分析:${res.candidate.id}`);
} catch (err) {
toast(err instanceof Error ? err.message : "上传失败");
} finally {
setUploading(false);
}
};
const active = useMemo(() => candidates.find((x) => x.id === activeId) || null, [candidates, activeId]);
return (
<>
<div className={embedded ? "" : "panel"}>
<div className="panel-header"><h2></h2></div>
<div className="panel-body">
<form className="crud-form" onSubmit={submit}>
<label className="field">
<span>Project</span>
<select value={project} onChange={(e) => {
const next = e.target.value as "dms" | "lane";
setProject(next);
if (next === "dms" && tasks.length > 0 && !tasks.includes(task)) {
setTask(tasks[0]);
}
}}>
<option value="dms">dms</option>
<option value="lane">lane</option>
</select>
</label>
{project === "dms" && (
<label className="field">
<span>Task</span>
<select value={task} onChange={(e) => setTask(e.target.value)}>
{(tasks.length > 0 ? tasks : [task]).map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
)}
<label className="field" style={{ gridColumn: "1 / -1" }}>
<span></span>
<input type="file" accept=".zip,.tar,.tar.gz,.tgz" onChange={(e) => setFile(e.target.files?.[0] || null)} />
</label>
<div className="crud-actions">
<button type="submit" className="btn btn-primary" disabled={uploading}>
{uploading ? "上传中..." : "上传并自动分析"}
</button>
<button type="button" className="btn btn-ghost" onClick={() => loadCandidates()}></button>
</div>
</form>
<div style={{ marginTop: 12 }}>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<p className="audit-note">{progress}% {active ? `| 当前分析状态:${active.status}` : ""}</p>
</div>
</div>
</div>
<div className={embedded ? "panel panel-compact" : "panel"}>
<div className="panel-header"><h2></h2></div>
<div className="panel-body table-wrap">
<table className="data-table compact">
<thead>
<tr>
<th>ID</th>
<th>/</th>
<th></th>
<th></th>
<th>train/val/test</th>
<th></th>
</tr>
</thead>
<tbody>
{candidates.map((c) => (
<tr key={c.id} className={c.id === activeId ? "row-active" : ""}>
<td className="mono">{c.id}</td>
<td>{c.project}{c.task ? `/${c.task}` : ""}</td>
<td><span className="badge badge-idle">{c.status}</span></td>
<td>{c.format_id || "—"}</td>
<td>
{c.split_counts
? `${c.split_counts.train || 0}/${c.split_counts.val || 0}/${c.split_counts.test || 0}`
: "—"}
</td>
<td>{c.error_message || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

9
platform/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const proxyTarget = env.VITE_API_PROXY || "http://127.0.0.1:8787";
return {
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
"/api": { target: proxyTarget, changeOrigin: true },
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
};
});