feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation
Major changes: - New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind - 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理 - Data catalog with charts (DMS/ADAS/Lane 3-tab view) - Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance - Audit enhancements: batch operations, rejection categories, Feishu notifications - Operation audit log (操作日志) - World model simulation studio (仿真工坊) - Dataset version management with snapshots and diff - ADAS 7-class dataset integration (138K images organized + compressed) - User management with Feishu integration and pagination - CRUD/search/filter on all pages, card layout redesign - PIL-optimized image overlay rendering - Auto-snapshot on build, in_review workflow stage - Removed embedded algorithm code (now in workspace)
This commit is contained in:
@@ -1,47 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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>;
|
||||
};
|
||||
};
|
||||
70
platform/web/src/app/AuthContext.tsx
Normal file
70
platform/web/src/app/AuthContext.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { hsapApi, hasPermission, type AuthUser } from "./hsap-api";
|
||||
|
||||
type AuthCtx = {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
authConfig: { feishu_enabled: boolean; dev_auth_enabled: boolean } | null;
|
||||
loginDev: (name?: string) => Promise<void>;
|
||||
loginFeishu: () => void;
|
||||
logout: () => void;
|
||||
hasPermission: (perm: string) => boolean;
|
||||
};
|
||||
|
||||
const Ctx = createContext<AuthCtx | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authConfig, setAuthConfig] = useState<AuthCtx["authConfig"]>(null);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tokenFromUrl = params.get("token");
|
||||
if (tokenFromUrl) {
|
||||
hsapApi.setToken(tokenFromUrl);
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
try {
|
||||
setAuthConfig(await hsapApi.authConfig());
|
||||
if (hsapApi.getToken()) {
|
||||
setUser(await hsapApi.me());
|
||||
}
|
||||
} catch {
|
||||
hsapApi.setToken(null);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { bootstrap(); }, [bootstrap]);
|
||||
|
||||
const loginDev = useCallback(async (name?: string) => {
|
||||
const res = await hsapApi.devLogin(name);
|
||||
hsapApi.setToken(res.access_token);
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
const loginFeishu = useCallback(() => {
|
||||
window.location.assign(`${window.location.origin}/api/v1/auth/feishu/authorize`);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
hsapApi.setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, loading, authConfig, loginDev, loginFeishu, logout, hasPermission: (perm: string) => hasPermission(user, perm) }),
|
||||
[user, loading, authConfig, loginDev, loginFeishu, logout],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useAuth outside provider");
|
||||
return ctx;
|
||||
}
|
||||
38
platform/web/src/app/HsapApp.tsx
Normal file
38
platform/web/src/app/HsapApp.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter, Switch, Route, Redirect } from "react-router-dom";
|
||||
import { AuthProvider, useAuth } from "./AuthContext";
|
||||
import { MainShell } from "./MainShell";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
|
||||
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-400 animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <Redirect to="/login" />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const AppRoutes: React.FC = () => (
|
||||
<Switch>
|
||||
<Route exact path="/login" component={LoginPage} />
|
||||
{/* All other routes — inside MainShell */}
|
||||
<Route>
|
||||
<RequireAuth>
|
||||
<MainShell />
|
||||
</RequireAuth>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export const HsapApp: React.FC = () => (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
46
platform/web/src/app/MainShell.tsx
Normal file
46
platform/web/src/app/MainShell.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { LabelingShell } from "@/modules/labeling/LabelingShell";
|
||||
import { ModelsShell } from "@/modules/models/ModelsShell";
|
||||
import { FleetShell } from "@/modules/fleet/FleetShell";
|
||||
import { SystemShell } from "@/modules/system/SystemShell";
|
||||
import { DashboardPage } from "@/modules/labeling/pages/DashboardPage";
|
||||
|
||||
export const MainShell: React.FC = () => {
|
||||
return (
|
||||
<div className="main-shell">
|
||||
<Sidebar />
|
||||
<div className="main-content">
|
||||
<div className="module-area">
|
||||
<Switch>
|
||||
{/* ── Dashboard (root) ── */}
|
||||
<Route exact path="/" component={DashboardPage} />
|
||||
{/* ── Module routes ── */}
|
||||
<Route path="/labeling" component={LabelingShell} />
|
||||
<Route path="/models" component={ModelsShell} />
|
||||
<Route path="/fleet" component={FleetShell} />
|
||||
<Route path="/system" component={SystemShell} />
|
||||
|
||||
{/* ── Legacy redirects ── */}
|
||||
<Redirect from="/deliveries" to="/labeling/deliveries" />
|
||||
<Redirect from="/catalog" to="/labeling/catalog" />
|
||||
<Redirect from="/labeling/ml" to="/models/overview" />
|
||||
<Redirect from="/audit/:id" to="/system/audit/:id" />
|
||||
<Redirect from="/audit" to="/system/audit" />
|
||||
<Redirect from="/jobs" to="/system/jobs" />
|
||||
<Redirect from="/training" to="/models/training/records" />
|
||||
|
||||
{/* ── 404 ── */}
|
||||
<Route>
|
||||
<div className="page-container text-center py-20">
|
||||
<h2 className="text-xl text-gray-400">页面未找到</h2>
|
||||
<p className="text-gray-400 text-sm mt-2">请检查 URL 是否正确</p>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
221
platform/web/src/app/Sidebar.tsx
Normal file
221
platform/web/src/app/Sidebar.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
|
||||
// ── Module definitions ──
|
||||
|
||||
interface NavItemDef {
|
||||
to: string;
|
||||
label: string;
|
||||
perm?: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface ModuleDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
requiredPerm: string;
|
||||
items: NavItemDef[];
|
||||
}
|
||||
|
||||
const MODULES: ModuleDef[] = [
|
||||
{
|
||||
id: "labeling",
|
||||
label: "数据送标",
|
||||
icon: "📋",
|
||||
requiredPerm: "read:pending",
|
||||
items: [
|
||||
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending" },
|
||||
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending" },
|
||||
{ to: "/labeling/review", label: "标注质检", perm: "read:pending" },
|
||||
{ to: "/labeling/export", label: "导出与入库", perm: "read:pending" },
|
||||
{ to: "/labeling/deliveries", label: "批次台账", perm: "read:deliveries" },
|
||||
{ to: "/labeling/catalog", label: "数据目录", perm: "read:catalog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "模型管理",
|
||||
icon: "🧠",
|
||||
requiredPerm: "read:jobs",
|
||||
items: [
|
||||
{ to: "/models/overview", label: "模型概览", perm: "read:jobs" },
|
||||
{ to: "/models/datasets", label: "数据集版本", perm: "read:jobs" },
|
||||
{ to: "/models/training/submit", label: "训练提交", perm: "write:approval_submit" },
|
||||
{ to: "/models/training/records", label: "训练记录", perm: "read:jobs" },
|
||||
{ to: "/models/evaluation", label: "评估管理", perm: "read:jobs" },
|
||||
{ to: "/models/promotion", label: "模型晋级", perm: "write:approval_submit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fleet",
|
||||
label: "车队管理",
|
||||
icon: "🚛",
|
||||
requiredPerm: "read:fleet",
|
||||
items: [
|
||||
{ to: "/fleet/dashboard", label: "车队总览", perm: "read:fleet" },
|
||||
{ to: "/fleet/vehicles", label: "车辆管理", perm: "read:fleet" },
|
||||
{ to: "/fleet/map", label: "实时地图", perm: "read:fleet" },
|
||||
{ to: "/fleet/trips", label: "行程记录", perm: "read:fleet" },
|
||||
{ to: "/fleet/tbox", label: "T-Box 配置", perm: "write:fleet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: "系统管理",
|
||||
icon: "⚙️",
|
||||
requiredPerm: "",
|
||||
items: [
|
||||
{ to: "/system/audit", label: "审核队列", perm: "read:audit" },
|
||||
{ to: "/system/jobs", label: "任务监控", perm: "read:jobs" },
|
||||
{ to: "/system/logs", label: "Agent 追踪", perm: "" },
|
||||
{ to: "/system/audit-log", label: "操作日志", perm: "admin:users" },
|
||||
{ to: "/system/users", label: "用户管理", perm: "admin:users" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Health indicator ──
|
||||
|
||||
function HealthDot() {
|
||||
const [ok, setOk] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false;
|
||||
fetch("/api/v1/health", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (!cancel) setOk(d?.status === "ok"); })
|
||||
.catch(() => { if (!cancel) setOk(false); });
|
||||
const t = setInterval(() => {
|
||||
fetch("/api/v1/health", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (!cancel) setOk(d?.status === "ok"); })
|
||||
.catch(() => { if (!cancel) setOk(false); });
|
||||
}, 30000);
|
||||
return () => { cancel = true; clearInterval(t); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
ok === null ? "bg-gray-300" : ok ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
title={ok === null ? "检测中..." : ok ? "服务正常" : "服务异常"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sidebar component ──
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const { user, hasPermission, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Determine which module is active from the current path
|
||||
const activeModuleId = MODULES.find(
|
||||
(m) => location.pathname.startsWith("/" + m.id)
|
||||
)?.id;
|
||||
|
||||
// Accordion: only the active module is expanded
|
||||
const [expandedId, setExpandedId] = useState<string | null>(() => {
|
||||
// Restore from localStorage or use active module
|
||||
const saved = localStorage.getItem("sidebar:expanded");
|
||||
return saved || activeModuleId || null;
|
||||
});
|
||||
|
||||
// Auto-expand active module when route changes
|
||||
useEffect(() => {
|
||||
if (activeModuleId) {
|
||||
setExpandedId(activeModuleId);
|
||||
}
|
||||
}, [activeModuleId]);
|
||||
|
||||
const toggleModule = useCallback(
|
||||
(id: string) => {
|
||||
const next = expandedId === id ? null : id;
|
||||
setExpandedId(next);
|
||||
if (next) localStorage.setItem("sidebar:expanded", next);
|
||||
else localStorage.removeItem("sidebar:expanded");
|
||||
},
|
||||
[expandedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
{/* Brand */}
|
||||
<div className="sidebar-brand">
|
||||
<NavLink to="/" className="no-underline text-inherit">
|
||||
HSAP <span className="text-gray-400 font-normal text-sm">数据闭环平台</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="sidebar-nav">
|
||||
{MODULES.map((mod) => {
|
||||
const visibleItems = mod.items.filter(
|
||||
(item) => !item.perm || hasPermission(item.perm),
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedId === mod.id;
|
||||
const isActive = activeModuleId === mod.id;
|
||||
|
||||
return (
|
||||
<div key={mod.id} className="module-group">
|
||||
<button
|
||||
className={`module-group-header ${isActive ? "active" : ""}`}
|
||||
onClick={() => toggleModule(mod.id)}
|
||||
>
|
||||
<span>{mod.icon}</span>
|
||||
<span>{mod.label}</span>
|
||||
<span className={`chevron ${isExpanded ? "open" : ""}`}>▶</span>
|
||||
</button>
|
||||
<div className={`module-group-items${isExpanded ? "" : " collapsed"}`}>
|
||||
{visibleItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
exact={item.exact ?? false}
|
||||
className="nav-item"
|
||||
activeClassName="active"
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Userpic username={user?.name || ""} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{user?.name || "未登录"}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 truncate">
|
||||
{user?.roles?.map((r: {code: string; name: string}) => r.name).join("、") || ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<HealthDot />
|
||||
<span className="text-[11px]">API</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-[11px] text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
469
platform/web/src/app/hsap-api.ts
Normal file
469
platform/web/src/app/hsap-api.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import type { AuthUser, PagedResult, LabelingBatchRow, BatchDelivery } from "@/lib/types";
|
||||
export type { AuthUser };
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
let _token: string | null =
|
||||
typeof localStorage !== "undefined"
|
||||
? localStorage.getItem("as_access_token")
|
||||
: null;
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (_token) h.Authorization = `Bearer ${_token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
export class HsapApiError extends Error {
|
||||
status: number;
|
||||
detail?: unknown;
|
||||
constructor(status: number, message: string, detail?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let detail: unknown;
|
||||
try { detail = JSON.parse(text); } catch { /* ignore */ }
|
||||
throw new HsapApiError(res.status, text || res.statusText, detail);
|
||||
}
|
||||
return JSON.parse(text || "{}") as T;
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||
return fetchJson<T>(url, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
}
|
||||
|
||||
async function putJson<T>(url: string, body?: unknown): Promise<T> {
|
||||
return fetchJson<T>(url, { method: "PUT", body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
}
|
||||
|
||||
async function patchJson<T>(url: string, body?: unknown): Promise<T> {
|
||||
return fetchJson<T>(url, { method: "PATCH", body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
}
|
||||
|
||||
async function deleteJson<T>(url: string): Promise<T> {
|
||||
return fetchJson<T>(url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export const hsapApi = {
|
||||
setToken(token: string | null) {
|
||||
_token = token;
|
||||
if (token) localStorage.setItem("as_access_token", token);
|
||||
else localStorage.removeItem("as_access_token");
|
||||
},
|
||||
|
||||
getToken: () => _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; database: string }>(`${API_BASE}/api/v1/health`),
|
||||
|
||||
// ── Labeling ──
|
||||
labelingBatches: (opts?: { stage?: string; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.stage) p.set("stage", opts.stage);
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
const q = p.toString();
|
||||
return fetchJson<PagedResult<LabelingBatchRow>>(`${API_BASE}/api/v1/labeling/batches${q ? `?${q}` : ""}`);
|
||||
},
|
||||
|
||||
openLabelingCampaign: (body: { project: string; task: string; batch: string; mode?: string | null; pack?: string | null; location?: string }) =>
|
||||
postJson(`${API_BASE}/api/v1/labeling/campaigns/open`, body),
|
||||
|
||||
labelingAssignees: () =>
|
||||
fetchJson<{ items: { id: number; name: string; roles: string[] }[] }>(`${API_BASE}/api/v1/labeling/assignees`),
|
||||
|
||||
assignLabelingCampaign: (campaignId: string, userId: number | null) =>
|
||||
patchJson(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/assign`, { user_id: userId }),
|
||||
|
||||
getLabelingCampaign: (id: string) =>
|
||||
fetchJson<LabelingBatchRow & { config_xml?: string }>(`${API_BASE}/api/v1/labeling/campaigns/${id}`),
|
||||
|
||||
labelingBootstrap: (campaignId: string) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/bootstrap`),
|
||||
|
||||
campaignProgress: (campaignId: string) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/progress`),
|
||||
|
||||
labelingTasks: (campaignId: string, offset = 0, limit = 50, opts?: { assignee?: "me" | "all" }) => {
|
||||
const capped = Math.min(Math.max(1, limit), 100);
|
||||
const q = new URLSearchParams({ offset: String(offset), limit: String(capped) });
|
||||
if (opts?.assignee === "me") q.set("assignee", "me");
|
||||
if (opts?.assignee === "all") q.set("assignee", "all");
|
||||
return fetchJson<{ tasks: { id: string; data: { image: string } }[]; total: number; hint?: string; my_assigned?: number }>(
|
||||
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks?${q}`,
|
||||
);
|
||||
},
|
||||
|
||||
getAnnotation: (campaignId: string, taskId: string) =>
|
||||
fetchJson<{ task_id: string; result?: unknown }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/annotations/${taskId}`),
|
||||
|
||||
saveAnnotation: (campaignId: string, taskId: string, body: { result?: unknown; annotations?: unknown[] }) =>
|
||||
putJson(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/annotations/${taskId}`, body),
|
||||
|
||||
labelingExport: (campaignId: string) =>
|
||||
postJson<{ ok: boolean; job?: { id: string } }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export`),
|
||||
|
||||
submitLabelingCampaign: (campaignId: string) =>
|
||||
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/submit`),
|
||||
|
||||
importVendorZip: (campaignId: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const h: Record<string, string> = {};
|
||||
if (_token) h.Authorization = `Bearer ${_token}`;
|
||||
return fetch(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/import-vendor`, {
|
||||
method: "POST", headers: h, body: form,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
});
|
||||
},
|
||||
|
||||
listCampaignExportJobs: (campaignId: string, limit = 20) =>
|
||||
fetchJson<{ items: Record<string, unknown>[] }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export-jobs?limit=${limit}`),
|
||||
|
||||
labelingRegistryProfiles: () =>
|
||||
fetchJson<{ profiles: Record<string, unknown>[] }>(`${API_BASE}/api/v1/labeling/registry-profiles`),
|
||||
|
||||
labelingAcquireLock: (campaignId: string, taskId: string) =>
|
||||
postJson<{ ok: boolean; holder?: string }>(
|
||||
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock`,
|
||||
),
|
||||
|
||||
labelingReleaseLock: (campaignId: string, taskId: string) =>
|
||||
deleteJson<{ ok: boolean }>(
|
||||
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock`,
|
||||
),
|
||||
|
||||
labelingReleaseLockKeepalive(campaignId: string, taskId: string) {
|
||||
const url = `${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock`;
|
||||
fetch(url, { method: "DELETE", headers: authHeaders(), keepalive: true }).catch(() => {});
|
||||
},
|
||||
|
||||
labelingRenewLock: (campaignId: string, taskId: string) =>
|
||||
postJson<{ ok: boolean }>(
|
||||
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock/renew`,
|
||||
),
|
||||
|
||||
labelingTasksPageLimit: 100,
|
||||
|
||||
async labelingTasksAll(
|
||||
campaignId: string,
|
||||
opts?: { assignee?: "me" | "all" },
|
||||
): Promise<{ tasks: { id: string; data: { image: string } }[]; total: number; hint?: string; my_assigned?: number }> {
|
||||
const page = 100;
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
let hint: string | undefined;
|
||||
let myAssigned: number | undefined;
|
||||
const tasks: { id: string; data: { image: string } }[] = [];
|
||||
for (;;) {
|
||||
const res = await this.labelingTasks(campaignId, offset, page, opts);
|
||||
tasks.push(...(res.tasks || []));
|
||||
total = res.total;
|
||||
hint = res.hint ?? hint;
|
||||
myAssigned = res.my_assigned ?? myAssigned;
|
||||
offset += page;
|
||||
if (tasks.length >= total || !(res.tasks?.length)) break;
|
||||
}
|
||||
return { tasks, total, hint, my_assigned: myAssigned };
|
||||
},
|
||||
|
||||
// ── Pending / Catalog ──
|
||||
pending: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/pending`),
|
||||
pendingGates: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/pending/gates`),
|
||||
catalog: (refresh = false) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/catalog${refresh ? "?refresh=true" : ""}`),
|
||||
catalogDms: (task: string, refresh = false) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/catalog/dms/${encodeURIComponent(task)}${refresh ? "?refresh=true" : ""}`),
|
||||
|
||||
// ── Data upload ──
|
||||
uploadDatasetFile: (file: File, project: string, task?: string, mode?: string, onProgress?: (n: number) => void) => {
|
||||
const formData = new FormData();
|
||||
formData.append("project", project);
|
||||
if (task) formData.append("task", task);
|
||||
if (mode) formData.append("mode", mode);
|
||||
formData.append("file", file);
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${API_BASE}/api/v1/data/upload/file`, true);
|
||||
if (_token) xhr.setRequestHeader("Authorization", `Bearer ${_token}`);
|
||||
xhr.upload.onprogress = (evt) => {
|
||||
if (!onProgress || !evt.lengthComputable) return;
|
||||
onProgress(Math.round((evt.loaded / evt.total) * 100));
|
||||
};
|
||||
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 { resolve(JSON.parse(xhr.responseText || "{}")); } catch { reject(new Error("上传响应解析失败")); }
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
|
||||
listDataCandidates: (opts?: { offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
p.set("limit", String(opts?.limit ?? 20));
|
||||
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/data/candidates?${p}`);
|
||||
},
|
||||
|
||||
promoteCandidateInbox: (candidateId: string, body?: { batch?: string; mode?: string }) =>
|
||||
postJson<{ ok: boolean; inbox_path?: string }>(`${API_BASE}/api/v1/data/candidates/${encodeURIComponent(candidateId)}/promote-inbox`, body || {}),
|
||||
|
||||
registryTasks: (project = "dms") =>
|
||||
fetchJson<{ project: string; tasks: Record<string, unknown> }>(`${API_BASE}/api/v1/data/registry-tasks?project=${encodeURIComponent(project)}`),
|
||||
|
||||
scanInbox: (project = "dms") =>
|
||||
fetchJson<{ project: string; items: Record<string, unknown>[]; inbox_path: string }>(`${API_BASE}/api/v1/data/scan-inbox?project=${encodeURIComponent(project)}`),
|
||||
|
||||
registerBatch: (body: Record<string, unknown>) => postJson(`${API_BASE}/api/v1/register-batch`, body),
|
||||
|
||||
// ── Deliveries ──
|
||||
listDeliveries: (opts?: { status?: string; mine?: boolean; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.status) p.set("status", opts.status);
|
||||
if (opts?.mine) p.set("mine", "1");
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
return fetchJson<PagedResult<BatchDelivery>>(`${API_BASE}/api/v1/deliveries?${p}`);
|
||||
},
|
||||
|
||||
getDelivery: (id: string) => fetchJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}`),
|
||||
|
||||
createDelivery: (body: Record<string, unknown>) => postJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries`, body),
|
||||
|
||||
patchDelivery: (id: string, body: Record<string, unknown>) =>
|
||||
patchJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}`, body),
|
||||
|
||||
submitDelivery: (id: string) =>
|
||||
postJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}/submit`),
|
||||
|
||||
deleteDelivery: (id: string) => deleteJson<{ ok: boolean }>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}`),
|
||||
|
||||
retryDeliveryIngest: (id: string) =>
|
||||
postJson<{ ok: boolean; job_id?: string }>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}/retry-ingest`),
|
||||
|
||||
// ── Audit ──
|
||||
listActions: () => fetchJson<{ actions: { id: string; label: string }[] }>(`${API_BASE}/api/v1/actions`),
|
||||
|
||||
listApprovals: (opts?: { status?: string; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.status) p.set("status", opts.status);
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/approvals?${p}`);
|
||||
},
|
||||
|
||||
getApproval: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/approvals/${id}`),
|
||||
getApprovalPreview: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/approvals/${id}/preview`),
|
||||
|
||||
listApprovalImages: (id: string, offset = 0, limit = 60) =>
|
||||
fetchJson<{ total: number; items: { id: string }[] }>(`${API_BASE}/api/v1/approvals/${id}/images?offset=${offset}&limit=${limit}`),
|
||||
|
||||
fetchApprovalImageBlob: async (approvalId: string, imageId: string, thumb = true) => {
|
||||
const q = thumb ? "?thumb=true" : "?thumb=false";
|
||||
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/images/${imageId}${q}`, { headers: authHeaders(), cache: "no-store" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return URL.createObjectURL(await res.blob());
|
||||
},
|
||||
|
||||
approvalRejectionCategories: () =>
|
||||
fetchJson<{ categories: { key: string; label: string }[] }>(`${API_BASE}/api/v1/system/audit/rejection-categories`),
|
||||
|
||||
approve: (id: string, comment?: string) => postJson(`${API_BASE}/api/v1/system/audit/${id}/approve`, { comment }),
|
||||
reject: (id: string, comment?: string, rejectionCategory?: string) =>
|
||||
postJson(`${API_BASE}/api/v1/system/audit/${id}/reject`, { comment, rejection_category: rejectionCategory || "" }),
|
||||
|
||||
batchApprove: (ids: string[]) =>
|
||||
postJson(`${API_BASE}/api/v1/system/audit/batch-approve`, { ids }),
|
||||
batchReject: (ids: string[], comment?: string, rejectionCategory?: string) =>
|
||||
postJson(`${API_BASE}/api/v1/system/audit/batch-reject`, { ids, comment, rejection_category: rejectionCategory || "" }),
|
||||
submitApproval: (action: string, params: Record<string, unknown>, note?: string) =>
|
||||
postJson(`${API_BASE}/api/v1/system/audit/submit`, { action, params, note }),
|
||||
submitBuildBatch: (body: Record<string, unknown>) => postJson(`${API_BASE}/api/v1/system/audit/submit-build-batch`, body),
|
||||
|
||||
// ── Jobs ──
|
||||
listJobs: (opts?: { status?: string; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.status) p.set("status", opts.status);
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/jobs?${p}`).catch(() => ({ items: [], total: 0 }));
|
||||
},
|
||||
|
||||
getJob: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/jobs/${id}`),
|
||||
|
||||
// ── Training / Models ──
|
||||
listTrainingRecords: (opts?: { project?: string; status?: string; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.project) p.set("project", opts.project);
|
||||
if (opts?.status) p.set("status", opts.status);
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
const q = p.toString();
|
||||
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/models/records${q ? `?${q}` : ""}`);
|
||||
},
|
||||
|
||||
getTrainingRecord: (jobId: string) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/records/${encodeURIComponent(jobId)}`),
|
||||
|
||||
getModelRegistry: (project = "dms", task?: string) => {
|
||||
const p = new URLSearchParams({ project });
|
||||
if (task) p.set("task", task);
|
||||
return fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/registry?${p}`);
|
||||
},
|
||||
|
||||
createTrainingRecord: (action: string, params: Record<string, unknown>, note?: string) =>
|
||||
postJson(`${API_BASE}/api/v1/models/records`, { action, params, note }),
|
||||
|
||||
trainingActions: () =>
|
||||
fetchJson<{ actions: { id: string; label: string }[] }>(`${API_BASE}/api/v1/models/actions`),
|
||||
|
||||
// ── Fleet ──
|
||||
fleetMapConfig: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/map-config`),
|
||||
fleetLive: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/live`),
|
||||
fleetSummary: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/summary`),
|
||||
fleetVehicles: () => fetchJson<{ items: Record<string, unknown>[] }>(`${API_BASE}/api/v1/fleet/vehicles`),
|
||||
|
||||
fleetCreateVehicle: (body: { plate_no: string; tbox_device_id: string; name?: string; team?: string }) =>
|
||||
postJson(`${API_BASE}/api/v1/fleet/vehicles`, body),
|
||||
|
||||
fleetUpdateVehicle: (id: number, body: Record<string, unknown>) =>
|
||||
patchJson(`${API_BASE}/api/v1/fleet/vehicles/${id}`, body),
|
||||
|
||||
fleetDeleteVehicle: (id: number) => deleteJson(`${API_BASE}/api/v1/fleet/vehicles/${id}`),
|
||||
|
||||
fleetRuns: (opts?: { vehicle_id?: number; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.vehicle_id != null) p.set("vehicle_id", String(opts.vehicle_id));
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/fleet/runs?${p}`);
|
||||
},
|
||||
|
||||
fleetRunDetail: (runId: number) =>
|
||||
fetchJson<{ run: Record<string, unknown>; vehicle: Record<string, unknown> | null; milestones: Record<string, unknown>[] }>(
|
||||
`${API_BASE}/api/v1/fleet/runs/${runId}`),
|
||||
|
||||
fleetRunTrack: (runId: number) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/runs/${runId}/track`),
|
||||
|
||||
fleetImportGpx: (vehicleId: number, file: File, note?: string) => {
|
||||
const form = new FormData();
|
||||
form.append("vehicle_id", String(vehicleId));
|
||||
form.append("file", file);
|
||||
if (note) form.append("note", note);
|
||||
const h: Record<string, string> = {};
|
||||
if (_token) h.Authorization = `Bearer ${_token}`;
|
||||
return fetch(`${API_BASE}/api/v1/fleet/runs/import-gpx`, { method: "POST", headers: h, body: form })
|
||||
.then(async (res) => { if (!res.ok) throw new Error(await res.text()); return res.json(); });
|
||||
},
|
||||
|
||||
fleetImportCsv: (vehicleId: number, file: File, opts?: { note?: string; project?: string; batch?: string }) => {
|
||||
const form = new FormData();
|
||||
form.append("vehicle_id", String(vehicleId));
|
||||
form.append("file", file);
|
||||
if (opts?.note) form.append("note", opts.note);
|
||||
if (opts?.project) form.append("project", opts.project);
|
||||
if (opts?.batch) form.append("batch", opts.batch);
|
||||
const h: Record<string, string> = {};
|
||||
if (_token) h.Authorization = `Bearer ${_token}`;
|
||||
return fetch(`${API_BASE}/api/v1/fleet/runs/import-csv`, { method: "POST", headers: h, body: form })
|
||||
.then(async (res) => { if (!res.ok) throw new Error(await res.text()); return res.json(); });
|
||||
},
|
||||
|
||||
fleetReseedDemo: () => postJson(`${API_BASE}/api/v1/fleet/mock/reseed`),
|
||||
|
||||
// ── Traces / Agents ──
|
||||
listTraces: (limit = 50) =>
|
||||
fetchJson<{ trace_ids: string[] }>(`${API_BASE}/api/v1/traces?limit=${limit}`),
|
||||
|
||||
getTrace: (traceId: string) =>
|
||||
fetchJson<{ trace_id: string; spans: Record<string, unknown>[] }>(`${API_BASE}/api/v1/traces/${traceId}`),
|
||||
|
||||
listTools: () =>
|
||||
fetchJson<{ tools: string[] }>(`${API_BASE}/api/v1/agents/tools`),
|
||||
|
||||
invokeAgent: (graph: string, params: Record<string, unknown>) =>
|
||||
postJson(`${API_BASE}/api/v1/agents/invoke`, { graph, params }),
|
||||
|
||||
// ── User management ──
|
||||
listUsers: (opts?: { search?: string; role?: string; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.search) p.set("search", opts.search);
|
||||
if (opts?.role) p.set("role", opts.role);
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
p.set("limit", String(opts?.limit ?? 20));
|
||||
const q = p.toString();
|
||||
return fetchJson<{ items: Record<string, unknown>[]; total: number }>(`${API_BASE}/api/v1/auth/users${q ? `?${q}` : ""}`);
|
||||
},
|
||||
|
||||
setUserRoles: (userId: number, roles: string[]) =>
|
||||
putJson(`${API_BASE}/api/v1/auth/users/${userId}/roles`, { roles }),
|
||||
|
||||
syncFeishuUsers: () =>
|
||||
postJson<{ ok: boolean; created: number; updated: number; total: number }>(`${API_BASE}/api/v1/system/feishu/sync-users`),
|
||||
|
||||
// ── Dataset Versions ──
|
||||
listDatasetVersions: (project = "dms") =>
|
||||
fetchJson<{ items: Record<string, unknown>[] }>(`${API_BASE}/api/v1/models/datasets?project=${encodeURIComponent(project)}`),
|
||||
|
||||
createDatasetSnapshot: (project: string, description: string) =>
|
||||
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/datasets/snapshot`, { project, description }),
|
||||
|
||||
getDatasetVersion: (versionId: string, project = "dms") =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/datasets/${encodeURIComponent(versionId)}?project=${encodeURIComponent(project)}`),
|
||||
|
||||
diffDatasetVersions: (v1: string, v2: string, project = "dms") =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/datasets/${encodeURIComponent(v2)}/diff?compare=${encodeURIComponent(v1)}&project=${encodeURIComponent(project)}`),
|
||||
};
|
||||
|
||||
export function mediaUrl(path: string): string {
|
||||
if (path.startsWith("http") || path.startsWith("/api/")) {
|
||||
return path.startsWith("http") ? path : `${API_BASE || ""}${path}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function fetchLabelingMediaBlob(imageApiPath: string): Promise<string> {
|
||||
const url = mediaUrl(imageApiPath);
|
||||
const res = await fetch(url, { headers: authHeaders(), cache: "no-store" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
|
||||
export function formatLockConflict(err: unknown): string {
|
||||
if (err instanceof HsapApiError && err.status === 409) {
|
||||
const raw = err.detail as Record<string, unknown> | undefined;
|
||||
const inner = (raw?.detail as Record<string, unknown> | undefined) ?? raw;
|
||||
const holder = inner?.holder as string | undefined;
|
||||
return holder ? `该图正由 ${holder} 标注中` : "该图片正被其他用户标注";
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export function hasPermission(user: { permissions: string[] } | null, perm: string): boolean {
|
||||
if (!user) return false;
|
||||
if (user.permissions.includes("*")) return true;
|
||||
return user.permissions.includes(perm);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
36
platform/web/src/components/BboxScatter.tsx
Normal file
36
platform/web/src/components/BboxScatter.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { CHART_EMPTY_HINT } from "@/lib/chartStats";
|
||||
|
||||
type Props = { points: [number, number][]; size?: number; maxPoints?: number; compact?: boolean };
|
||||
|
||||
export const BboxScatter: React.FC<Props> = ({ points, size = 220, maxPoints = 800, compact = false }) => {
|
||||
const sample = points.length > maxPoints ? points.slice(0, maxPoints) : points;
|
||||
if (!sample.length) return <p className="text-sm text-gray-400 m-0">{CHART_EMPTY_HINT}</p>;
|
||||
|
||||
const pad = compact ? 22 : 28;
|
||||
const w = size, h = size;
|
||||
const plotW = w - pad * 2, plotH = h - pad * 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
||||
<rect x={pad} y={pad} width={plotW} height={plotH} fill="none" stroke="#d1d5db" strokeOpacity={0.3} />
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={pad + t * plotW} y1={pad} x2={pad + t * plotW} y2={pad + plotH} stroke="#d1d5db" strokeOpacity={0.08} />
|
||||
<line x1={pad} y1={pad + (1 - t) * plotH} x2={pad + plotW} y2={pad + (1 - t) * plotH} stroke="#d1d5db" strokeOpacity={0.08} />
|
||||
</g>
|
||||
))}
|
||||
{sample.map(([bw, bh], i) => (
|
||||
<circle key={i} cx={pad + Math.min(1, Math.max(0, bw)) * plotW} cy={pad + (1 - Math.min(1, Math.max(0, bh))) * plotH}
|
||||
r={1.8} fill="#2563eb" fillOpacity={0.55} />
|
||||
))}
|
||||
<text x={w / 2} y={h - 6} textAnchor="middle" className="fill-gray-400 text-[10px]">宽 w</text>
|
||||
<text x={10} y={h / 2} textAnchor="middle" transform={`rotate(-90 10 ${h / 2})`} className="fill-gray-400 text-[10px]">高 h</text>
|
||||
</svg>
|
||||
{!compact && points.length > maxPoints && (
|
||||
<p className="text-sm text-gray-400 m-0">采样展示 {maxPoints} / {points.length} 点</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
platform/web/src/components/ClassCountBars.tsx
Normal file
26
platform/web/src/components/ClassCountBars.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = { counts: Record<string, number>; maxBars?: number; compact?: boolean };
|
||||
|
||||
export const ClassCountBars: React.FC<Props> = ({ counts, maxBars = 12, compact = false }) => {
|
||||
const entries = Object.entries(counts || {})
|
||||
.filter(([, n]) => n > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, maxBars);
|
||||
if (!entries.length) return <p className="text-sm text-gray-400">暂无类别统计</p>;
|
||||
const max = Math.max(...entries.map(([, n]) => n), 1);
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-1" : "space-y-2"}>
|
||||
{entries.map(([name, n]) => (
|
||||
<div key={name} className={`flex items-center gap-2 ${compact ? "text-[11px]" : "text-sm"}`}>
|
||||
<span className={`truncate font-mono text-gray-500 shrink-0 ${compact ? "w-24" : "w-28"}`} title={name}>{name}</span>
|
||||
<div className={`flex-1 min-w-0 rounded-full bg-gray-100 overflow-hidden ${compact ? "h-2.5" : "h-2"}`}>
|
||||
<div className="h-full rounded-full bg-blue-700" style={{ width: `${Math.round((n / max) * 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-right tabular-nums shrink-0 ${compact ? "w-10" : "w-12"}`}>{n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
platform/web/src/components/ClassCountPie.tsx
Normal file
54
platform/web/src/components/ClassCountPie.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { CHART_EMPTY_HINT, pieSlices } from "@/lib/chartStats";
|
||||
|
||||
type Props = { counts: Record<string, number>; size?: number };
|
||||
|
||||
type ArcSlice = { name: string; value: number; pct: number; d: string };
|
||||
type PieTip = { name: string; value: number; pct: number; x: number; y: number };
|
||||
|
||||
const COLORS = ["#2563eb", "#3b82f6", "#60a5fa", "#93c5fd", "#1d4ed8", "#1e40af", "#6366f1", "#818cf8", "#94a3b8"];
|
||||
|
||||
export const ClassCountPie: React.FC<Props> = ({ counts, size = 200 }) => {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [tip, setTip] = useState<PieTip | null>(null);
|
||||
|
||||
const entries = Object.entries(counts || {}).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]);
|
||||
const slices = pieSlices(entries);
|
||||
if (!slices.length) return <p className="text-sm text-gray-400 m-0">{CHART_EMPTY_HINT}</p>;
|
||||
|
||||
const cx = size / 2, cy = size / 2, r = size * 0.38, ir = r * 0.45;
|
||||
let angle = -Math.PI / 2;
|
||||
|
||||
const arcs: ArcSlice[] = slices.map((s) => {
|
||||
const sweep = s.pct * 2 * Math.PI;
|
||||
const x1 = cx + r * Math.cos(angle), y1 = cy + r * Math.sin(angle);
|
||||
const x2 = cx + r * Math.cos(angle + sweep), y2 = cy + r * Math.sin(angle + sweep);
|
||||
const xi1 = cx + ir * Math.cos(angle + sweep), yi1 = cy + ir * Math.sin(angle + sweep);
|
||||
const xi2 = cx + ir * Math.cos(angle), yi2 = cy + ir * Math.sin(angle);
|
||||
const large = sweep > Math.PI ? 1 : 0;
|
||||
const d = [`M ${x1} ${y1}`, `A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`, `L ${xi1} ${yi1}`, `A ${ir} ${ir} 0 ${large} 0 ${xi2} ${yi2}`, "Z"].join(" ");
|
||||
angle += sweep;
|
||||
return { ...s, d };
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative flex items-center justify-center" onMouseLeave={() => setTip(null)}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
|
||||
{arcs.map((a, i) => (
|
||||
<path key={a.name} d={a.d} fill={COLORS[i % COLORS.length]} stroke="#fff" strokeWidth={1}
|
||||
className="cursor-pointer transition-opacity" style={{ opacity: tip && tip.name !== a.name ? 0.72 : 1 }}
|
||||
onMouseEnter={(e) => { const b = wrapRef.current?.getBoundingClientRect(); if (b) setTip({ ...a, x: e.clientX - b.left, y: e.clientY - b.top }); }}
|
||||
onMouseMove={(e) => { const b = wrapRef.current?.getBoundingClientRect(); if (b) setTip({ ...a, x: e.clientX - b.left, y: e.clientY - b.top }); }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{tip && (
|
||||
<div className="pointer-events-none absolute z-20 rounded border border-gray-200 bg-white px-2 py-1 shadow-md text-[11px] leading-tight whitespace-nowrap"
|
||||
style={{ left: tip.x, top: tip.y - 8, transform: "translate(-50%, -100%)" }}>
|
||||
<span className="font-mono font-semibold">{tip.name}</span>
|
||||
<span className="text-gray-500 ml-1.5 tabular-nums">{tip.value} · {Math.round(tip.pct * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
platform/web/src/components/ClassCountRadar.tsx
Normal file
46
platform/web/src/components/ClassCountRadar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = { counts: Record<string, number>; size?: number; compact?: boolean };
|
||||
|
||||
export const ClassCountRadar: React.FC<Props> = ({ counts, size = 220, compact = false }) => {
|
||||
const entries = Object.entries(counts || {}).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||||
if (!entries.length) return null;
|
||||
|
||||
const max = Math.max(...entries.map(([, n]) => n), 1);
|
||||
const cx = size / 2, cy = size / 2, R = size * 0.38, n = entries.length;
|
||||
const angleStep = (2 * Math.PI) / n;
|
||||
|
||||
const points = entries.map(([, v], i) => {
|
||||
const a = -Math.PI / 2 + i * angleStep;
|
||||
const r = (v / max) * R;
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||
});
|
||||
const poly = points.map((p) => p.join(",")).join(" ");
|
||||
|
||||
const gridLevels = [0.25, 0.5, 0.75, 1];
|
||||
const grids = gridLevels.map((lv) => {
|
||||
const gr = lv * R;
|
||||
return entries.map((_, i) => {
|
||||
const a = -Math.PI / 2 + i * angleStep;
|
||||
return [cx + gr * Math.cos(a), cy + gr * Math.sin(a)].join(",");
|
||||
}).join(" ");
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{grids.map((gp, i) => <polygon key={i} points={gp} fill="none" stroke="#d1d5db" strokeOpacity={0.3} />)}
|
||||
{entries.map((_, i) => {
|
||||
const a = -Math.PI / 2 + i * angleStep;
|
||||
return <line key={i} x1={cx} y1={cy} x2={cx + R * Math.cos(a)} y2={cy + R * Math.sin(a)} stroke="#d1d5db" strokeOpacity={0.2} />;
|
||||
})}
|
||||
<polygon points={poly} fill="rgba(37,99,235,0.25)" stroke="#2563eb" strokeWidth={2} />
|
||||
</svg>
|
||||
{!compact && (
|
||||
<div className="flex flex-wrap gap-1 justify-center max-w-xs">
|
||||
{entries.map(([name]) => <span key={name} className="text-xs font-mono text-gray-500 truncate max-w-[5rem]" title={name}>{name}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
58
platform/web/src/components/ListPaginationBar.tsx
Normal file
58
platform/web/src/components/ListPaginationBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
interface ListPaginationBarProps {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onOffsetChange: (offset: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
|
||||
export const ListPaginationBar: React.FC<ListPaginationBarProps> = ({
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
onOffsetChange,
|
||||
onLimitChange,
|
||||
}) => {
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
const start = total === 0 ? 0 : offset + 1;
|
||||
const end = Math.min(offset + limit, total);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-3 text-sm text-gray-500">
|
||||
<div>
|
||||
共 {total} 条,显示 {start}–{end}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{s}条/页</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
|
||||
className="px-2 py-1 border border-gray-300 rounded disabled:opacity-30 hover:bg-gray-50"
|
||||
>
|
||||
‹ 上一页
|
||||
</button>
|
||||
<span className="text-xs">{page}/{totalPages}</span>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onOffsetChange(offset + limit)}
|
||||
className="px-2 py-1 border border-gray-300 rounded disabled:opacity-30 hover:bg-gray-50"
|
||||
>
|
||||
下一页 ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
platform/web/src/components/ModuleGuard.tsx
Normal file
25
platform/web/src/components/ModuleGuard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ModuleGuardProps {
|
||||
requiredPerms: string[];
|
||||
fallback?: "redirect" | "message";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ModuleGuard = ({ requiredPerms, fallback = "redirect", children }: ModuleGuardProps) => {
|
||||
const { hasPermission } = useAuth();
|
||||
const hasAccess = requiredPerms.some((p) => hasPermission(p));
|
||||
if (!hasAccess) {
|
||||
if (fallback === "redirect") return <Redirect to="/" />;
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-500 text-lg">您没有访问此模块的权限</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
43
platform/web/src/components/PageQueryState.tsx
Normal file
43
platform/web/src/components/PageQueryState.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
|
||||
interface PageQueryStateProps {
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
empty?: boolean;
|
||||
emptyMessage?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageQueryState: React.FC<PageQueryStateProps> = ({
|
||||
loading,
|
||||
error,
|
||||
empty,
|
||||
emptyMessage = "暂无数据",
|
||||
children,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-gray-400 text-sm animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-red-500 text-sm">加载失败: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-gray-400 text-sm">{emptyMessage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
32
platform/web/src/components/SplitCountsBars.tsx
Normal file
32
platform/web/src/components/SplitCountsBars.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { CHART_EMPTY_HINT } from "@/lib/chartStats";
|
||||
import type { SplitCounts } from "@/lib/dmsCatalog";
|
||||
|
||||
type Props = { counts: SplitCounts; height?: number };
|
||||
|
||||
const LABELS: { key: keyof SplitCounts; label: string }[] = [
|
||||
{ key: "train", label: "train" }, { key: "val", label: "val" }, { key: "test", label: "test" },
|
||||
];
|
||||
|
||||
export const SplitCountsBars: React.FC<Props> = ({ counts, height = 120 }) => {
|
||||
const total = counts.train + counts.val + counts.test;
|
||||
if (total <= 0) return <p className="text-sm text-gray-400 m-0">{CHART_EMPTY_HINT}</p>;
|
||||
|
||||
const max = Math.max(counts.train, counts.val, counts.test, 1);
|
||||
const barW = 48;
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-6 justify-center" style={{ height }}>
|
||||
{LABELS.map(({ key, label }) => {
|
||||
const n = counts[key];
|
||||
return (
|
||||
<div key={key} className="flex flex-col items-center" style={{ width: barW }}>
|
||||
<span className="text-sm tabular-nums font-semibold mb-1">{n}</span>
|
||||
<div className="w-full rounded-t bg-blue-700" style={{ height: `${Math.max(8, Math.round((n / max) * (height - 48)))}px` }} />
|
||||
<span className="text-xs text-gray-500 mt-2 font-mono">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
71
platform/web/src/components/ui/Badge.tsx
Normal file
71
platform/web/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: "default" | "success" | "warning" | "danger" | "info";
|
||||
size?: "small" | "medium";
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: "bg-gray-100 text-gray-700",
|
||||
success: "bg-green-100 text-green-800",
|
||||
warning: "bg-yellow-100 text-yellow-800",
|
||||
danger: "bg-red-100 text-red-800",
|
||||
info: "bg-blue-100 text-blue-800",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
small: "px-1.5 py-0.5 text-[10px]",
|
||||
medium: "px-2.5 py-1 text-xs",
|
||||
};
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
variant = "default",
|
||||
size = "medium",
|
||||
className = "",
|
||||
children,
|
||||
}) => {
|
||||
const cls = [
|
||||
"inline-flex items-center font-medium rounded-full whitespace-nowrap",
|
||||
variantClasses[variant] || variantClasses.default,
|
||||
sizeClasses[size],
|
||||
className,
|
||||
].join(" ");
|
||||
|
||||
return <span className={cls}>{children}</span>;
|
||||
};
|
||||
|
||||
/** Stage badge — maps batch stages to colors */
|
||||
export const StageBadge: React.FC<{ stage: string }> = ({ stage }) => {
|
||||
const map: Record<string, { variant: BadgeProps["variant"]; label: string }> = {
|
||||
raw_pool: { variant: "default", label: "待标注" },
|
||||
out_for_labeling: { variant: "info", label: "标中" },
|
||||
labeling_submitted: { variant: "warning", label: "已提交" },
|
||||
returned: { variant: "success", label: "待入库" },
|
||||
ingested: { variant: "success", label: "已入库" },
|
||||
in_review: { variant: "warning", label: "质检中" },
|
||||
review_approved: { variant: "success", label: "质检通过" },
|
||||
review_rejected: { variant: "danger", label: "质检退回" },
|
||||
};
|
||||
const m = map[stage] || { variant: "default" as const, label: stage };
|
||||
return <Badge variant={m.variant}>{m.label}</Badge>;
|
||||
};
|
||||
|
||||
/** Status badge for approvals, jobs, deliveries */
|
||||
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
const map: Record<string, { variant: BadgeProps["variant"]; label: string }> = {
|
||||
pending: { variant: "warning", label: "待审核" },
|
||||
approved: { variant: "success", label: "已通过" },
|
||||
rejected: { variant: "danger", label: "已驳回" },
|
||||
running: { variant: "info", label: "执行中" },
|
||||
completed: { variant: "success", label: "已完成" },
|
||||
failed: { variant: "danger", label: "失败" },
|
||||
draft: { variant: "default", label: "草稿" },
|
||||
submitted: { variant: "info", label: "已提交" },
|
||||
ingested: { variant: "success", label: "已入湖" },
|
||||
cancelled: { variant: "default", label: "已取消" },
|
||||
};
|
||||
const m = map[status] || { variant: "default" as const, label: status };
|
||||
return <Badge variant={m.variant}>{m.label}</Badge>;
|
||||
};
|
||||
55
platform/web/src/components/ui/Button.tsx
Normal file
55
platform/web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "default" | "danger" | "success" | "ghost";
|
||||
type ButtonSize = "small" | "medium" | "large";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
look?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: "bg-blue-700 text-white hover:bg-blue-800 active:bg-blue-900",
|
||||
default: "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 active:bg-gray-100",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800",
|
||||
success: "bg-green-600 text-white hover:bg-green-700 active:bg-green-800",
|
||||
ghost: "bg-transparent text-gray-600 hover:bg-gray-100 active:bg-gray-200",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
small: "px-3 py-1.5 text-xs rounded",
|
||||
medium: "px-4 py-2 text-sm rounded-md",
|
||||
large: "px-6 py-3 text-base rounded-md",
|
||||
};
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = "default",
|
||||
size = "medium",
|
||||
look,
|
||||
loading,
|
||||
className = "",
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
// Map legacy "look" prop to variant
|
||||
const resolvedVariant = (look === "primary" ? "primary" : look === "danger" ? "danger" : variant) as ButtonVariant;
|
||||
|
||||
const cls = [
|
||||
"inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
variantClasses[resolvedVariant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<button className={cls} disabled={disabled || loading} {...props}>
|
||||
{loading && (
|
||||
<span className="mr-2 inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
44
platform/web/src/components/ui/Userpic.tsx
Normal file
44
platform/web/src/components/ui/Userpic.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
interface UserpicProps {
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Userpic: React.FC<UserpicProps> = ({
|
||||
username = "",
|
||||
avatarUrl,
|
||||
size = 32,
|
||||
className = "",
|
||||
}) => {
|
||||
const initials = username
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() || "")
|
||||
.join("");
|
||||
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={username}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full bg-blue-100 text-blue-800 font-semibold ${className}`}
|
||||
style={{ width: size, height: size, fontSize: size * 0.4 }}
|
||||
>
|
||||
{initials || "?"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
/** 车道线数据可视化(暂时关闭;恢复时改为 import.meta.env.VITE_LANE_DATA_VIZ === "1") */
|
||||
export const LANE_DATA_VIZ_ENABLED = false;
|
||||
30
platform/web/src/lib/chartStats.ts
Normal file
30
platform/web/src/lib/chartStats.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export const CHART_EMPTY_HINT = "暂无统计,请点刷新重建 catalog 缓存";
|
||||
|
||||
export function sortedCountEntries(counts: Record<string, number>, max = 12): [string, number][] {
|
||||
return Object.entries(counts || {})
|
||||
.filter(([, n]) => n > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
export function pieSlices(entries: [string, number][]): { name: string; value: number; pct: number }[] {
|
||||
const total = entries.reduce((s, [, n]) => s + n, 0);
|
||||
if (total <= 0) return [];
|
||||
return entries.map(([name, value]) => ({ name, value, pct: value / total }));
|
||||
}
|
||||
|
||||
export function buildDensityGrid(
|
||||
points: [number, number][],
|
||||
bins = 16,
|
||||
): { grid: number[][]; max: number } {
|
||||
const grid = Array.from({ length: bins }, () => Array(bins).fill(0));
|
||||
let max = 0;
|
||||
for (const [w, h] of points) {
|
||||
if (w == null || h == null || Number.isNaN(w) || Number.isNaN(h)) continue;
|
||||
const wi = Math.min(bins - 1, Math.max(0, Math.floor(w * bins)));
|
||||
const hi = Math.min(bins - 1, Math.max(0, Math.floor(h * bins)));
|
||||
grid[hi][wi] += 1;
|
||||
max = Math.max(max, grid[hi][wi]);
|
||||
}
|
||||
return { grid, max };
|
||||
}
|
||||
259
platform/web/src/lib/dmsCatalog.ts
Normal file
259
platform/web/src/lib/dmsCatalog.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// Data catalog types and utilities — ported from Label Studio hsap-platform
|
||||
export type CatalogReport = Record<string, unknown> & {
|
||||
dms?: Record<string, DmsTaskEntry>;
|
||||
lane?: Record<string, Record<string, unknown>>;
|
||||
projects?: { dms?: { active_packs?: string[] }; lane?: { active_packs?: string[] } };
|
||||
_cache?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DmsTaskEntry = {
|
||||
type?: string; domain?: string; domain_label?: string; label?: string; nc?: number;
|
||||
modes?: Record<string, { label?: string; packs?: DmsPackRow[]; class_counts?: Record<string, number> }>;
|
||||
packs?: DmsPackRow[]; class_counts?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type DmsPackRow = {
|
||||
name: string; enabled: boolean;
|
||||
train_images?: number; val_images?: number; test_images?: number;
|
||||
class_counts?: Record<string, number>; bbox_points?: [number, number][];
|
||||
total_boxes?: number; label_files?: number; sampled?: boolean;
|
||||
};
|
||||
|
||||
export type SplitCounts = { train: number; val: number; test: number };
|
||||
|
||||
export function aggregateSplitCounts(packs: DmsPackRow[]): SplitCounts {
|
||||
return packs.reduce((acc, p) => ({
|
||||
train: acc.train + (p.train_images || 0),
|
||||
val: acc.val + (p.val_images || 0),
|
||||
test: acc.test + (p.test_images || 0),
|
||||
}), { train: 0, val: 0, test: 0 });
|
||||
}
|
||||
|
||||
export function splitCountsFromPack(pack: DmsPackRow | undefined): SplitCounts {
|
||||
if (!pack) return { train: 0, val: 0, test: 0 };
|
||||
return { train: pack.train_images || 0, val: pack.val_images || 0, test: pack.test_images || 0 };
|
||||
}
|
||||
|
||||
export function aggregateBboxPoints(packs: DmsPackRow[]): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
for (const p of packs) {
|
||||
for (const pt of p.bbox_points || []) {
|
||||
if (Array.isArray(pt) && pt.length >= 2) {
|
||||
const w = Number(pt[0]), h = Number(pt[1]);
|
||||
if (!Number.isNaN(w) && !Number.isNaN(h)) out.push([w, h]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bboxPointsFromPack(pack: DmsPackRow | undefined): [number, number][] {
|
||||
if (!pack?.bbox_points?.length) return [];
|
||||
return aggregateBboxPoints([pack]);
|
||||
}
|
||||
|
||||
export function primaryPack(packs: DmsPackRow[]): DmsPackRow | undefined {
|
||||
return packs.find((p) => p.enabled) || packs[0];
|
||||
}
|
||||
|
||||
export function isMultiTask(entry: DmsTaskEntry | undefined): boolean {
|
||||
if (!entry) return false;
|
||||
if (entry.type === "multi") return true;
|
||||
return Boolean(entry.modes && Object.keys(entry.modes).length > 0);
|
||||
}
|
||||
|
||||
export function isForwardTask(_id: string, entry: DmsTaskEntry | undefined): boolean {
|
||||
return _id === "forward" || entry?.domain === "forward";
|
||||
}
|
||||
|
||||
export function dmsTaskModes(entry: DmsTaskEntry | undefined): string[] {
|
||||
if (entry?.modes && Object.keys(entry.modes).length > 0) return Object.keys(entry.modes);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function dmsPacks(entry: DmsTaskEntry | undefined, mode: string): DmsPackRow[] {
|
||||
if (!entry) return [];
|
||||
if (mode && entry.modes?.[mode]?.packs?.length) return entry.modes[mode].packs!;
|
||||
return (entry.packs || []) as DmsPackRow[];
|
||||
}
|
||||
|
||||
export function findPackRow(entry: DmsTaskEntry | undefined, packName: string, modeId?: string): DmsPackRow | undefined {
|
||||
if (!entry || !packName) return undefined;
|
||||
if (modeId && entry.modes?.[modeId]?.packs) {
|
||||
const hit = entry.modes[modeId].packs!.find((p) => p.name === packName);
|
||||
if (hit) return hit;
|
||||
}
|
||||
for (const p of entry.packs || []) { if (p.name === packName) return p; }
|
||||
for (const mode of Object.values(entry.modes || {})) {
|
||||
for (const p of mode.packs || []) { if (p.name === packName) return p; }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function packTaskKey(task: string, mode?: string): string {
|
||||
return mode ? `${task}|${mode}` : task;
|
||||
}
|
||||
|
||||
export function parsePackTaskKey(key: string): { task: string; mode: string } {
|
||||
const i = key.indexOf("|");
|
||||
if (i < 0) return { task: key, mode: "" };
|
||||
return { task: key.slice(0, i), mode: key.slice(i + 1) };
|
||||
}
|
||||
|
||||
export function shouldSplitModesInPackView(entry: DmsTaskEntry | undefined): boolean {
|
||||
if (!entry) return false;
|
||||
const modes = dmsTaskModes(entry);
|
||||
if (modes.length <= 1) return false;
|
||||
if (entry.domain === "forward" || isForwardTask("", entry)) return true;
|
||||
return modes.every((m) => !m.startsWith("batch_"));
|
||||
}
|
||||
|
||||
export const MODE_LABELS: Record<string, string> = {
|
||||
batch_0516: "0516 批次", batch_0417: "0417 批次",
|
||||
detect: "粗检测", classify: "细分类",
|
||||
};
|
||||
|
||||
export type PackTreeTask = { id: string; mode?: string; label: string; domain?: string; key: string };
|
||||
export type DmsPackTreeNode = { pack: string; enabled: boolean; tasks: PackTreeTask[] };
|
||||
export type DmsBatchTreeNode = { taskId: string; label: string; domain?: string; modes: { id: string; label: string }[] };
|
||||
|
||||
export function collectTaskPackNames(entry: DmsTaskEntry | undefined): { name: string; enabled: boolean }[] {
|
||||
if (!entry) return [];
|
||||
const seen = new Map<string, boolean>();
|
||||
const mark = (name: string, enabled: boolean) => { seen.set(name, Boolean(enabled) || Boolean(seen.get(name))); };
|
||||
for (const p of entry.packs || []) { if (p.name) mark(p.name, p.enabled); }
|
||||
for (const mode of Object.values(entry.modes || {})) {
|
||||
for (const p of mode.packs || []) { if (p.name) mark(p.name, p.enabled); }
|
||||
}
|
||||
return [...seen.entries()].map(([name, enabled]) => ({ name, enabled }))
|
||||
.sort((a, b) => { if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; return a.name.localeCompare(b.name); });
|
||||
}
|
||||
|
||||
export function packTreeTasksForEntry(taskId: string, entry: DmsTaskEntry, packName: string): PackTreeTask[] {
|
||||
const modes = dmsTaskModes(entry);
|
||||
if (!shouldSplitModesInPackView(entry)) {
|
||||
return [{ id: taskId, label: entry.label || taskId, domain: entry.domain, key: packTaskKey(taskId) }];
|
||||
}
|
||||
const rows: PackTreeTask[] = [];
|
||||
for (const m of modes) {
|
||||
const packs = entry.modes?.[m]?.packs || [];
|
||||
if (!packs.some((p) => p.name === packName)) continue;
|
||||
const modeLabel = entry.modes?.[m]?.label || MODE_LABELS[m] || m;
|
||||
const base = (entry.label || taskId).replace(/·交通标志$/, "").trim();
|
||||
rows.push({ id: taskId, mode: m, label: `${base} · ${modeLabel}`, domain: entry.domain, key: packTaskKey(taskId, m) });
|
||||
}
|
||||
return rows.length ? rows : [{ id: taskId, label: entry.label || taskId, domain: entry.domain, key: packTaskKey(taskId) }];
|
||||
}
|
||||
|
||||
export function groupDmsTasks(dms: CatalogReport["dms"]) {
|
||||
const entries = Object.entries(dms || {});
|
||||
const forward = entries.filter(([, e]) => isForwardTask("", e));
|
||||
const forwardIds = new Set(forward.map(([id]) => id));
|
||||
return { cabin: entries.filter(([id, e]) => !forwardIds.has(id) && (e.domain || "dms") === "dms"), forward };
|
||||
}
|
||||
|
||||
export function buildDmsPackTree(cat: CatalogReport | null): DmsPackTreeNode[] {
|
||||
if (!cat?.dms) return [];
|
||||
const activePacks = new Set(cat.projects?.dms?.active_packs || []);
|
||||
const byPack = new Map<string, { enabled: boolean; tasks: PackTreeTask[] }>();
|
||||
for (const [taskId, entry] of Object.entries(cat.dms)) {
|
||||
for (const { name, enabled } of collectTaskPackNames(entry)) {
|
||||
if (!byPack.has(name)) byPack.set(name, { enabled: false, tasks: [] });
|
||||
const node = byPack.get(name)!;
|
||||
node.enabled = node.enabled || Boolean(enabled) || activePacks.has(name);
|
||||
for (const row of packTreeTasksForEntry(taskId, entry, name)) {
|
||||
if (!node.tasks.some((t) => t.key === row.key)) node.tasks.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byPack.entries()].map(([pack, node]) => ({
|
||||
pack, enabled: activePacks.has(pack) || node.enabled,
|
||||
tasks: node.tasks.sort((a, b) => a.label.localeCompare(b.label, "zh")),
|
||||
})).sort((a, b) => { if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; return a.pack.localeCompare(b.pack); });
|
||||
}
|
||||
|
||||
export function buildDmsBatchTree(cat: CatalogReport | null): DmsBatchTreeNode[] {
|
||||
if (!cat?.dms) return [];
|
||||
const nodes: DmsBatchTreeNode[] = [];
|
||||
const { cabin, forward } = groupDmsTasks(cat.dms);
|
||||
for (const [taskId, entry] of [...cabin, ...forward]) {
|
||||
const modes = dmsTaskModes(entry);
|
||||
if (!modes.length) continue;
|
||||
nodes.push({
|
||||
taskId, label: entry.label || taskId, domain: entry.domain,
|
||||
modes: modes.map((m) => ({ id: m, label: entry.modes?.[m]?.label || MODE_LABELS[m] || m })),
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export type CatalogScope = { project: "dms" | "dms-pack" | "lane"; task: string; mode?: string; pack?: string };
|
||||
export function isDmsCatalogScope(scope: CatalogScope): boolean { return scope.project === "dms" || scope.project === "dms-pack"; }
|
||||
|
||||
export function parseCatalogScope(key: string): CatalogScope {
|
||||
if (key.startsWith("lane:")) return { project: "lane", task: key.slice(5) };
|
||||
const parts = key.split(":");
|
||||
if (parts[0] === "dms-pack" && parts.length >= 4) return { project: "dms-pack", pack: parts[1], task: parts[2], mode: parts[3] };
|
||||
if (parts[0] === "dms-pack" && parts.length >= 3) return { project: "dms-pack", pack: parts[1], task: parts[2] };
|
||||
if (parts[0] === "dms") {
|
||||
if (parts.length >= 3) return { project: "dms", task: parts[1], mode: parts[2] };
|
||||
return { project: "dms", task: parts[1] || "" };
|
||||
}
|
||||
return { project: "dms", task: key };
|
||||
}
|
||||
|
||||
export function formatCatalogScope(scope: CatalogScope): string {
|
||||
if (scope.project === "lane") return `lane:${scope.task}`;
|
||||
if (scope.project === "dms-pack" && scope.pack) {
|
||||
if (scope.mode) return `dms-pack:${scope.pack}:${scope.task}:${scope.mode}`;
|
||||
return `dms-pack:${scope.pack}:${scope.task}`;
|
||||
}
|
||||
if (scope.mode) return `dms:${scope.task}:${scope.mode}`;
|
||||
return `dms:${scope.task}`;
|
||||
}
|
||||
|
||||
export type CatalogViewKind = "pack" | "batch" | "lane";
|
||||
export type CatalogUiSelection = { view: CatalogViewKind; pack: string; task: string; mode: string; lanePack: string };
|
||||
|
||||
export function packScopeKey(pack: string, task: string, mode?: string): string {
|
||||
return formatCatalogScope({ project: "dms-pack", pack, task, mode: mode || undefined });
|
||||
}
|
||||
export function batchScopeKey(task: string, mode: string): string {
|
||||
return formatCatalogScope({ project: "dms", task, mode });
|
||||
}
|
||||
export function laneScopeKey(pack: string): string {
|
||||
return formatCatalogScope({ project: "lane", task: pack });
|
||||
}
|
||||
|
||||
export function scopeKeyFromSelection(sel: CatalogUiSelection): string {
|
||||
if (sel.view === "lane") return laneScopeKey(sel.lanePack);
|
||||
if (sel.view === "batch") return batchScopeKey(sel.task, sel.mode);
|
||||
return packScopeKey(sel.pack, sel.task, sel.mode || undefined);
|
||||
}
|
||||
|
||||
export function selectionFromScopeKey(key: string, cat: CatalogReport | null): CatalogUiSelection {
|
||||
const scope = parseCatalogScope(key);
|
||||
const packTree = buildDmsPackTree(cat);
|
||||
const batchTree = buildDmsBatchTree(cat);
|
||||
const laneKeys = Object.keys(cat?.lane || {});
|
||||
if (scope.project === "lane") {
|
||||
return { view: "lane", pack: "", task: "", mode: "", lanePack: laneKeys.includes(scope.task) ? scope.task : laneKeys[0] || scope.task };
|
||||
}
|
||||
if (scope.project === "dms-pack" && scope.pack && scope.task) {
|
||||
const node = packTree.find((p) => p.pack === scope.pack);
|
||||
const wantKey = packTaskKey(scope.task, scope.mode);
|
||||
const match = node?.tasks.find((t) => t.key === wantKey) || node?.tasks.find((t) => t.id === scope.task && (!scope.mode || t.mode === scope.mode));
|
||||
const fallback = node?.tasks[0];
|
||||
return { view: "pack", pack: scope.pack, task: match?.id || fallback?.id || scope.task, mode: match?.mode || fallback?.mode || scope.mode || "", lanePack: laneKeys[0] || "" };
|
||||
}
|
||||
if (scope.project === "dms" && scope.task && scope.mode) {
|
||||
const node = batchTree.find((t) => t.taskId === scope.task);
|
||||
const modeOk = node?.modes.some((m) => m.id === scope.mode);
|
||||
if (!cat) return { view: "batch", pack: "dms_v1", task: scope.task, mode: scope.mode, lanePack: laneKeys[0] || "" };
|
||||
return { view: "batch", pack: packTree.find((p) => p.enabled)?.pack || packTree[0]?.pack || "", task: scope.task, mode: modeOk ? scope.mode : node?.modes[0]?.id || scope.mode, lanePack: laneKeys[0] || "" };
|
||||
}
|
||||
const defaultPack = packTree.find((p) => p.enabled) || packTree[0];
|
||||
const defaultTask = defaultPack?.tasks.find((t) => t.id === "dam") || defaultPack?.tasks[0];
|
||||
const defaultBatch = batchTree.find((t) => t.taskId === "dam") || batchTree[0];
|
||||
return { view: "pack", pack: defaultPack?.pack || "dms_v1", task: defaultTask?.id || "dam", mode: defaultBatch?.modes[0]?.id || "batch_0516", lanePack: laneKeys[0] || "" };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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`,
|
||||
};
|
||||
34
platform/web/src/lib/permissions.ts
Normal file
34
platform/web/src/lib/permissions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Permission check — matches backend
|
||||
export function hasPermission(
|
||||
user: { permissions: string[] } | null,
|
||||
perm: string
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (user.permissions.includes("*")) return true;
|
||||
return user.permissions.includes(perm);
|
||||
}
|
||||
|
||||
// Permission set — batch check
|
||||
export function hasAnyPermission(
|
||||
user: { permissions: string[] } | null,
|
||||
perms: string[]
|
||||
): boolean {
|
||||
return perms.some((p) => hasPermission(user, p));
|
||||
}
|
||||
|
||||
export const PERM_LABELS: Record<string, string> = {
|
||||
"*": "全部权限",
|
||||
"admin:users": "用户管理",
|
||||
"read:pending": "查看待处理",
|
||||
"read:catalog": "查看数据目录",
|
||||
"read:audit": "查看审核",
|
||||
"read:jobs": "查看任务",
|
||||
"read:fleet": "查看车队",
|
||||
"read:deliveries": "查看批次台账",
|
||||
"write:approval_submit": "提交审核",
|
||||
"write:approval_review": "审批操作",
|
||||
"write:labeling_assign": "分配标注",
|
||||
"write:labeling_vendor": "供应商导入",
|
||||
"write:fleet": "车队管理",
|
||||
"write:delivery_submit": "批次送标",
|
||||
};
|
||||
127
platform/web/src/lib/types.ts
Normal file
127
platform/web/src/lib/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Shared type definitions
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
roles: { code: string; name: string }[];
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export type PagedResult<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type BatchRecord = {
|
||||
project: string;
|
||||
task?: string;
|
||||
mode?: string;
|
||||
batch: string;
|
||||
pack?: string;
|
||||
stage: string;
|
||||
location: string;
|
||||
path?: string;
|
||||
counts?: { images?: number; labels?: number };
|
||||
next_cli?: string;
|
||||
};
|
||||
|
||||
export type LabelingBatchRow = BatchRecord & {
|
||||
scope_key?: string;
|
||||
domain_label?: string;
|
||||
task_label?: string;
|
||||
mode_label?: string;
|
||||
labeling_profile?: string;
|
||||
export_default?: string;
|
||||
ml_adapter?: string;
|
||||
campaign_id?: string;
|
||||
campaign_status?: string;
|
||||
assigned_to_user_id?: number | null;
|
||||
assigned_to_name?: string | null;
|
||||
total_tasks?: number;
|
||||
completed_tasks?: number;
|
||||
assigned_tasks?: number;
|
||||
};
|
||||
|
||||
export type BatchDelivery = {
|
||||
id: string;
|
||||
project: string;
|
||||
task?: string | null;
|
||||
mode?: string | null;
|
||||
batch_name: string;
|
||||
source_type?: string | null;
|
||||
vehicle_scene?: string | null;
|
||||
collection_start?: string | null;
|
||||
collection_end?: string | null;
|
||||
data_path: string;
|
||||
estimated_count?: number | null;
|
||||
remark?: string | null;
|
||||
status: string;
|
||||
owner_user_id?: number | null;
|
||||
owner_name?: string | null;
|
||||
submitted_by_user_id?: number | null;
|
||||
submitted_by_name?: string | null;
|
||||
approval_id?: string | null;
|
||||
approval_status?: string | null;
|
||||
job_id?: string | null;
|
||||
job_status?: string | null;
|
||||
candidate_id?: string | null;
|
||||
inbox_path?: string | null;
|
||||
error_message?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type JobRecord = {
|
||||
id: string;
|
||||
action: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ApprovalRecord = {
|
||||
id: string;
|
||||
action: string;
|
||||
action_label?: string;
|
||||
status: string;
|
||||
params?: Record<string, unknown>;
|
||||
submitted_by_name?: string | null;
|
||||
submitted_by?: string;
|
||||
reviewed_by?: string;
|
||||
note?: string;
|
||||
comment?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type FleetVehicle = {
|
||||
id: number;
|
||||
plate_no: string;
|
||||
tbox_device_id: string;
|
||||
name?: string;
|
||||
team?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type FleetRun = {
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
status: string;
|
||||
started_at?: string;
|
||||
ended_at?: string;
|
||||
};
|
||||
|
||||
export type ModelRecord = {
|
||||
id: string;
|
||||
project: string;
|
||||
task?: string;
|
||||
version?: string;
|
||||
metrics?: Record<string, number>;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/main.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { HsapApp } from "./app/HsapApp";
|
||||
import "./styles/index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<HsapApp />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
47
platform/web/src/modules/fleet/FleetShell.tsx
Normal file
47
platform/web/src/modules/fleet/FleetShell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { VehiclesPage } from "./pages/VehiclesPage";
|
||||
import { LiveMapPage } from "./pages/LiveMapPage";
|
||||
import { TripRecordsPage } from "./pages/TripRecordsPage";
|
||||
import { TboxConfigPage } from "./pages/TboxConfigPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/fleet/dashboard", label: "车队总览", perm: "read:fleet" },
|
||||
{ to: "/fleet/vehicles", label: "车辆管理", perm: "read:fleet" },
|
||||
{ to: "/fleet/map", label: "实时地图", perm: "read:fleet" },
|
||||
{ to: "/fleet/trips", label: "行程记录", perm: "read:fleet" },
|
||||
{ to: "/fleet/tbox", label: "T-Box 配置", perm: "write:fleet" },
|
||||
];
|
||||
|
||||
export const FleetShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:fleet"]}>
|
||||
<div>
|
||||
<nav className="module-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className="module-tab"
|
||||
activeClassName="active"
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Switch>
|
||||
<Route exact path={`${path}`} render={() => <Redirect to="/fleet/dashboard" />} />
|
||||
<Route path={`${path}/dashboard`} component={DashboardPage} />
|
||||
<Route path={`${path}/vehicles`} component={VehiclesPage} />
|
||||
<Route path={`${path}/map`} component={LiveMapPage} />
|
||||
<Route path={`${path}/trips`} component={TripRecordsPage} />
|
||||
<Route path={`${path}/tbox`} component={TboxConfigPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
57
platform/web/src/modules/fleet/pages/DashboardPage.tsx
Normal file
57
platform/web/src/modules/fleet/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [summary, setSummary] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hsapApi.fleetSummary()
|
||||
.then(setSummary)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>车队总览</h1>
|
||||
<p>车队实时状态概览</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-blue-700">{String(summary?.active_vehicles ?? "—")}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">活跃车辆</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{String(summary?.active_runs ?? "—")}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">进行中行程</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-orange-600">{String(summary?.total_vehicles ?? "—")}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">总车辆数</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-gray-600">{String(summary?.total_runs ?? "—")}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">总行程数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">快捷操作</div>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/fleet/map" className="text-blue-600 text-sm hover:underline">查看实时地图 →</Link>
|
||||
<Link to="/fleet/vehicles" className="text-blue-600 text-sm hover:underline">管理车辆 →</Link>
|
||||
<Link to="/fleet/trips" className="text-blue-600 text-sm hover:underline">行程记录 →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
platform/web/src/modules/fleet/pages/LiveMapPage.tsx
Normal file
81
platform/web/src/modules/fleet/pages/LiveMapPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
|
||||
export const LiveMapPage: React.FC = () => {
|
||||
const [live, setLive] = useState<Record<string, unknown> | null>(null);
|
||||
const [mapConfig, setMapConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [l, m] = await Promise.all([hsapApi.fleetLive(), hsapApi.fleetMapConfig()]);
|
||||
setLive(l);
|
||||
setMapConfig(m);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const vehicles = (live?.vehicles || []) as Record<string, unknown>[];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>实时地图</h1>
|
||||
<p>车辆 GPS 实时位置追踪</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{/* Map placeholder — Leaflet/AMap will be integrated */}
|
||||
<div className="card mb-4" style={{ height: "400px", background: "#e5e7eb" }}>
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">🗺️</div>
|
||||
<p>地图组件 (Leaflet/高德)</p>
|
||||
<p className="text-xs mt-1">底图: {mapConfig?.tile_provider as string || "gaode"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">在线车辆 ({vehicles.length})</div>
|
||||
{vehicles.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无在线车辆</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>车牌号</th>
|
||||
<th>经纬度</th>
|
||||
<th>速度</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-medium">{v.plate_no as string || "—"}</td>
|
||||
<td className="font-mono text-xs">
|
||||
{String(v.lat ?? "—")}, {String(v.lng ?? "—")}
|
||||
</td>
|
||||
<td>{v.speed_kmh != null ? `${v.speed_kmh} km/h` : "—"}</td>
|
||||
<td><Badge variant="success">在线</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<button onClick={load} className="mt-3 text-sm text-blue-600 hover:underline">刷新</button>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
platform/web/src/modules/fleet/pages/TboxConfigPage.tsx
Normal file
60
platform/web/src/modules/fleet/pages/TboxConfigPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
|
||||
export const TboxConfigPage: React.FC = () => {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>T-Box 配置</h1>
|
||||
<p>T-Box 设备上报配置说明</p>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">上报接口</div>
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="font-mono text-xs">POST</td>
|
||||
<td className="font-mono text-xs">/api/v1/tbox/gps</td>
|
||||
<td>单点 GPS 上报</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="font-mono text-xs">POST</td>
|
||||
<td className="font-mono text-xs">/api/v1/tbox/gps/batch</td>
|
||||
<td>批量 GPS 上报(最多 100 点)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">认证</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
T-Box 上报需要在 HTTP Header 中携带 <code className="bg-gray-100 px-1 rounded">X-Tbox-Token</code>,
|
||||
值为环境变量 <code className="bg-gray-100 px-1 rounded">AS_TBOX_INGEST_TOKEN</code> 的值(默认: <code className="bg-gray-100 px-1 rounded">hsap-demo-tbox-token</code>)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">上报示例</div>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto">{`curl -X POST http://127.0.0.1:8787/api/v1/tbox/gps \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Tbox-Token: hsap-demo-tbox-token" \\
|
||||
-d '{
|
||||
"device_id": "TBOX-001",
|
||||
"lat": 28.2282,
|
||||
"lng": 112.9388,
|
||||
"speed_kmh": 40,
|
||||
"run_signal": "active",
|
||||
"plate_no": "湘A·采集01"
|
||||
}'`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
platform/web/src/modules/fleet/pages/TripRecordsPage.tsx
Normal file
94
platform/web/src/modules/fleet/pages/TripRecordsPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge, Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
export const TripRecordsPage: React.FC = () => {
|
||||
const [runs, setRuns] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.fleetRuns({ offset: newOffset, limit: newLimit });
|
||||
setRuns((res.items || []) as Record<string, unknown>[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleImportGpx = async (vehicleId: number) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".gpx";
|
||||
input.onchange = async (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
await hsapApi.fleetImportGpx(vehicleId, f);
|
||||
load(0, limit);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>行程记录</h1>
|
||||
<p>查看车辆采集行程历史</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={runs.length === 0} emptyMessage="暂无行程记录">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>车辆</th>
|
||||
<th>状态</th>
|
||||
<th>开始时间</th>
|
||||
<th>结束时间</th>
|
||||
<th>里程 (km)</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.id as number}>
|
||||
<td className="font-mono text-xs">{String(r.id)}</td>
|
||||
<td>{(r.plate_no as string) || `ID:${r.vehicle_id}`}</td>
|
||||
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500">{r.started_at as string || "—"}</td>
|
||||
<td className="text-xs text-gray-500">{r.ended_at as string || "—"}</td>
|
||||
<td>{r.total_km != null ? `${Number(r.total_km).toFixed(1)}` : "—"}</td>
|
||||
<td className="flex gap-2">
|
||||
<Button size="small" variant="default" onClick={() => handleImportGpx(r.vehicle_id as number)}>
|
||||
导入 GPX
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
platform/web/src/modules/fleet/pages/VehiclesPage.tsx
Normal file
121
platform/web/src/modules/fleet/pages/VehiclesPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
export const VehiclesPage: React.FC = () => {
|
||||
const [vehicles, setVehicles] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [plateNo, setPlateNo] = useState("");
|
||||
const [deviceId, setDeviceId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [team, setTeam] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.fleetVehicles();
|
||||
setVehicles((res.items || []) as Record<string, unknown>[]);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!plateNo || !deviceId) return;
|
||||
try {
|
||||
await hsapApi.fleetCreateVehicle({ plate_no: plateNo, tbox_device_id: deviceId, name: name || undefined, team: team || undefined });
|
||||
setShowForm(false);
|
||||
setPlateNo(""); setDeviceId(""); setName(""); setTeam("");
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定删除此车辆?")) return;
|
||||
try {
|
||||
await hsapApi.fleetDeleteVehicle(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>车辆管理</h1>
|
||||
<p>管理采集车队车辆信息</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? "取消" : "添加车辆"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card mb-4 max-w-lg">
|
||||
<div className="form-group">
|
||||
<label className="form-label">车牌号</label>
|
||||
<input className="form-input" value={plateNo} onChange={(e) => setPlateNo(e.target.value)} placeholder="如 湘A·采集01" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">T-Box 设备 ID</label>
|
||||
<input className="form-input" value={deviceId} onChange={(e) => setDeviceId(e.target.value)} placeholder="如 TBOX-001" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">车辆名称(可选)</label>
|
||||
<input className="form-input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属车队(可选)</label>
|
||||
<input className="form-input" value={team} onChange={(e) => setTeam(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleCreate}>创建车辆</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={vehicles.length === 0} emptyMessage="暂无车辆">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>车牌号</th>
|
||||
<th>T-Box</th>
|
||||
<th>名称</th>
|
||||
<th>车队</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v) => (
|
||||
<tr key={v.id as number}>
|
||||
<td>{String(v.id)}</td>
|
||||
<td className="font-medium">{v.plate_no as string}</td>
|
||||
<td className="font-mono text-xs">{v.tbox_device_id as string}</td>
|
||||
<td>{(v.name as string) || "—"}</td>
|
||||
<td>{(v.team as string) || "—"}</td>
|
||||
<td><Badge variant={v.status === "active" ? "success" : "default"}>{v.status as string}</Badge></td>
|
||||
<td>
|
||||
<Button size="small" variant="danger" onClick={() => handleDelete(v.id as number)}>删除</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
platform/web/src/modules/labeling/LabelingShell.tsx
Normal file
54
platform/web/src/modules/labeling/LabelingShell.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { WorkbenchPage } from "./pages/WorkbenchPage";
|
||||
import { CampaignsPage } from "./pages/CampaignsPage";
|
||||
import { ExportPage } from "./pages/ExportPage";
|
||||
import { DeliveriesPage } from "./pages/DeliveriesPage";
|
||||
import { CatalogPage } from "./pages/CatalogPage";
|
||||
import { QualityReviewPage } from "./pages/QualityReviewPage";
|
||||
import { SimulationStudioPage } from "./pages/SimulationStudioPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending" },
|
||||
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending" },
|
||||
{ to: "/labeling/review", label: "标注质检", perm: "write:approval_review" },
|
||||
{ to: "/labeling/export", label: "导出与入库", perm: "read:pending" },
|
||||
{ to: "/labeling/deliveries", label: "批次台账", perm: "read:deliveries" },
|
||||
{ to: "/labeling/catalog", label: "数据目录", perm: "read:catalog" },
|
||||
{ to: "/labeling/simulate", label: "仿真工坊", perm: "read:pending" },
|
||||
];
|
||||
|
||||
export const LabelingShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:pending", "read:deliveries", "read:catalog", "write:delivery_submit"]}>
|
||||
<div>
|
||||
<nav className="module-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className="module-tab"
|
||||
activeClassName="active"
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Switch>
|
||||
<Route exact path={`${path}`} render={() => <Redirect to="/labeling/workbench" />} />
|
||||
<Route path={`${path}/workbench`} component={WorkbenchPage} />
|
||||
<Route path={`${path}/review/:campaignId`} component={QualityReviewPage} />
|
||||
<Route path={`${path}/review`} component={QualityReviewPage} />
|
||||
<Route path={`${path}/campaigns`} component={CampaignsPage} />
|
||||
<Route path={`${path}/export`} component={ExportPage} />
|
||||
<Route path={`${path}/deliveries`} component={DeliveriesPage} />
|
||||
<Route path={`${path}/catalog`} component={CatalogPage} />
|
||||
<Route path={`${path}/simulate`} component={SimulationStudioPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
122
platform/web/src/modules/labeling/pages/CampaignsPage.tsx
Normal file
122
platform/web/src/modules/labeling/pages/CampaignsPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
export const CampaignsPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q) || (b.campaign_id || "").toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", limit: 100 });
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
setInfo(null);
|
||||
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (campaignId: string) => {
|
||||
try { await hsapApi.submitLabelingCampaign(campaignId); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>标注进度</h1>
|
||||
<p>查看和管理进行中的标注活动</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={load}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次、任务或 Campaign ID..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无进行中的标注活动">
|
||||
<div className="space-y-3">
|
||||
{filtered.map((b) => {
|
||||
const pct = b.total_tasks && b.total_tasks > 0 ? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100) : 0;
|
||||
return (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{b.campaign_id && <span className="font-mono">{b.campaign_id.slice(0, 14)}...</span>}
|
||||
{b.assigned_to_name && <span>👤 {b.assigned_to_name}</span>}
|
||||
</div>
|
||||
{b.total_tasks != null && b.total_tasks > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden max-w-[200px]">
|
||||
<div className={`h-full rounded-full transition-all ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{b.completed_tasks}/{b.total_tasks}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && (
|
||||
<>
|
||||
<a href={`/labeling/campaigns/${encodeURIComponent(b.campaign_id)}/annotate`} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
✏️ 标注
|
||||
</a>
|
||||
<button onClick={() => handleExport(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
📤 导出
|
||||
</button>
|
||||
<button onClick={() => handleSubmit(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition-colors">
|
||||
✅ 提交
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
297
platform/web/src/modules/labeling/pages/CatalogPage.tsx
Normal file
297
platform/web/src/modules/labeling/pages/CatalogPage.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ClassCountBars } from "@/components/ClassCountBars";
|
||||
import { ClassCountPie } from "@/components/ClassCountPie";
|
||||
import { ClassCountRadar } from "@/components/ClassCountRadar";
|
||||
import { BboxScatter } from "@/components/BboxScatter";
|
||||
import { SplitCountsBars } from "@/components/SplitCountsBars";
|
||||
import {
|
||||
aggregateBboxPoints, aggregateSplitCounts, bboxPointsFromPack,
|
||||
buildDmsPackTree, dmsPacks, dmsTaskModes,
|
||||
findPackRow, isDmsCatalogScope, packTaskKey, parseCatalogScope,
|
||||
parsePackTaskKey, primaryPack, scopeKeyFromSelection, selectionFromScopeKey,
|
||||
splitCountsFromPack, type CatalogReport, type CatalogUiSelection,
|
||||
type CatalogViewKind, type DmsPackRow, type DmsTaskEntry,
|
||||
} from "@/lib/dmsCatalog";
|
||||
|
||||
const CHART_SIZE = 152;
|
||||
const SCOPE_STORAGE_KEY = "hsap.catalog.scope";
|
||||
|
||||
type DomainTab = "dms" | "forward" | "lane";
|
||||
const DOMAIN_TABS: { key: DomainTab; label: string; desc: string }[] = [
|
||||
{ key: "dms", label: "DMS 舱内", desc: "驾驶员监测数据" },
|
||||
{ key: "forward", label: "ADAS 前向", desc: "前向感知检测数据" },
|
||||
{ key: "lane", label: "车道线", desc: "Lane 检测数据" },
|
||||
];
|
||||
|
||||
const ChartCell: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/40 p-2 flex flex-col min-h-0">
|
||||
<p className="text-[11px] text-gray-500 m-0 mb-1 leading-none">{title}</p>
|
||||
<div className="flex-1 flex items-center justify-center min-h-[132px] overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function readStoredScope(fallback: string): string {
|
||||
try { return localStorage.getItem(SCOPE_STORAGE_KEY) || fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
export const CatalogPage: React.FC = () => {
|
||||
const [cat, setCat] = useState<CatalogReport | null>(null);
|
||||
const [dmsDetail, setDmsDetail] = useState<DmsTaskEntry | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
const [domain, setDomain] = useState<DomainTab>("dms");
|
||||
const [subView, setSubView] = useState<CatalogViewKind>("pack");
|
||||
const [ui, setUi] = useState<CatalogUiSelection>(() =>
|
||||
selectionFromScopeKey(readStoredScope("dms-pack:dms_v1:dam"), null)
|
||||
);
|
||||
|
||||
const scopeKey = useMemo(() => scopeKeyFromSelection(ui), [ui]);
|
||||
|
||||
const load = async (refresh = false) => {
|
||||
setLoading(true); setErr("");
|
||||
try { setCat((await hsapApi.catalog(refresh)) as CatalogReport); }
|
||||
catch (e) { setErr(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const scope = parseCatalogScope(scopeKey);
|
||||
const isPackView = scope.project === "dms-pack";
|
||||
|
||||
const loadDmsDetail = async (refresh = false) => {
|
||||
if (!isDmsCatalogScope(scope) || !scope.task) return;
|
||||
try { setDmsDetail((await hsapApi.catalogDms(scope.task, refresh)) as DmsTaskEntry); }
|
||||
catch { setDmsDetail(null); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { if (!cat) return; setUi((prev) => { const next = selectionFromScopeKey(scopeKeyFromSelection(prev), cat); return next; }); }, [cat]);
|
||||
useEffect(() => { loadDmsDetail(); }, [scopeKey]);
|
||||
useEffect(() => { try { localStorage.setItem(SCOPE_STORAGE_KEY, scopeKey); } catch { /* */ } }, [scopeKey]);
|
||||
|
||||
// Filter tasks by domain
|
||||
const dmsData = useMemo(() => {
|
||||
if (!cat?.dms) return {};
|
||||
const filtered: Record<string, DmsTaskEntry> = {};
|
||||
for (const [k, v] of Object.entries(cat.dms)) {
|
||||
const entry = v as DmsTaskEntry;
|
||||
const d = entry.domain || (k === "forward" || k === "adas" ? "forward" : "dms");
|
||||
if (d === domain || (domain === "forward" && d === "forward")) {
|
||||
filtered[k] = entry;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}, [cat, domain]);
|
||||
|
||||
// Build filtered catalog for pack/batch trees
|
||||
const filteredCat: CatalogReport | null = useMemo(() => {
|
||||
if (!cat) return null;
|
||||
return { ...cat, dms: dmsData as Record<string, unknown> } as CatalogReport;
|
||||
}, [cat, dmsData]);
|
||||
|
||||
// Full pack tree then filter to only packs relevant to current domain
|
||||
const allPackTree = useMemo(() => buildDmsPackTree(filteredCat), [filteredCat]);
|
||||
const packTree = useMemo(() => {
|
||||
if (domain === "dms") {
|
||||
// DMS 舱内: only dms_v1, dms_v2
|
||||
return allPackTree.filter((p) => p.pack.startsWith("dms_"));
|
||||
} else if (domain === "forward") {
|
||||
// ADAS 前向: only adas_v1
|
||||
return allPackTree.filter((p) => p.pack.startsWith("adas_"));
|
||||
}
|
||||
return allPackTree;
|
||||
}, [allPackTree, domain]);
|
||||
const lanePacks = useMemo(() => Object.keys(cat?.lane || {}).sort(), [cat]);
|
||||
|
||||
// Build task list for current domain
|
||||
const taskList = useMemo(() => {
|
||||
return Object.entries(dmsData).map(([id, entry]) => ({
|
||||
id, label: entry.label || id, domain: entry.domain, nc: entry.nc,
|
||||
}));
|
||||
}, [dmsData]);
|
||||
|
||||
const activePackNode = packTree.find((p) => p.pack === ui.pack) || packTree[0];
|
||||
const packTasks = activePackNode?.tasks || [];
|
||||
|
||||
// Reset selection when domain changes
|
||||
useEffect(() => {
|
||||
if (!filteredCat) return;
|
||||
const tasks = Object.keys(dmsData);
|
||||
const laneKeys = Object.keys(cat?.lane || {});
|
||||
let defaultScope: string;
|
||||
if (domain === "lane") {
|
||||
defaultScope = `lane:${laneKeys[0] || ""}`;
|
||||
} else if (domain === "forward") {
|
||||
const fwdPack = packTree[0]?.pack || "adas_v1";
|
||||
const fwdTask = packTree[0]?.tasks?.[0];
|
||||
defaultScope = `dms-pack:${fwdPack}:${fwdTask?.id || "adas"}`;
|
||||
} else {
|
||||
const dmsPack = packTree.find((p) => p.pack === "dms_v1")?.pack || packTree[0]?.pack || "dms_v1";
|
||||
defaultScope = `dms-pack:${dmsPack}:dam`;
|
||||
}
|
||||
setUi(selectionFromScopeKey(readStoredScope(defaultScope), filteredCat));
|
||||
setSubView(domain === "lane" ? "lane" : "pack");
|
||||
}, [domain, packTree]);
|
||||
|
||||
const setPack = (pack: string) => {
|
||||
setUi((prev) => {
|
||||
const node = packTree.find((p) => p.pack === pack) || packTree[0];
|
||||
const prevKey = packTaskKey(prev.task, prev.mode || undefined);
|
||||
const match = node?.tasks.find((t) => t.key === prevKey) || node?.tasks[0];
|
||||
return { ...prev, view: "pack" as const, pack: node?.pack || pack, task: match?.id || prev.task, mode: match?.mode || "" };
|
||||
});
|
||||
};
|
||||
|
||||
const setTaskKey = (taskKey: string) => {
|
||||
const { task, mode } = parsePackTaskKey(taskKey);
|
||||
setUi((prev) => ({ ...prev, view: "pack" as const, task, mode }));
|
||||
};
|
||||
|
||||
// DMS/Forward: use existing data paths
|
||||
const taskEntry: DmsTaskEntry | undefined = isDmsCatalogScope(scope) ? dmsDetail || (dmsData[scope.task] as DmsTaskEntry | undefined) : undefined;
|
||||
const packRow: DmsPackRow | undefined = isPackView && scope.pack ? findPackRow(taskEntry, scope.pack, scope.mode) : undefined;
|
||||
const activePackTask = packTasks.find((t) => t.key === packTaskKey(ui.task, ui.mode || undefined));
|
||||
const modePacks = scope.project === "dms" ? dmsPacks(taskEntry, scope.mode || "") : [];
|
||||
const tablePacks: DmsPackRow[] = isPackView ? (packRow ? [packRow] : []) : modePacks;
|
||||
|
||||
const classCounts = isPackView ? packRow?.class_counts || {} : (scope.mode && taskEntry?.modes?.[scope.mode]?.class_counts) || taskEntry?.class_counts || primaryPack(modePacks)?.class_counts || modePacks[0]?.class_counts || {};
|
||||
const bboxPoints = isPackView ? bboxPointsFromPack(packRow) : aggregateBboxPoints(modePacks);
|
||||
const splitCounts = isPackView ? splitCountsFromPack(packRow) : aggregateSplitCounts(modePacks);
|
||||
const laneEntry = scope.project === "lane" ? cat?.lane?.[scope.task] : null;
|
||||
|
||||
const selectClass = "mt-1 block w-full min-w-[10rem] rounded-md border border-gray-300 px-3 py-2 text-sm";
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>数据目录</h1>
|
||||
<p>按训练包与任务查看统计分布</p>
|
||||
</div>
|
||||
<Button variant="default" size="small" onClick={() => { load(true); loadDmsDetail(true); }} disabled={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-red-500 text-sm mb-3">{err}</p>}
|
||||
|
||||
<PageQueryState loading={loading}>
|
||||
<>
|
||||
{/* Domain tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{DOMAIN_TABS.map((tab) => (
|
||||
<button key={tab.key} onClick={() => setDomain(tab.key)}
|
||||
className={`px-4 py-2 rounded-md text-sm transition-colors ${
|
||||
domain === tab.key ? "bg-blue-700 text-white" : "bg-white border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
}`}>
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
<span className="ml-1.5 text-xs opacity-70">{tab.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DMS / Forward sub-views */}
|
||||
{domain !== "lane" && (
|
||||
<div className="flex flex-wrap gap-2 mb-4 text-sm">
|
||||
<p className="text-xs text-gray-500 m-0 self-center mr-2">视图</p>
|
||||
{(["pack", "batch"] as const).map((v) => (
|
||||
<button key={v} onClick={() => setSubView(v)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${
|
||||
subView === v ? "border-blue-300 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
|
||||
}`}>
|
||||
{v === "pack" ? "训练包" : "采集批次"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pack selector */}
|
||||
{domain !== "lane" && subView === "pack" && packTree.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label className="flex-1 min-w-[8rem] text-xs text-gray-500">训练包
|
||||
<select className={selectClass} value={ui.pack} onChange={(e) => setPack(e.target.value)}>
|
||||
{packTree.map((p) => <option key={p.pack} value={p.pack}>{p.pack}{p.enabled ? "(启用)" : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex-[1.2] min-w-[10rem] text-xs text-gray-500">任务
|
||||
<select className={selectClass} value={packTaskKey(ui.task, ui.mode || undefined)} onChange={(e) => setTaskKey(e.target.value)}>
|
||||
{packTasks.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DMS/Forward: Charts */}
|
||||
{domain !== "lane" && taskEntry && isDmsCatalogScope(scope) && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3 mb-4">
|
||||
<div className="flex flex-wrap gap-2 items-center mb-2">
|
||||
<span className="text-base font-semibold">{activePackTask?.label || taskEntry.label || scope.task}</span>
|
||||
{isPackView && scope.pack && <Badge variant="info">{scope.pack}</Badge>}
|
||||
{taskEntry.nc != null && <Badge variant="info">{taskEntry.nc} 类</Badge>}
|
||||
{Array.isArray((taskEntry as Record<string, unknown>)?.names) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{((taskEntry as Record<string, unknown>).names as string[]).map((n: string) => (
|
||||
<Badge key={n} variant="default" size="small">{n}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)] items-stretch">
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<p className="text-[11px] text-gray-500 m-0 mb-1.5">类别分布</p>
|
||||
<ClassCountBars counts={classCounts as Record<string, number>} compact maxBars={14} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||
<ChartCell title="数据划分"><SplitCountsBars counts={splitCounts} height={100} /></ChartCell>
|
||||
<ChartCell title="类别占比"><ClassCountPie counts={classCounts as Record<string, number>} size={CHART_SIZE} /></ChartCell>
|
||||
<ChartCell title="框宽高散点"><BboxScatter points={bboxPoints} size={CHART_SIZE} compact /></ChartCell>
|
||||
<ChartCell title="雷达"><ClassCountRadar counts={classCounts as Record<string, number>} size={CHART_SIZE} compact /></ChartCell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lane */}
|
||||
{domain === "lane" && laneEntry && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 mb-4">
|
||||
<div className="font-mono text-base font-semibold">{scope.task}</div>
|
||||
<p className="text-sm text-gray-500 mt-2">train_lines: {String(laneEntry.train_lines ?? "—")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{domain !== "lane" && !taskEntry && (
|
||||
<div className="card text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">暂无 {DOMAIN_TABS.find((t) => t.key === domain)?.label} 数据</p>
|
||||
<p className="text-sm">请先在送标工作台中扫描入库,或检查数据集链接是否正确</p>
|
||||
</div>
|
||||
)}
|
||||
{domain === "lane" && !laneEntry && (
|
||||
<div className="card text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">暂无车道线数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pack table */}
|
||||
{domain !== "lane" && isDmsCatalogScope(scope) && taskEntry && tablePacks.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="bg-gray-50 text-left border-b border-gray-200"><th className="px-4 py-2">训练包</th><th>启用</th><th>train</th><th>val</th><th>test</th></tr></thead>
|
||||
<tbody>
|
||||
{tablePacks.map((p) => (
|
||||
<tr key={p.name} className="border-b border-gray-100">
|
||||
<td className="px-4 py-2 font-mono">{p.name}</td><td>{p.enabled ? "是" : "否"}</td>
|
||||
<td>{p.train_images ?? "—"}</td><td>{p.val_images ?? "—"}</td><td>{p.test_images ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
platform/web/src/modules/labeling/pages/DashboardPage.tsx
Normal file
203
platform/web/src/modules/labeling/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
type DashboardData = {
|
||||
stages: Record<string, number>; total_batches: number; pending_approvals: number;
|
||||
running_jobs: number; model_count: number; fleet: Record<string, unknown>;
|
||||
activity: Record<string, unknown>[]; recent_training: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/dashboard", {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
cache: "no-store",
|
||||
}).then((r) => r.json()).then(setData).catch((e) => setError(String(e))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>数据闭环</h1>
|
||||
<p>双数据源驱动 — T-Box 实采 + 世界模型仿真</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{data && (
|
||||
<>
|
||||
{/* Pipeline flow visualization */}
|
||||
<div className="card mb-6">
|
||||
<div className="card-header">数据流入管线</div>
|
||||
<div className="flex items-center gap-0 text-sm py-4">
|
||||
{/* Left: T-Box */}
|
||||
<div className="flex-1 text-center">
|
||||
<Link to="/fleet/dashboard" className="block p-4 rounded-xl border-2 border-blue-200 bg-blue-50/50 hover:border-blue-400 transition-colors">
|
||||
<div className="text-3xl mb-2">🚛</div>
|
||||
<div className="font-semibold text-blue-800">T-Box 实采</div>
|
||||
<div className="text-xs text-gray-500 mt-1">车队采集 · GPS 标注</div>
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
{data.fleet?.active_vehicles != null ? `${data.fleet.active_vehicles} 辆在线` : "—"}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mt-2 text-xs text-gray-400">多帧视频 → 预处理 → 入库</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="text-gray-300 text-2xl px-2">→</div>
|
||||
|
||||
{/* Middle: Processing */}
|
||||
<div className="flex-1 text-center">
|
||||
<div className="p-4 rounded-xl border-2 border-gray-200 bg-gray-50/50">
|
||||
<div className="text-3xl mb-2">⚙️</div>
|
||||
<div className="font-semibold text-gray-700">数据加工</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.stages.out_for_labeling || 0} 标中 · {data.stages.returned || 0} 待入库
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{data.total_batches} 个批次</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">标注 → 质检 → 入库 → build</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="text-gray-300 text-2xl px-2">→</div>
|
||||
|
||||
{/* Right: Training */}
|
||||
<div className="flex-1 text-center">
|
||||
<Link to="/models/overview" className="block p-4 rounded-xl border-2 border-green-200 bg-green-50/50 hover:border-green-400 transition-colors">
|
||||
<div className="text-3xl mb-2">🧠</div>
|
||||
<div className="font-semibold text-green-800">模型训练</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{data.model_count} 个模型</div>
|
||||
<div className="text-xs text-green-600 mt-1">{data.running_jobs} 个执行中</div>
|
||||
</Link>
|
||||
<div className="mt-2 text-xs text-gray-400">train → eval → promote</div>
|
||||
</div>
|
||||
|
||||
{/* Rightmost: World Model */}
|
||||
<div className="text-gray-300 text-2xl px-2">↻</div>
|
||||
|
||||
<div className="flex-1 text-center">
|
||||
<Link to="/labeling/simulate" className="block p-4 rounded-xl border-2 border-purple-200 bg-purple-50/50 hover:border-purple-400 transition-colors">
|
||||
<div className="text-3xl mb-2">🌐</div>
|
||||
<div className="font-semibold text-purple-800">世界模型仿真</div>
|
||||
<div className="text-xs text-gray-500 mt-1">场景生成 · 自动标注</div>
|
||||
<div className="text-xs text-purple-600 mt-1">补数据缺口</div>
|
||||
</Link>
|
||||
<div className="mt-2 text-xs text-gray-400">评估反馈 → 生成 → 直接入库</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
<Link to="/labeling/workbench" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-blue-700">{data.total_batches}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">总批次</div>
|
||||
</Link>
|
||||
<Link to="/system/audit" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-orange-600">{data.pending_approvals}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">待审核</div>
|
||||
</Link>
|
||||
<Link to="/models/overview" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-green-600">{data.model_count}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">模型</div>
|
||||
</Link>
|
||||
<Link to="/system/jobs" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-purple-600">{data.running_jobs}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">执行中</div>
|
||||
</Link>
|
||||
<Link to="/fleet/dashboard" className="card text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{data.fleet?.active_vehicles != null ? String(data.fleet.active_vehicles) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">在线车辆</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stage distribution + Activity */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<div className="card-header">批次阶段分布</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ k: "raw_pool", label: "待送标", color: "bg-gray-400" },
|
||||
{ k: "out_for_labeling", label: "标中", color: "bg-blue-500" },
|
||||
{ k: "in_review", label: "质检中", color: "bg-yellow-500" },
|
||||
{ k: "review_approved", label: "已通过", color: "bg-green-500" },
|
||||
{ k: "returned", label: "已入库", color: "bg-green-600" },
|
||||
].map(({ k, label, color }) => {
|
||||
const v = data.stages[k] || 0;
|
||||
const max = Math.max(...Object.values(data.stages), 1);
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-14 text-gray-500">{label}</span>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded overflow-hidden">
|
||||
<div className={`h-full rounded transition-all ${color}`} style={{ width: `${(v / max) * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-6 text-right font-mono font-semibold text-sm">{v}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">快捷入口</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Link to="/labeling/workbench" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">📋</span> 送标工作台
|
||||
</Link>
|
||||
<Link to="/labeling/simulate" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-purple-50 transition-colors">
|
||||
<span className="text-lg">🌐</span> 仿真工坊
|
||||
</Link>
|
||||
<Link to="/labeling/campaigns" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">✏️</span> 标注进度
|
||||
</Link>
|
||||
<Link to="/labeling/review" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">✅</span> 标注质检
|
||||
</Link>
|
||||
<Link to="/models/training/submit" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">🚀</span> 训练提交
|
||||
</Link>
|
||||
<Link to="/system/audit" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">📝</span> 审核队列
|
||||
</Link>
|
||||
<Link to="/fleet/dashboard" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">🚛</span> 车队总览
|
||||
</Link>
|
||||
<Link to="/models/datasets" className="flex items-center gap-2 p-2.5 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">📦</span> 数据集版本
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data source summary */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<div className="card border-l-4 border-l-blue-500">
|
||||
<div className="text-xs text-gray-400 mb-1">T-Box 实采数据流</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
采集车多帧视频 → 预处理去噪去重 → 入库 →
|
||||
标注 → 质检 → build → 模型训练
|
||||
</div>
|
||||
</div>
|
||||
<div className="card border-l-4 border-l-purple-500">
|
||||
<div className="text-xs text-gray-400 mb-1">世界模型仿真数据流</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
评估反馈(数据缺口) → 场景配置 → 生成 →
|
||||
自动标注 → 直接入库 → 补充训练
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
145
platform/web/src/modules/labeling/pages/DeliveriesPage.tsx
Normal file
145
platform/web/src/modules/labeling/pages/DeliveriesPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import type { BatchDelivery } from "@/lib/types";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
export const DeliveriesPage: React.FC = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [deliveries, setDeliveries] = useState<BatchDelivery[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const load = async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listDeliveries({ status: statusFilter || undefined, offset: newOffset, limit: newLimit });
|
||||
let items = res.items || [];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((d) => (d.batch_name || "").toLowerCase().includes(q) || (d.task || "").toLowerCase().includes(q) || (d.owner_name || "").toLowerCase().includes(q));
|
||||
}
|
||||
setDeliveries(items);
|
||||
setTotal(search ? items.length : res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [search, statusFilter]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const batchName = prompt("批次名称 (如 20260601_pilot):");
|
||||
if (!batchName) return;
|
||||
const dataPath = prompt("数据路径 (如 datasets/dms/inbox/ddaw/20260601_pilot):");
|
||||
if (!dataPath) return;
|
||||
try {
|
||||
await hsapApi.createDelivery({
|
||||
project: "dms",
|
||||
batch_name: batchName,
|
||||
data_path: dataPath,
|
||||
});
|
||||
load(0, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (id: string) => {
|
||||
try {
|
||||
await hsapApi.submitDelivery(id);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除此送标记录?")) return;
|
||||
try {
|
||||
await hsapApi.deleteDelivery(id);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>批次台账</h1>
|
||||
<p>管理数据送标申请与审核流程</p>
|
||||
</div>
|
||||
{hasPermission("write:delivery_submit") && (
|
||||
<Button variant="primary" onClick={handleCreate}>新建送标</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次/任务/提单人..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "草稿", "已提交", "已入湖", "已驳回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["draft", "submitted", "ingested", "rejected"][i - 1];
|
||||
return <button key={val} onClick={() => { setStatusFilter(val); setOffset(0); }} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${statusFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(0, limit)}>刷新</Button>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{total} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={deliveries.length === 0} emptyMessage="暂无送标记录">
|
||||
<div className="space-y-2">
|
||||
{deliveries.map((d) => (
|
||||
<div key={d.id} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{d.batch_name}</span>
|
||||
<span className="text-xs text-gray-400">{d.project}/{d.task || "—"}</span>
|
||||
<StatusBadge status={d.status} />
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>🖼 {d.estimated_count ?? "—"}</span>
|
||||
<span>👤 {d.submitted_by_name || d.owner_name || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{d.status === "draft" && (
|
||||
<>
|
||||
<button onClick={() => handleSubmit(d.id)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">✅ 提交审核</button>
|
||||
<button onClick={() => handleDelete(d.id)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors">🗑 删除</button>
|
||||
</>
|
||||
)}
|
||||
{d.approval_id && (
|
||||
<Link to={`/system/audit/${d.approval_id}`} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors">📋 审核</Link>
|
||||
)}
|
||||
{d.status === "ingested" && <span className="text-green-600 text-sm font-medium">✓ 已入湖</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
158
platform/web/src/modules/labeling/pages/ExportPage.tsx
Normal file
158
platform/web/src/modules/labeling/pages/ExportPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
export const ExportPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (search && !(b.batch || "").toLowerCase().includes(search.toLowerCase()) && !(b.task || "").toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (stageFilter && b.stage !== stageFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [submitted, returned] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "returned", limit: 100 }),
|
||||
]);
|
||||
if (submitted.status === "fulfilled") results.push(...((submitted.value.items || []) as LabelingBatchRow[]));
|
||||
if (returned.status === "fulfilled") results.push(...((returned.value.items || []) as LabelingBatchRow[]));
|
||||
if (submitted.status === "rejected" && returned.status === "rejected") setError(String(submitted.reason));
|
||||
setBatches(results);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
try { await hsapApi.labelingExport(campaignId); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const handleImportVendor = async (campaignId: string) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file"; input.accept = ".zip";
|
||||
input.onchange = async (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
setImportingId(campaignId);
|
||||
try { await hsapApi.importVendorZip(campaignId, f); load(); }
|
||||
catch (err) { setError(String(err)); }
|
||||
setImportingId(null);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const hasData = batches.length > 0;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>导出与入库</h1>
|
||||
<p>标注完成后的导出、供应商回标导入、入库流程</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次/任务..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "待导出", "待入库"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["labeling_submitted", "returned"][i - 1];
|
||||
return <button key={val} onClick={() => setStageFilter(val)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow guide */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">流程说明</div>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">1</span>
|
||||
<span><strong>标注提交</strong> — 标注员在 Campaign 中完成标注后,点击"提交批次"</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">2</span>
|
||||
<span>
|
||||
<strong>导出标注</strong> — 在此页面点击"执行导出",将标注结果转为 YOLO 格式
|
||||
{hasData && <span className="text-gray-400">(下表有待导出批次)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-xs font-bold">3</span>
|
||||
<span>
|
||||
<strong>供应商回标</strong> — 如果是外部供应商标注的,点击"导入供应商"上传 ZIP 回标文件
|
||||
<span className="block text-xs text-gray-400 mt-0.5">ZIP 格式要求:每张图片对应一个同名 .txt 标注文件(YOLO 格式),放在同一目录下打包</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-xs font-bold">4</span>
|
||||
<span>
|
||||
<strong>入库 build</strong> — 导出完成后,批次进入 <Badge variant="success" size="small">待入库</Badge> 状态,可通过审核队列提交 build
|
||||
<span className="block text-xs text-gray-400 mt-0.5">build 成功后自动生成数据集版本快照</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0}>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((b) => (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.task || "—"}</span>
|
||||
<Badge variant={b.stage === "returned" ? "success" : "warning"}>{b.stage === "labeling_submitted" ? "待导出" : "待入库"}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-1">{b.campaign_id?.slice(0, 16) || "—"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && b.stage === "labeling_submitted" && (
|
||||
<>
|
||||
<Button size="small" variant="primary" onClick={() => handleExport(b.campaign_id!)}>📤 执行导出</Button>
|
||||
<Button size="small" variant="default" loading={importingId === b.campaign_id} onClick={() => handleImportVendor(b.campaign_id!)}>📥 导入供应商</Button>
|
||||
</>
|
||||
)}
|
||||
{b.stage === "returned" && <span className="text-green-600 text-sm font-medium">✓ 已入库</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-400 text-lg mb-3">暂无待导出或待入库的批次</p>
|
||||
<p className="text-gray-400 text-sm mb-4">完成标注后,在标注进度页提交批次,即可在此处导出</p>
|
||||
<Link to="/labeling/campaigns"><Button variant="default" size="small">去标注进度 →</Button></Link>
|
||||
</div>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
273
platform/web/src/modules/labeling/pages/QualityReviewPage.tsx
Normal file
273
platform/web/src/modules/labeling/pages/QualityReviewPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ReviewItem = { id: string; image_path: string; fileName: string; score: string; has_label: boolean };
|
||||
type ScoreCounts = { good: number; fine: number; bad: number; pending: number };
|
||||
type ScoreKey = keyof ScoreCounts;
|
||||
|
||||
const SCORE_CFG: Record<ScoreKey, { label: string; color: string; bg: string; icon: string; btn: string }> = {
|
||||
good: { label: "合格", color: "text-green-600", bg: "bg-green-500", icon: "✓", btn: "bg-green-600 hover:bg-green-500 text-white" },
|
||||
fine: { label: "可用", color: "text-yellow-600", bg: "bg-yellow-500", icon: "~", btn: "bg-yellow-600 hover:bg-yellow-500 text-white" },
|
||||
bad: { label: "退回", color: "text-red-600", bg: "bg-red-500", icon: "✗", btn: "bg-red-600 hover:bg-red-500 text-white" },
|
||||
pending: { label: "未评", color: "text-gray-400", bg: "bg-gray-300", icon: "○", btn: "" },
|
||||
};
|
||||
|
||||
// ── List ──
|
||||
const ReviewListPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [filtered, setFiltered] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
const [sort, setSort] = useState<"batch"|"task">("batch");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [inReview, approved, rejected] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "in_review", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "review_approved", limit: 50 }),
|
||||
hsapApi.labelingBatches({ stage: "review_rejected", limit: 50 }),
|
||||
]);
|
||||
if (inReview.status === "fulfilled") results.push(...((inReview.value.items || []) as LabelingBatchRow[]));
|
||||
if (approved.status === "fulfilled") results.push(...((approved.value.items || []) as LabelingBatchRow[]));
|
||||
if (rejected.status === "fulfilled") results.push(...((rejected.value.items || []) as LabelingBatchRow[]));
|
||||
setBatches(results);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
let result = [...batches];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((b) => (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q));
|
||||
}
|
||||
if (stageFilter) result = result.filter((b) => b.stage === stageFilter);
|
||||
result.sort((a, b) => (a[sort] || "").localeCompare(b[sort] || ""));
|
||||
setFiltered(result);
|
||||
}, [batches, search, stageFilter, sort]);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>标注质检</h1>
|
||||
<p>审核标注质量 — 合格和可用的流入训练集,退回的返回标注员修正</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={load}>刷新</Button>
|
||||
</div>
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次名称、任务名称..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "质检中", "已通过", "已退回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["in_review", "review_approved", "review_rejected"][i - 1];
|
||||
return (
|
||||
<button key={val} onClick={() => setStageFilter(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}>{label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-gray-300">|</span>
|
||||
{/* Sort */}
|
||||
<select className="text-xs border border-gray-300 rounded-lg px-2.5 py-1.5 text-gray-600"
|
||||
value={sort} onChange={(e) => setSort(e.target.value as "batch"|"task")}>
|
||||
<option value="batch">按批次排序</option>
|
||||
<option value="task">按任务排序</option>
|
||||
</select>
|
||||
{/* Count */}
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无待质检的批次">
|
||||
<div className="space-y-2">
|
||||
{filtered.map((b) => {
|
||||
const pct = b.total_tasks && b.total_tasks > 0 ? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100) : 0;
|
||||
return (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
||||
<span className="font-mono">{(b.campaign_id || "").slice(0, 14)}...</span>
|
||||
{pct > 0 && <span>{b.completed_tasks}/{b.total_tasks} ({pct}%)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "in_review" && <Link to={`/labeling/review/${b.campaign_id}`}><Button size="small" variant="primary">▶ 开始质检</Button></Link>}
|
||||
{b.stage === "review_approved" && <span className="text-green-600 text-sm font-medium">✓ 已通过</span>}
|
||||
{b.stage === "review_rejected" && <span className="text-red-600 text-sm font-medium">✗ 已退回</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Detail ──
|
||||
const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
|
||||
const [items, setItems] = useState<ReviewItem[]>([]);
|
||||
const [scores, setScores] = useState<ScoreCounts>({ good: 0, fine: 0, bad: 0, pending: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentIdx, setCurrentIdx] = useState(0);
|
||||
const [localScores, setLocalScores] = useState<Record<string, string>>({});
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ offset: "0", limit: "2000" });
|
||||
const res = await fetch(`/api/v1/labeling/campaigns/${campaignId}/review-queue?${params}`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` }, cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setItems((res.items || []) as ReviewItem[]);
|
||||
setScores((res.scores || { good: 0, fine: 0, bad: 0, pending: 0 }) as ScoreCounts);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [campaignId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const currentImage = items[currentIdx];
|
||||
const imageUrl = currentImage
|
||||
? `/api/v1/labeling/campaigns/${campaignId}/review-image?path=${encodeURIComponent(currentImage.image_path)}`
|
||||
: "";
|
||||
const currentScore = currentImage ? (localScores[currentImage.image_path] || currentImage.score || "pending") : "pending";
|
||||
|
||||
const handleScore = (score: string) => {
|
||||
if (!currentImage) return;
|
||||
setLocalScores((prev) => ({ ...prev, [currentImage.image_path]: score }));
|
||||
fetch(`/api/v1/labeling/campaigns/${campaignId}/review-submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
body: JSON.stringify({ scores: [{ image_path: currentImage.image_path, score }] }),
|
||||
}).catch(() => {});
|
||||
setScores((prev) => {
|
||||
const oldScore = currentImage.score || "pending";
|
||||
const next = { ...prev };
|
||||
if (next[oldScore as ScoreKey] > 0) next[oldScore as ScoreKey]--;
|
||||
next[score as ScoreKey] = (next[score as ScoreKey] || 0) + 1;
|
||||
return next;
|
||||
});
|
||||
if (currentIdx < items.length - 1) setTimeout(() => setCurrentIdx((i) => i + 1), 250);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === "ArrowLeft") setCurrentIdx((i) => Math.max(0, i - 1));
|
||||
else if (e.key === "ArrowRight") setCurrentIdx((i) => Math.min(items.length - 1, i + 1));
|
||||
else if (e.key === "g" || e.key === "G") handleScore("good");
|
||||
else if (e.key === "f" || e.key === "F") handleScore("fine");
|
||||
else if (e.key === "b" || e.key === "B") handleScore("bad");
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [currentIdx, items.length]);
|
||||
|
||||
const reviewed = scores.good + scores.fine + scores.bad;
|
||||
const total = reviewed + scores.pending;
|
||||
const progressPct = total > 0 ? (reviewed / total) * 100 : 0;
|
||||
const passRate = reviewed > 0 ? ((scores.good + scores.fine) / reviewed) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900 text-white">
|
||||
{/* Top control bar */}
|
||||
<header className="flex items-center gap-4 px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
|
||||
<Link to="/labeling/review" className="text-blue-400 text-sm hover:underline">← 返回列表</Link>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span className="text-sm font-mono text-gray-300">{campaignId.slice(0, 16)}...</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
{(["good", "fine", "bad"] as ScoreKey[]).map((k) => (
|
||||
<span key={k} className={`text-sm ${SCORE_CFG[k].color} flex items-center gap-1`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${SCORE_CFG[k].bg}`} />{SCORE_CFG[k].label}: {scores[k]}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-gray-500">{reviewed}/{total} 已评</span>
|
||||
<div className="w-24 h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs ${passRate >= 80 ? "text-green-400" : passRate > 0 ? "text-yellow-400" : "text-gray-500"}`}>
|
||||
{Math.round(passRate)}% 通过
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={items.length === 0} emptyMessage="该批次暂无图片">
|
||||
{currentImage && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
|
||||
{imgError ? (
|
||||
<div className="text-gray-500 text-center"><p className="text-lg mb-2">图片加载失败</p><p className="text-sm">{currentImage.fileName}</p></div>
|
||||
) : (
|
||||
<img src={imageUrl} alt={currentImage.fileName} className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||
onError={() => setImgError(true)} onLoad={() => setImgError(false)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="shrink-0 bg-gray-800 border-t border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center gap-3 max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="small" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} disabled={currentIdx <= 0}>←</Button>
|
||||
<span className="text-sm text-gray-400 w-20 text-center tabular-nums">{currentIdx + 1}/{items.length}</span>
|
||||
<Button variant="default" size="small" onClick={() => setCurrentIdx((i) => Math.min(items.length - 1, i + 1))} disabled={currentIdx >= items.length - 1}>→</Button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{(["good", "fine", "bad"] as ScoreKey[]).map((s) => (
|
||||
<button key={s} onClick={() => handleScore(s)}
|
||||
className={`px-6 py-2.5 rounded-lg font-semibold text-sm transition-all ${SCORE_CFG[s].btn} ${
|
||||
currentScore === s ? "ring-2 ring-offset-2 ring-offset-gray-800 scale-105" : "hover:scale-105"
|
||||
}`}>
|
||||
{SCORE_CFG[s].icon} {SCORE_CFG[s].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-1.5 text-xs text-gray-500">
|
||||
{currentImage.fileName} · 标注: {currentImage.has_label ? "有" : "无"} · 快捷键: G/F/B 评分 ←→ 翻页
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Router ──
|
||||
export const QualityReviewPage: React.FC = () => {
|
||||
const { campaignId } = useParams<{ campaignId: string }>();
|
||||
if (!campaignId) return <ReviewListPage />;
|
||||
return <ReviewDetailPage campaignId={campaignId} />;
|
||||
};
|
||||
251
platform/web/src/modules/labeling/pages/SimulationStudioPage.tsx
Normal file
251
platform/web/src/modules/labeling/pages/SimulationStudioPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
type SimJob = Record<string, unknown>;
|
||||
|
||||
const SCENES = [
|
||||
{ key: "urban_highway", label: "城市快速路", icon: "🛣️" },
|
||||
{ key: "urban_street", label: "城市街道", icon: "🏙️" },
|
||||
{ key: "rural_road", label: "乡村道路", icon: "🌾" },
|
||||
{ key: "tunnel", label: "隧道", icon: "🚇" },
|
||||
{ key: "night_city", label: "夜间城市", icon: "🌃" },
|
||||
{ key: "rain_highway", label: "雨天高速", icon: "🌧️" },
|
||||
{ key: "fog_rural", label: "雾天乡村", icon: "🌫️" },
|
||||
];
|
||||
|
||||
const CAMERAS = [
|
||||
{ key: "truck_front", label: "卡车前视", icon: "🚛", height: "2.5m", fov: "75°" },
|
||||
{ key: "truck_side", label: "卡车侧视", icon: "🚛", height: "2.5m", fov: "100°" },
|
||||
{ key: "car_front", label: "轿车前视", icon: "🚗", height: "1.2m", fov: "60°" },
|
||||
{ key: "car_wide", label: "轿车广角", icon: "🚗", height: "1.2m", fov: "120°" },
|
||||
];
|
||||
|
||||
const WEATHERS = ["clear", "cloudy", "rain", "fog", "night"];
|
||||
const WEATHER_LABELS: Record<string, string> = { clear: "晴天", cloudy: "多云", rain: "雨天", fog: "大雾", night: "夜间" };
|
||||
const DENSITY_LABELS: Record<string, string> = { sparse: "稀疏 5-10", medium: "中等 10-30", dense: "密集 30+" };
|
||||
const ALL_CLASSES = ["Pedestrain", "Car", "Truck", "Bus", "Motor-vehicles", "Tricycle", "cones"];
|
||||
|
||||
const JOBS_PAGE = 20;
|
||||
|
||||
export const SimulationStudioPage: React.FC = () => {
|
||||
// Config form
|
||||
const [scene, setScene] = useState("urban_highway");
|
||||
const [camera, setCamera] = useState("truck_front");
|
||||
const [weather, setWeather] = useState("clear");
|
||||
const [objects, setObjects] = useState<string[]>(["Pedestrain", "Car", "Truck", "Bus"]);
|
||||
const [density, setDensity] = useState("medium");
|
||||
const [count, setCount] = useState(100);
|
||||
const [fovVariant, setFovVariant] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
// Jobs list
|
||||
const [jobs, setJobs] = useState<SimJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/simulate/jobs?limit=50`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` }, cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setJobs((res.items || []) as SimJob[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { loadJobs(); }, []);
|
||||
|
||||
const toggleObject = (obj: string) => {
|
||||
setObjects((prev) => prev.includes(obj) ? prev.filter((o) => o !== obj) : [...prev, obj]);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (objects.length === 0) { setError("请至少选择一种目标类别"); return; }
|
||||
setGenerating(true); setError(null); setInfo(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/simulate/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
body: JSON.stringify({ scene, camera, weather, objects, density, count, fov_variant: fovVariant, note }),
|
||||
}).then((r) => r.json());
|
||||
setInfo(`任务已提交: ${res.id}`);
|
||||
loadJobs();
|
||||
} catch (e) { setError(String(e)); }
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleIngest = async (jobId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/simulate/jobs/${jobId}/ingest?task=adas`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
}).then((r) => r.json());
|
||||
if (res.ok) setInfo(`已入库: ${res.batch}`);
|
||||
else setError(String(res.error));
|
||||
loadJobs();
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>仿真工坊</h1>
|
||||
<p>世界模型生成合成数据 — 自动标注,直接入库</p>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Config panel */}
|
||||
<div>
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">场景配置</div>
|
||||
|
||||
{/* Scene */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">场景模板</label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{SCENES.map((s) => (
|
||||
<button key={s.key} onClick={() => setScene(s.key)}
|
||||
className={`p-2 rounded-lg text-xs text-center transition-colors ${
|
||||
scene === s.key ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}>
|
||||
<div className="text-lg">{s.icon}</div>
|
||||
<div>{s.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Camera */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">相机参数</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{CAMERAS.map((c) => (
|
||||
<button key={c.key} onClick={() => setCamera(c.key)}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
camera === c.key ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}>
|
||||
<div>{c.icon} {c.label}</div>
|
||||
<div className="text-[10px] opacity-70">高度 {c.height} · FOV {c.fov}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">天气/光照</label>
|
||||
<div className="flex gap-1.5">
|
||||
{WEATHERS.map((w) => (
|
||||
<button key={w} onClick={() => setWeather(w)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
weather === w ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}>{WEATHER_LABELS[w] || w}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objects */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">目标类别 ({objects.length}/7)</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ALL_CLASSES.map((c) => (
|
||||
<button key={c} onClick={() => toggleObject(c)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${
|
||||
objects.includes(c) ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-500"
|
||||
}`}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Density + Count */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="form-label">目标密度</label>
|
||||
<select className="form-input" value={density} onChange={(e) => setDensity(e.target.value)}>
|
||||
{Object.entries(DENSITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">生成数量</label>
|
||||
<select className="form-input" value={count} onChange={(e) => setCount(Number(e.target.value))}>
|
||||
{[50, 100, 200, 500, 1000, 2000, 5000].map((n) => <option key={n} value={n}>{n} 张</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-FOV variant */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={fovVariant} onChange={(e) => setFovVariant(e.target.checked)} className="rounded" />
|
||||
<span>生成多FOV变体(卡车+轿车视角各一份)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">备注</label>
|
||||
<input className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="如:补卡车视角雨天行人数据" />
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={handleGenerate} loading={generating} className="w-full">
|
||||
🚀 生成仿真数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Job history */}
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>生成历史</span>
|
||||
<Button size="small" variant="default" onClick={loadJobs}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={jobs.length === 0} emptyMessage="暂无生成记录">
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{jobs.map((job) => {
|
||||
const params = job.params as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<div key={job.id as string} className="border border-gray-100 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{(job.id as string).slice(0, 20)}...</span>
|
||||
<StatusBadge status={(job.status as string) || "queued"} />
|
||||
</div>
|
||||
{params && (
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<div>{String(params.scene_label ?? "")} · {String(params.camera_label ?? "")} · {WEATHER_LABELS[String(params.weather ?? "")] || String(params.weather ?? "")}</div>
|
||||
<div className="flex gap-1">
|
||||
{(params.objects as string[])?.map((o) => <Badge key={o} size="small" variant="default">{o}</Badge>)}
|
||||
</div>
|
||||
<div>{DENSITY_LABELS[params.density as string]} · {params.count as number} 张</div>
|
||||
{params.note != null && String(params.note) && <div className="text-gray-400 italic">"{String(params.note)}"</div>}
|
||||
</div>
|
||||
)}
|
||||
{job.status === "completed" && !Boolean(job.batch_registered) && (
|
||||
<button onClick={() => handleIngest(job.id as string)}
|
||||
className="mt-2 text-xs text-blue-600 hover:underline">入库到训练集 →</button>
|
||||
)}
|
||||
{Boolean(job.batch_registered) && (
|
||||
<span className="mt-2 text-xs text-green-600 inline-block">✓ 已入库: {String(job.batch_name ?? "")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
platform/web/src/modules/labeling/pages/WorkbenchPage.tsx
Normal file
200
platform/web/src/modules/labeling/pages/WorkbenchPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge, Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ScanItem = { project: string; task: string; batch: string; path: string; images: number; labels: number; has_labels: boolean; stage_hint: string };
|
||||
|
||||
export const WorkbenchPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState("raw_pool");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredBatches = batches.filter((b) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
// Scan inbox
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanItems, setScanItems] = useState<ScanItem[]>([]);
|
||||
const [showScan, setShowScan] = useState(false);
|
||||
const [registering, setRegistering] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (filterStage: string) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.labelingBatches({ stage: filterStage, limit: 100 });
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(stage); }, [stage, load]);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true); setError(null);
|
||||
try {
|
||||
const dms = await hsapApi.scanInbox("dms");
|
||||
setScanItems((dms.items || []) as unknown as ScanItem[]);
|
||||
setShowScan(true);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const handleRegister = async (item: ScanItem) => {
|
||||
setRegistering(item.batch);
|
||||
try {
|
||||
await hsapApi.registerBatch({
|
||||
project: item.project, task: item.task, batch: item.batch,
|
||||
stage: item.stage_hint, location: "inbox",
|
||||
});
|
||||
setScanItems((prev) => prev.filter((s) => s.batch !== item.batch));
|
||||
load(stage);
|
||||
setInfo(`已登记: ${item.task}/${item.batch}`);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setRegistering(null);
|
||||
};
|
||||
|
||||
const handleRegisterAll = async () => {
|
||||
let count = 0;
|
||||
for (const item of scanItems) {
|
||||
try {
|
||||
await hsapApi.registerBatch({
|
||||
project: item.project, task: item.task, batch: item.batch,
|
||||
stage: item.stage_hint, location: "inbox",
|
||||
});
|
||||
count++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setScanItems([]); setShowScan(false);
|
||||
load(stage);
|
||||
setInfo(`已登记 ${count} 个批次`);
|
||||
};
|
||||
|
||||
const handleOpenCampaign = async (row: LabelingBatchRow) => {
|
||||
if (!row.task) return;
|
||||
try {
|
||||
await hsapApi.openLabelingCampaign({
|
||||
project: row.project, task: row.task, batch: row.batch,
|
||||
mode: row.mode || null, pack: row.pack || null, location: row.location,
|
||||
});
|
||||
load(stage);
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const STAGES = [
|
||||
{ key: "raw_pool", label: "待送标" },
|
||||
{ key: "out_for_labeling", label: "标中" },
|
||||
{ key: "returned", label: "待入库" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>送标工作台</h1>
|
||||
<p>管理数据标注批次的全生命周期</p>
|
||||
</div>
|
||||
<Button variant="primary" size="small" onClick={handleScan} loading={scanning}>
|
||||
扫描入库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
{/* Scan results panel */}
|
||||
{showScan && (
|
||||
<div className="card mb-4 border-blue-200 bg-blue-50/30">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>扫描结果 — 发现 {scanItems.length} 个未登记批次</span>
|
||||
<div className="flex gap-2">
|
||||
{scanItems.length > 0 && <Button size="small" variant="primary" onClick={handleRegisterAll}>全部登记</Button>}
|
||||
<Button size="small" variant="default" onClick={() => setShowScan(false)}>收起</Button>
|
||||
</div>
|
||||
</div>
|
||||
{scanItems.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">所有 inbox 数据均已登记</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr><th>任务</th><th>批次</th><th>图片</th><th>标注</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanItems.map((item) => (
|
||||
<tr key={`${item.task}/${item.batch}`}>
|
||||
<td className="font-medium">{item.task}</td>
|
||||
<td>{item.batch}</td>
|
||||
<td>{item.images}</td>
|
||||
<td>{item.has_labels ? <Badge variant="success" size="small">{item.labels} 个</Badge> : <span className="text-gray-400">无标注</span>}</td>
|
||||
<td><StageBadge stage={item.stage_hint} /></td>
|
||||
<td>
|
||||
<Button size="small" variant="primary" loading={registering === item.batch} onClick={() => handleRegister(item)}>
|
||||
登记入库
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage filter + Search */}
|
||||
<div className="flex gap-2 mb-4 items-center flex-wrap">
|
||||
{STAGES.map((s) => (
|
||||
<button key={s.key} onClick={() => setStage(s.key)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${stage === s.key ? "bg-blue-700 text-white" : "bg-white border border-gray-300 text-gray-600 hover:bg-gray-50"}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<input className="form-input w-40" placeholder="搜索批次..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filteredBatches.length === 0}>
|
||||
<div className="space-y-2">
|
||||
{filteredBatches.map((b) => (
|
||||
<div key={`${b.project}/${b.task}/${b.batch}`} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.project}/{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>🖼 {b.counts?.images ?? 0}</span>
|
||||
<span>🏷 {b.counts?.labels ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "raw_pool" && (
|
||||
<Button size="small" variant="primary" onClick={() => handleOpenCampaign(b)}>开标</Button>
|
||||
)}
|
||||
{b.stage === "out_for_labeling" && b.campaign_id && (
|
||||
<a href={`/labeling/campaigns/${b.campaign_id}/annotate`} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
✏️ 进入标注
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// FORCE REBUILD 1780387552
|
||||
// UNIQUE_MARKER_1780387674745592125
|
||||
// CARD_LAYOUT_MARKER_v2_20260602
|
||||
50
platform/web/src/modules/models/ModelsShell.tsx
Normal file
50
platform/web/src/modules/models/ModelsShell.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { OverviewPage } from "./pages/OverviewPage";
|
||||
import { TrainingSubmitPage } from "./pages/TrainingSubmitPage";
|
||||
import { TrainingRecordsPage } from "./pages/TrainingRecordsPage";
|
||||
import { EvaluationPage } from "./pages/EvaluationPage";
|
||||
import { PromotionPage } from "./pages/PromotionPage";
|
||||
import { DatasetVersionsPage } from "./pages/DatasetVersionsPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/models/overview", label: "模型概览", perm: "read:jobs" },
|
||||
{ to: "/models/datasets", label: "数据集版本", perm: "read:jobs" },
|
||||
{ to: "/models/training/submit", label: "训练提交", perm: "write:approval_submit" },
|
||||
{ to: "/models/training/records", label: "训练记录", perm: "read:jobs" },
|
||||
{ to: "/models/evaluation", label: "评估管理", perm: "read:jobs" },
|
||||
{ to: "/models/promotion", label: "模型晋级", perm: "write:approval_submit" },
|
||||
];
|
||||
|
||||
export const ModelsShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:jobs", "write:approval_submit"]}>
|
||||
<div>
|
||||
<nav className="module-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className="module-tab"
|
||||
activeClassName="active"
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Switch>
|
||||
<Route exact path={`${path}`} render={() => <Redirect to="/models/overview" />} />
|
||||
<Route path={`${path}/overview`} component={OverviewPage} />
|
||||
<Route path={`${path}/datasets`} component={DatasetVersionsPage} />
|
||||
<Route path={`${path}/training/submit`} component={TrainingSubmitPage} />
|
||||
<Route path={`${path}/training/records`} component={TrainingRecordsPage} />
|
||||
<Route path={`${path}/evaluation`} component={EvaluationPage} />
|
||||
<Route path={`${path}/promotion`} component={PromotionPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
204
platform/web/src/modules/models/pages/DatasetVersionsPage.tsx
Normal file
204
platform/web/src/modules/models/pages/DatasetVersionsPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
type VersionEntry = Record<string, unknown>;
|
||||
|
||||
export const DatasetVersionsPage: React.FC = () => {
|
||||
const [versions, setVersions] = useState<VersionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [project, setProject] = useState("dms");
|
||||
const [diffA, setDiffA] = useState("");
|
||||
const [diffB, setDiffB] = useState("");
|
||||
const [diffResult, setDiffResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const filtered = versions.filter((v) => !search || String(v.description || v.version_id || "").toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listDatasetVersions(project);
|
||||
setVersions((res.items || []) as VersionEntry[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDiff = async () => {
|
||||
if (!diffA || !diffB) return;
|
||||
try {
|
||||
const res = await hsapApi.diffDatasetVersions(diffA, diffB, project);
|
||||
setDiffResult(res);
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>数据集版本</h1>
|
||||
<p>build 入库后自动生成快照,追踪数据演化历史</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select className="form-input w-auto" value={project} onChange={(e) => setProject(e.target.value)}>
|
||||
<option value="dms">DMS</option>
|
||||
<option value="lane">Lane</option>
|
||||
</select>
|
||||
<Button variant="default" size="small" onClick={load}>刷新</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input className="form-input w-48" placeholder="搜索版本描述..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<span className="text-xs text-gray-400 self-center">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
|
||||
{/* Version diff */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">版本对比</div>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="form-group flex-1">
|
||||
<label className="form-label">版本 A (旧)</label>
|
||||
<select className="form-input" value={diffA} onChange={(e) => setDiffA(e.target.value)}>
|
||||
<option value="">选择版本...</option>
|
||||
{versions.map((v) => <option key={v._id as string} value={v._id as string}>{v.version_id as string} - {v.description as string || "无描述"}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group flex-1">
|
||||
<label className="form-label">版本 B (新)</label>
|
||||
<select className="form-input" value={diffB} onChange={(e) => setDiffB(e.target.value)}>
|
||||
<option value="">选择版本...</option>
|
||||
{versions.map((v) => <option key={v._id as string} value={v._id as string}>{v.version_id as string} - {v.description as string || "无描述"}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Button variant="default" onClick={handleDiff} disabled={!diffA || !diffB}>对比</Button>
|
||||
</div>
|
||||
{diffResult && (
|
||||
<div className="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-3 gap-4 mb-3 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">{(diffResult.v1 as Record<string,unknown>)?.id as string || "—"}</span>
|
||||
<p className="text-xs text-gray-500">图片: {(diffResult.v1 as Record<string,unknown>)?.total as number || 0}</p>
|
||||
</div>
|
||||
<div className="text-center text-2xl text-gray-400">→</div>
|
||||
<div>
|
||||
<span className="font-semibold">{(diffResult.v2 as Record<string,unknown>)?.id as string || "—"}</span>
|
||||
<p className="text-xs text-gray-500">图片: {(diffResult.v2 as Record<string,unknown>)?.total as number || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
{diffResult.image_delta != null && (
|
||||
<p className="text-sm mb-2">
|
||||
图片变化: <span className={Number(diffResult.image_delta) >= 0 ? "text-green-600" : "text-red-600"}>
|
||||
{Number(diffResult.image_delta) >= 0 ? "+" : ""}{diffResult.image_delta as number} 张
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{((diffResult.added_packs as string[])?.length > 0) && (
|
||||
<p className="text-sm text-green-600">+ 新增包: {(diffResult.added_packs as string[]).join(", ")}</p>
|
||||
)}
|
||||
{((diffResult.removed_packs as string[])?.length > 0) && (
|
||||
<p className="text-sm text-red-600">- 移除包: {(diffResult.removed_packs as string[]).join(", ")}</p>
|
||||
)}
|
||||
{((diffResult.pack_changes as unknown[])?.length > 0) && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-semibold text-gray-500 mb-1">数据包变化详情:</p>
|
||||
{(diffResult.pack_changes as Record<string, unknown>[]).map((pc, i) => (
|
||||
<div key={i} className="text-xs text-gray-600 ml-2">
|
||||
{pc.pack as string}: train {(pc.v1 as Record<string,number>).train}→{(pc.v2 as Record<string,number>).train}, val {(pc.v1 as Record<string,number>).val}→{(pc.v2 as Record<string,number>).val}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version list */}
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无数据集版本,请创建第一个快照">
|
||||
<div className="space-y-4">
|
||||
{filtered.map((v) => {
|
||||
const summary = v.summary as Record<string, unknown> | undefined;
|
||||
const diff = v.diff as Record<string, unknown> | undefined;
|
||||
const isExpanded = expandedId === v._id;
|
||||
return (
|
||||
<div key={v._id as string} className="card">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="info">{v.version_id as string}</Badge>
|
||||
<span className="text-sm font-medium">{v.description as string || "无描述"}</span>
|
||||
{(v.parent_version as string) && (
|
||||
<span className="text-xs text-gray-400">
|
||||
基于 <span className="font-mono">{v.parent_version as string}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>{v.created_at as string}</span>
|
||||
<span>{v.author as string || "—"}</span>
|
||||
<button onClick={() => setExpandedId(isExpanded ? null : (v._id as string))}
|
||||
className="text-blue-600 hover:underline">{isExpanded ? "收起" : "详情"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span>📦 {summary?.packs_count as number || 0} 个包</span>
|
||||
<span>🖼️ {summary?.total_images as number || 0} 张图</span>
|
||||
<span>🏷️ {summary?.total_labels as number || 0} 个标注</span>
|
||||
<span>📋 {summary?.batches_count as number || 0} 个批次</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold text-xs text-gray-500 mb-1">数据包</p>
|
||||
{Object.entries((v.packs || {}) as Record<string, Record<string, number>>).map(([name, info]) => (
|
||||
<div key={name} className="text-xs font-mono text-gray-600">
|
||||
{name}: train={info.train_images} val={info.val_images} test={info.test_images}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-xs text-gray-500 mb-1">批次列表</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{((v.batches || []) as string[]).map((b) => (
|
||||
<Badge key={b} size="small">{b}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{diff && (
|
||||
<div className="mt-3">
|
||||
<p className="font-semibold text-xs text-gray-500 mb-1">相对父版本变化</p>
|
||||
{((diff.added_packs as string[])?.length > 0) && (
|
||||
<p className="text-xs text-green-600">+ 包: {(diff.added_packs as string[]).join(", ")}</p>
|
||||
)}
|
||||
{((diff.removed_packs as string[])?.length > 0) && (
|
||||
<p className="text-xs text-red-600">- 包: {(diff.removed_packs as string[]).join(", ")}</p>
|
||||
)}
|
||||
{((diff.added_batches as string[])?.length > 0) && (
|
||||
<p className="text-xs text-green-600">+ 批次: {(diff.added_batches as string[]).join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Link to="/labeling/catalog" className="text-blue-600 text-xs hover:underline">查看数据目录 →</Link>
|
||||
<Link to="/models/training/records" className="text-blue-600 text-xs hover:underline">查看训练记录 →</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
platform/web/src/modules/models/pages/EvaluationPage.tsx
Normal file
150
platform/web/src/modules/models/pages/EvaluationPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type EvalRecord = Record<string, unknown>;
|
||||
|
||||
export const EvaluationPage: React.FC = () => {
|
||||
const [records, setRecords] = useState<EvalRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const load = async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listTrainingRecords({ offset: newOffset, limit: newLimit });
|
||||
const all = (res.items || []) as EvalRecord[];
|
||||
const evalRecs = all.filter((r) => {
|
||||
const a = (r.action as string || "").toLowerCase();
|
||||
return a.includes("eval") || a.includes("promote");
|
||||
});
|
||||
setRecords(evalRecs);
|
||||
setTotal(evalRecs.length);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// Extract metric data for chart
|
||||
const metricData = useMemo(() => {
|
||||
const data: { label: string; map50: number; map50_95: number }[] = [];
|
||||
for (const r of records) {
|
||||
const result = r.result as Record<string, unknown> | undefined;
|
||||
const metrics = result?.metrics as Record<string, number> | undefined;
|
||||
if (metrics?.map50 != null) {
|
||||
data.push({
|
||||
label: `${r.task || "?"}/${r.pack || "?"}`.slice(0, 24),
|
||||
map50: metrics.map50,
|
||||
map50_95: metrics.map50_95 ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}, [records]);
|
||||
|
||||
const maxMap50 = Math.max(...metricData.map((d) => d.map50), 0.01);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>评估管理</h1>
|
||||
<p>模型评估结果与指标对比</p>
|
||||
</div>
|
||||
|
||||
{/* mAP comparison bar chart */}
|
||||
{metricData.length > 0 && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">mAP 指标对比</div>
|
||||
<div className="space-y-2">
|
||||
{metricData.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-32 truncate font-mono text-xs text-gray-500" title={d.label}>{d.label}</span>
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<div className="flex-1 h-5 bg-gray-100 rounded relative overflow-hidden">
|
||||
<div className="absolute inset-y-0 left-0 bg-blue-600 rounded" style={{ width: `${(d.map50 / maxMap50) * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-14 text-right font-mono text-xs">{d.map50.toFixed(3)}</span>
|
||||
</div>
|
||||
{d.map50_95 > 0 && (
|
||||
<div className="flex items-center gap-1 flex-1">
|
||||
<span className="text-[10px] text-gray-400 w-16 text-right">mAP50-95</span>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded relative overflow-hidden">
|
||||
<div className="absolute inset-y-0 left-0 bg-green-500 rounded" style={{ width: `${(d.map50_95 / maxMap50) * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-14 text-right font-mono text-xs">{d.map50_95.toFixed(3)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation record list */}
|
||||
<PageQueryState loading={loading} error={error} empty={records.length === 0} emptyMessage="暂无评估记录">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-8" />
|
||||
<th>Job ID</th>
|
||||
<th>项目</th>
|
||||
<th>任务</th>
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>mAP@50</th>
|
||||
<th>mAP@50-95</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => {
|
||||
const rid = r.id as string;
|
||||
const result = r.result as Record<string, unknown> | undefined;
|
||||
const metrics = result?.metrics as Record<string, number> | undefined;
|
||||
return (
|
||||
<React.Fragment key={rid}>
|
||||
<tr className={expandedId === rid ? "bg-blue-50/50" : ""}>
|
||||
<td>
|
||||
<button onClick={() => setExpandedId(expandedId === rid ? null : rid)} className="text-gray-400 hover:text-blue-600 text-xs px-1">
|
||||
{expandedId === rid ? "▼" : "▶"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="font-mono text-xs">{rid.slice(0, 16)}...</td>
|
||||
<td>{r.project as string || "—"}</td>
|
||||
<td>{r.task as string || "—"}</td>
|
||||
<td>{r.action as string || "—"}</td>
|
||||
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
|
||||
<td className="font-mono text-xs">{metrics?.map50?.toFixed(4) || "—"}</td>
|
||||
<td className="font-mono text-xs">{metrics?.map50_95?.toFixed(4) || "—"}</td>
|
||||
<td className="text-xs text-gray-500">{r.created_at as string || "—"}</td>
|
||||
</tr>
|
||||
{expandedId === rid && (
|
||||
<tr>
|
||||
<td colSpan={9} className="bg-gray-50 p-4">
|
||||
{result ? (
|
||||
<pre className="text-xs bg-white border rounded p-3 max-h-48 overflow-auto">{JSON.stringify(result, null, 2)}</pre>
|
||||
) : <p className="text-gray-400 text-sm">暂无详细结果</p>}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
128
platform/web/src/modules/models/pages/OverviewPage.tsx
Normal file
128
platform/web/src/modules/models/pages/OverviewPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
type ModelEntry = { name?: string; version?: string; task?: string; project?: string; metrics?: Record<string, number>; status?: string; created_at?: string };
|
||||
|
||||
export const OverviewPage: React.FC = () => {
|
||||
const [registry, setRegistry] = useState<Record<string, unknown> | null>(null);
|
||||
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
hsapApi.getModelRegistry("dms"),
|
||||
hsapApi.listTrainingRecords({ limit: 5 }),
|
||||
])
|
||||
.then(([reg, rec]) => { setRegistry(reg); setRecords((rec.items || []) as Record<string, unknown>[]); })
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const models = (registry?.models || []) as ModelEntry[];
|
||||
const activePacks = (registry?.active_packs || []) as string[];
|
||||
const completedCount = records.filter((r) => r.status === "completed").length;
|
||||
const runningCount = records.filter((r) => r.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>模型概览</h1>
|
||||
<p>模型注册表与最近训练动态</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-blue-700">{models.length || "—"}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">已注册模型</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{completedCount}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">已完成训练</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-orange-600">{runningCount}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">执行中</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-gray-600">{activePacks.length || "—"}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">活跃数据包</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model registry */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">模型注册表</div>
|
||||
{models.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无已注册模型</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模型名称</th>
|
||||
<th>任务</th>
|
||||
<th>版本</th>
|
||||
<th>mAP@50</th>
|
||||
<th>mAP@50-95</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((m, i) => (
|
||||
<tr key={m.name || i}>
|
||||
<td className="font-medium">{m.name || m.version || `模型 #${i + 1}`}</td>
|
||||
<td>{m.task || "—"}</td>
|
||||
<td className="font-mono text-xs">{m.version || "—"}</td>
|
||||
<td className="font-mono">{m.metrics?.map50?.toFixed(4) || "—"}</td>
|
||||
<td className="font-mono">{m.metrics?.map50_95?.toFixed(4) || "—"}</td>
|
||||
<td><Badge variant={m.status === "production" ? "success" : "info"}>{m.status || "experiment"}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent training records */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>最近训练记录</span>
|
||||
<Link to="/models/training/records" className="text-blue-600 text-sm hover:underline">查看全部 →</Link>
|
||||
</div>
|
||||
{records.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无训练记录</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr><th>Job ID</th><th>操作</th><th>状态</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.slice(0, 5).map((r) => (
|
||||
<tr key={r.id as string}>
|
||||
<td className="font-mono text-xs">{(r.id as string).slice(0, 16)}...</td>
|
||||
<td>{r.action as string}</td>
|
||||
<td><Badge variant={r.status === "completed" ? "success" : r.status === "failed" ? "danger" : "warning"}>{r.status as string}</Badge></td>
|
||||
<td className="text-xs text-gray-500">{r.created_at as string}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Link to="/models/training/submit"><Button variant="primary" size="small">提交训练</Button></Link>
|
||||
<Link to="/models/evaluation"><Button variant="default" size="small">查看评估</Button></Link>
|
||||
<Link to="/models/promotion"><Button variant="default" size="small">模型晋级</Button></Link>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
154
platform/web/src/modules/models/pages/PromotionPage.tsx
Normal file
154
platform/web/src/modules/models/pages/PromotionPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
type ModelEntry = { name?: string; version?: string; task?: string; metrics?: Record<string, number>; status?: string };
|
||||
|
||||
export const PromotionPage: React.FC = () => {
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [history, setHistory] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [project, setProject] = useState("dms");
|
||||
const [task, setTask] = useState("");
|
||||
const [version, setVersion] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const [reg, rec] = await Promise.all([
|
||||
hsapApi.getModelRegistry(project, task || undefined),
|
||||
hsapApi.listTrainingRecords({ project, limit: 20 }),
|
||||
]);
|
||||
setModels((reg.models || []) as ModelEntry[]);
|
||||
setHistory(((rec.items || []) as Record<string, unknown>[]).filter((r) => {
|
||||
const a = (r.action as string || "").toLowerCase();
|
||||
return a.includes("promote");
|
||||
}));
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [project]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!version.trim()) { setError("请选择或输入模型版本"); return; }
|
||||
setSubmitting(true); setError(null); setResult(null);
|
||||
try {
|
||||
const res = await hsapApi.createTrainingRecord(`promote_${project}`, { project, task: task.trim(), version: version.trim() }, note.trim() || undefined) as Record<string, unknown>;
|
||||
setResult(`晋级申请已提交 · Job: ${res.id as string}`);
|
||||
load();
|
||||
} catch (e) { setError(String(e)); }
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>模型晋级</h1>
|
||||
<p>将评估通过的模型晋升为生产版本</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Submit form */}
|
||||
<div className="card">
|
||||
<div className="card-header">提交晋级申请</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">项目</label>
|
||||
<select className="form-input" value={project} onChange={(e) => { setProject(e.target.value); setVersion(""); }}>
|
||||
<option value="dms">DMS</option>
|
||||
<option value="lane">Lane</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">任务</label>
|
||||
<input className="form-input" value={task} onChange={(e) => setTask(e.target.value)} placeholder="如 ddaw, dam" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">模型版本 *</label>
|
||||
<select className="form-input" value={version} onChange={(e) => setVersion(e.target.value)}>
|
||||
<option value="">选择版本...</option>
|
||||
{models.filter((m) => !task || m.task === task).map((m, i) => (
|
||||
<option key={i} value={m.version || m.name || ""}>
|
||||
{m.version || m.name || `#${i}`} {m.metrics?.map50 != null ? `(mAP50=${m.metrics.map50.toFixed(3)})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">或手动输入版本号:</p>
|
||||
<input className="form-input mt-1" value={version} onChange={(e) => setVersion(e.target.value)} placeholder="如 v2.1.0" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">晋级理由</label>
|
||||
<textarea className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="说明晋级原因(可选)" rows={3} />
|
||||
</div>
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded p-3 mb-3 text-sm text-red-700">{error}</div>}
|
||||
{result && <div className="bg-green-50 border border-green-200 rounded p-3 mb-3 text-sm text-green-700">{result}</div>}
|
||||
<Button variant="primary" onClick={handleSubmit} loading={submitting}>提交晋级审核</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: Promotion history */}
|
||||
<div className="card">
|
||||
<div className="card-header">晋级历史</div>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无晋级记录</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr><th>Job ID</th><th>任务</th><th>版本</th><th>状态</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((r) => {
|
||||
const params = r.params as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<tr key={r.id as string}>
|
||||
<td className="font-mono text-xs">{(r.id as string).slice(0, 12)}...</td>
|
||||
<td>{params?.task as string || "—"}</td>
|
||||
<td className="font-mono text-xs">{params?.version as string || "—"}</td>
|
||||
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500">{r.created_at as string}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Candidate models */}
|
||||
{models.length > 0 && (
|
||||
<div className="card mt-4">
|
||||
<div className="card-header">候选模型 ({project})</div>
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr><th>版本</th><th>任务</th><th>mAP@50</th><th>mAP@50-95</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.filter((m) => !task || m.task === task).map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-xs font-medium">{m.version || m.name || "—"}</td>
|
||||
<td>{m.task || "—"}</td>
|
||||
<td className="font-mono text-xs">{m.metrics?.map50?.toFixed(4) || "—"}</td>
|
||||
<td className="font-mono text-xs">{m.metrics?.map50_95?.toFixed(4) || "—"}</td>
|
||||
<td>{m.status || "experiment"}</td>
|
||||
<td>
|
||||
<Button size="small" variant="primary" onClick={() => { setVersion(m.version || m.name || ""); }}>
|
||||
选择晋级
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
178
platform/web/src/modules/models/pages/TrainingRecordsPage.tsx
Normal file
178
platform/web/src/modules/models/pages/TrainingRecordsPage.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge, Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type TrainingRecord = Record<string, unknown>;
|
||||
|
||||
export const TrainingRecordsPage: React.FC = () => {
|
||||
const [records, setRecords] = useState<TrainingRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [project, setProject] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [detailCache, setDetailCache] = useState<Record<string, TrainingRecord | null>>({});
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listTrainingRecords({
|
||||
project: project || undefined,
|
||||
status: status || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setRecords((res.items || []) as TrainingRecord[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [project, status]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Auto-refresh every 15 seconds
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => load(offset, limit), 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [load, offset, limit]);
|
||||
|
||||
const handleToggleDetail = async (jobId: string) => {
|
||||
if (expandedId === jobId) { setExpandedId(null); return; }
|
||||
setExpandedId(jobId);
|
||||
if (!detailCache[jobId]) {
|
||||
try {
|
||||
const detail = await hsapApi.getTrainingRecord(jobId);
|
||||
setDetailCache((prev) => ({ ...prev, [jobId]: detail }));
|
||||
} catch {
|
||||
setDetailCache((prev) => ({ ...prev, [jobId]: null }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const detail = expandedId ? detailCache[expandedId] : null;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>训练记录</h1>
|
||||
<p>查看所有训练/评估任务,每 15 秒自动刷新</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>手动刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
<input className="form-input w-44" placeholder="搜索 Job ID/任务..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
<select className="form-input w-auto" value={project} onChange={(e) => { setProject(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部项目</option>
|
||||
<option value="dms">DMS</option>
|
||||
<option value="lane">Lane</option>
|
||||
</select>
|
||||
<select className="form-input w-auto" value={status} onChange={(e) => { setStatus(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={records.length === 0} emptyMessage="暂无训练记录">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-10" />
|
||||
<th>Job ID</th>
|
||||
<th>项目</th>
|
||||
<th>任务</th>
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>数据包</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => {
|
||||
const rid = r.id as string;
|
||||
const isExpanded = expandedId === rid;
|
||||
return (
|
||||
<React.Fragment key={rid}>
|
||||
<tr className={isExpanded ? "bg-blue-50/50" : ""}>
|
||||
<td>
|
||||
<button onClick={() => handleToggleDetail(rid)} className="text-gray-400 hover:text-blue-600 text-xs px-1">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="font-mono text-xs">{rid.slice(0, 16)}...</td>
|
||||
<td>{r.project as string || "—"}</td>
|
||||
<td>{r.task as string || "—"}</td>
|
||||
<td><Badge variant="info">{(r.action as string || "—").replace(/_/g, " ")}</Badge></td>
|
||||
<td><StatusBadge status={(r.status as string) || "pending"} /></td>
|
||||
<td className="text-xs font-mono">{r.pack as string || "—"}</td>
|
||||
<td className="text-xs text-gray-500 whitespace-nowrap">{r.created_at as string || "—"}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${rid}-detail`}>
|
||||
<td colSpan={8} className="bg-gray-50 p-4">
|
||||
{detail === undefined ? (
|
||||
<p className="text-gray-400 text-sm">加载中...</p>
|
||||
) : detail === null ? (
|
||||
<p className="text-red-400 text-sm">加载失败</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold mb-2 text-gray-700">参数</p>
|
||||
<pre className="text-xs bg-white border rounded p-3 max-h-48 overflow-auto">
|
||||
{JSON.stringify(detail.params || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold mb-2 text-gray-700">结果</p>
|
||||
{detail.result ? (
|
||||
<pre className="text-xs bg-white border rounded p-3 max-h-48 overflow-auto">
|
||||
{JSON.stringify(detail.result, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{detail.status === "running" ? "执行中..." : detail.status === "failed" ? `失败: ${detail.error || "未知错误"}` : "暂无结果"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{detail.note != null && String(detail.note) && (
|
||||
<div className="col-span-2">
|
||||
<p className="font-semibold mb-1 text-gray-700">备注</p>
|
||||
<p className="text-sm text-gray-600">{String(detail.note ?? "")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
platform/web/src/modules/models/pages/TrainingSubmitPage.tsx
Normal file
95
platform/web/src/modules/models/pages/TrainingSubmitPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
train_dms: "DMS 训练", train_lane: "Lane 训练",
|
||||
eval_dms: "DMS 评估", eval_lane: "Lane 评估",
|
||||
promote_dms: "DMS 晋级", promote_lane: "Lane 晋级",
|
||||
pipeline_dms: "DMS 全流程",
|
||||
};
|
||||
|
||||
export const TrainingSubmitPage: React.FC = () => {
|
||||
const [action, setAction] = useState("train_dms");
|
||||
const [project, setProject] = useState("dms");
|
||||
const [task, setTask] = useState("");
|
||||
const [pack, setPack] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!task.trim()) e.task = "请输入任务名称";
|
||||
if (["train_dms", "train_lane", "eval_dms", "eval_lane"].includes(action) && !pack.trim()) e.pack = "请输入数据包名称";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await hsapApi.createTrainingRecord(action, { project, task: task.trim(), pack: pack.trim() }, note.trim() || undefined) as Record<string, unknown>;
|
||||
setResult(res);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>训练提交</h1>
|
||||
<p>提交训练/评估任务,进入审核队列</p>
|
||||
</div>
|
||||
|
||||
<div className="card max-w-lg">
|
||||
<div className="form-group">
|
||||
<label className="form-label">操作类型 *</label>
|
||||
<select className="form-input" value={action} onChange={(e) => { setAction(e.target.value); setErrors({}); }}>
|
||||
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{k} — {v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">项目 *</label>
|
||||
<select className="form-input" value={project} onChange={(e) => setProject(e.target.value)}>
|
||||
<option value="dms">DMS</option>
|
||||
<option value="lane">Lane</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">任务 *</label>
|
||||
<input className={`form-input ${errors.task ? "border-red-500" : ""}`} value={task} onChange={(e) => { setTask(e.target.value); setErrors((p) => ({ ...p, task: "" })); }} placeholder="如 ddaw, dam, lane_v1" />
|
||||
{errors.task && <p className="text-red-500 text-xs mt-1">{errors.task}</p>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">数据包</label>
|
||||
<input className={`form-input ${errors.pack ? "border-red-500" : ""}`} value={pack} onChange={(e) => { setPack(e.target.value); setErrors((p) => ({ ...p, pack: "" })); }} placeholder="如 dms_v1" />
|
||||
{errors.pack && <p className="text-red-500 text-xs mt-1">{errors.pack}</p>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">备注</label>
|
||||
<textarea className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="可选备注信息" rows={3} />
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded p-3 mb-3 text-sm text-red-700">{error}</div>}
|
||||
{result && (
|
||||
<div className="bg-green-50 border border-green-200 rounded p-3 mb-3">
|
||||
<p className="text-green-700 text-sm font-medium">提交成功!</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Job ID: {result.id as string}</p>
|
||||
<Link to="/models/training/records" className="text-blue-600 text-xs hover:underline mt-1 inline-block">查看训练记录 →</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="primary" onClick={handleSubmit} loading={submitting}>提交审核</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
platform/web/src/modules/system/SystemShell.tsx
Normal file
49
platform/web/src/modules/system/SystemShell.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { Switch, Route, Redirect, NavLink, useRouteMatch } from "react-router-dom";
|
||||
import { ModuleGuard } from "@/components/ModuleGuard";
|
||||
import { AuditQueuePage } from "./pages/AuditQueuePage";
|
||||
import { AuditDetailPage } from "./pages/AuditDetailPage";
|
||||
import { JobMonitorPage } from "./pages/JobMonitorPage";
|
||||
import { ExecutionLogsPage } from "./pages/ExecutionLogsPage";
|
||||
import { UserManagementPage } from "./pages/UserManagementPage";
|
||||
import { AuditLogPage } from "./pages/AuditLogPage";
|
||||
|
||||
const TABS = [
|
||||
{ to: "/system/audit", label: "审核队列", perm: "read:audit" },
|
||||
{ to: "/system/jobs", label: "任务监控", perm: "read:jobs" },
|
||||
{ to: "/system/audit-log", label: "操作日志", perm: "admin:users" },
|
||||
{ to: "/system/logs", label: "Agent 追踪", perm: "" },
|
||||
{ to: "/system/users", label: "用户管理", perm: "admin:users" },
|
||||
];
|
||||
|
||||
export const SystemShell: React.FC = () => {
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:audit", "read:jobs", "admin:users", ""]}>
|
||||
<div>
|
||||
<nav className="module-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className="module-tab"
|
||||
activeClassName="active"
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Switch>
|
||||
<Route exact path={`${path}`} render={() => <Redirect to="/system/audit" />} />
|
||||
<Route exact path={`${path}/audit`} component={AuditQueuePage} />
|
||||
<Route path={`${path}/audit/:id`} component={AuditDetailPage} />
|
||||
<Route path={`${path}/jobs`} component={JobMonitorPage} />
|
||||
<Route path={`${path}/audit-log`} component={AuditLogPage} />
|
||||
<Route path={`${path}/logs`} component={ExecutionLogsPage} />
|
||||
<Route path={`${path}/users`} component={UserManagementPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
212
platform/web/src/modules/system/pages/AuditDetailPage.tsx
Normal file
212
platform/web/src/modules/system/pages/AuditDetailPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type RejectionCategory = { key: string; label: string };
|
||||
|
||||
export const AuditDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { hasPermission: can } = useAuth();
|
||||
const isReviewer = can("write:approval_review") || can("*");
|
||||
|
||||
const [approval, setApproval] = useState<Record<string, unknown> | null>(null);
|
||||
const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
|
||||
const [images, setImages] = useState<{ id: string; url?: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [rejectCategory, setRejectCategory] = useState("");
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [categories, setCategories] = useState<RejectionCategory[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
hsapApi.getApproval(id),
|
||||
hsapApi.getApprovalPreview(id).catch(() => null),
|
||||
]);
|
||||
setApproval(a); setPreview(p as Record<string, unknown> | null);
|
||||
const imgRes = await hsapApi.listApprovalImages(id, 0, 60).catch(() => null);
|
||||
const items = (imgRes?.items || []) as { id: string }[];
|
||||
const withUrls = await Promise.all(items.slice(0, 12).map(async (img) => {
|
||||
try { return { ...img, url: await hsapApi.fetchApprovalImageBlob(id, img.id, true) }; }
|
||||
catch { return img; }
|
||||
}));
|
||||
setImages(withUrls);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [id]);
|
||||
useEffect(() => {
|
||||
if (isReviewer) { hsapApi.approvalRejectionCategories().then((r) => setCategories(r.categories || [])); }
|
||||
}, [isReviewer]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setBusy(true);
|
||||
try { await hsapApi.approve(id!); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setBusy(true);
|
||||
try { await hsapApi.reject(id!, rejectComment || undefined, rejectCategory || undefined); load(); setRejecting(false); }
|
||||
catch (e) { setError(String(e)); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<Link to="/system/audit" className="text-blue-600 text-sm hover:underline mb-2 inline-block">← 返回审核队列</Link>
|
||||
<h1>审核详情</h1>
|
||||
<p className="font-mono text-xs text-gray-400">ID: {id}</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{approval && (
|
||||
<>
|
||||
{/* Summary card */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>{approval.action_label as string || approval.action as string || "审核单"}</span>
|
||||
<StatusBadge status={(approval.status as string) || "pending"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">提单人: </span><span>{approval.submitted_by as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">审核人: </span><span>{approval.reviewed_by as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">提交时间: </span><span className="text-xs">{approval.submitted_at as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">审核时间: </span><span className="text-xs">{approval.reviewed_at as string || "—"}</span>
|
||||
</div>
|
||||
{approval.note != null && String(approval.note) && <div className="col-span-2"><span className="text-gray-500">备注: </span>{String(approval.note)}</div>}
|
||||
{approval.review_comment != null && String(approval.review_comment) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">审核意见: </span>
|
||||
<span className="text-gray-800">{String(approval.review_comment ?? "")}</span>
|
||||
</div>
|
||||
)}
|
||||
{approval.rejection_category != null && String(approval.rejection_category) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">驳回分类: </span>
|
||||
<Badge variant="danger">{String(approval.rejection_category ?? "")}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job link */}
|
||||
{approval.job_id != null && String(approval.job_id) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">关联任务: </span>
|
||||
<Link to={`/system/jobs`} className="text-blue-600 text-sm hover:underline font-mono">
|
||||
{String(approval.job_id ?? "")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview - structured view */}
|
||||
{preview && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">变更详情</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{(preview.task as string) && (
|
||||
<div><span className="text-gray-500">任务: </span><Badge variant="info">{preview.task as string}</Badge></div>
|
||||
)}
|
||||
{(preview.pack as string) && (
|
||||
<div><span className="text-gray-500">数据包: </span><Badge variant="default">{preview.pack as string}</Badge></div>
|
||||
)}
|
||||
{(preview.scope_label as string) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">范围: </span>
|
||||
<span>{String(preview.scope_label ?? "")}</span>
|
||||
</div>
|
||||
)}
|
||||
{(preview.class_names as Record<string, string>) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500 text-xs">类别: </span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(preview.class_names as Record<string, string>).map(([k, v]) => (
|
||||
<Badge key={k} size="small" variant="default">{v || k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Params detail */}
|
||||
{approval.params != null && typeof approval.params === "object" && Object.keys(approval.params as Record<string, unknown>).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 font-semibold">参数明细</span>
|
||||
<pre className="text-xs mt-1 bg-gray-50 p-2 rounded max-h-40 overflow-auto">
|
||||
{JSON.stringify(approval.params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">图片预览 ({images.length})</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="w-24 h-24 bg-gray-100 rounded overflow-hidden">
|
||||
{img.url ? (
|
||||
<img src={img.url} alt={img.id} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-xs">{img.id.slice(0, 8)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === "pending" && isReviewer && (
|
||||
<div className="card">
|
||||
{!rejecting ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="success" onClick={handleApprove} loading={busy}>✓ 通过</Button>
|
||||
<Button variant="danger" onClick={() => setRejecting(true)}>✗ 驳回</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">驳回审核</p>
|
||||
<div className="space-y-3">
|
||||
<select className="form-input max-w-xs" value={rejectCategory} onChange={(e) => setRejectCategory(e.target.value)}>
|
||||
<option value="">选择驳回原因分类</option>
|
||||
{categories.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<textarea className="form-input max-w-md" value={rejectComment} onChange={(e) => setRejectComment(e.target.value)} placeholder="补充说明(可选)" rows={3} />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="danger" onClick={handleReject} loading={busy} disabled={!rejectCategory}>确认驳回</Button>
|
||||
<Button variant="default" onClick={() => { setRejecting(false); setRejectCategory(""); setRejectComment(""); }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
platform/web/src/modules/system/pages/AuditLogPage.tsx
Normal file
111
platform/web/src/modules/system/pages/AuditLogPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type LogEntry = {
|
||||
id: number; timestamp: string; user_id: number; user_name: string;
|
||||
category: string; action: string; target_type: string; target_id: string;
|
||||
summary: string; detail: Record<string, unknown> | null; ip_address: string;
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { label: string; color: "info" | "success" | "warning" | "danger" | "default" }> = {
|
||||
auth: { label: "认证", color: "info" },
|
||||
data: { label: "数据", color: "success" },
|
||||
labeling: { label: "标注", color: "warning" },
|
||||
audit: { label: "审核", color: "danger" },
|
||||
training: { label: "训练", color: "info" },
|
||||
system: { label: "系统", color: "default" },
|
||||
};
|
||||
|
||||
export const AuditLogPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 30) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set("category", category);
|
||||
if (search) params.set("search", search);
|
||||
params.set("offset", String(newOffset));
|
||||
params.set("limit", String(newLimit));
|
||||
const res = await fetch(`/api/v1/system/audit-log?${params}`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setLogs((res.items || []) as LogEntry[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [category, search]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const catBadge = (cat: string) => {
|
||||
const c = CATEGORY_LABELS[cat] || { label: cat, color: "default" as const };
|
||||
return <Badge variant={c.color} size="small">{c.label}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>操作日志</h1>
|
||||
<p>平台所有关键操作的审计记录,保留 90 天</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select className="form-input w-auto" value={category} onChange={(e) => { setCategory(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<input className="form-input w-48" placeholder="搜索用户/摘要..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={logs.length === 0} emptyMessage="暂无操作记录">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="space-y-0 divide-y divide-gray-100">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="py-2.5 px-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Userpic username={log.user_name} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{log.user_name || "系统"}</span>
|
||||
{catBadge(log.category)}
|
||||
<span className="text-sm text-gray-700 truncate">{log.summary || log.action}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-gray-400">
|
||||
<span>{log.timestamp || "—"}</span>
|
||||
{log.target_type && <span>· {log.target_type}/{log.target_id?.slice(0, 16)}</span>}
|
||||
<button onClick={() => setExpandedId(expandedId === log.id ? null : log.id)} className="text-blue-500 hover:underline">
|
||||
{expandedId === log.id ? "收起" : "详情"}
|
||||
</button>
|
||||
</div>
|
||||
{expandedId === log.id && log.detail && (
|
||||
<pre className="text-xs mt-1 bg-gray-50 p-2 rounded max-h-32 overflow-auto">
|
||||
{JSON.stringify(log.detail, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
platform/web/src/modules/system/pages/AuditQueuePage.tsx
Normal file
200
platform/web/src/modules/system/pages/AuditQueuePage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type RejectionCategory = { key: string; label: string };
|
||||
|
||||
export const AuditQueuePage: React.FC = () => {
|
||||
const { hasPermission: can } = useAuth();
|
||||
const isReviewer = can("write:approval_review") || can("*");
|
||||
const [approvals, setApprovals] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchMode, setBatchMode] = useState<"" | "approve" | "reject">("");
|
||||
const [rejectCategory, setRejectCategory] = useState("");
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const [batchBusy, setBatchBusy] = useState(false);
|
||||
const [categories, setCategories] = useState<RejectionCategory[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listApprovals({ status: filterStatus || undefined, offset: newOffset, limit: newLimit });
|
||||
let items = (res.items || []) as Record<string, unknown>[];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((a) =>
|
||||
String(a.action_label || a.action || "").toLowerCase().includes(q) ||
|
||||
String(a.submitted_by || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
setApprovals(items);
|
||||
setTotal(search ? items.length : res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [filterStatus]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
if (isReviewer) { hsapApi.approvalRejectionCategories().then((r) => setCategories(r.categories || [])); }
|
||||
}, [isReviewer]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === approvals.length) { setSelected(new Set()); }
|
||||
else { setSelected(new Set(approvals.filter((a) => a.status === "pending").map((a) => a.id as string))); }
|
||||
};
|
||||
|
||||
const handleBatchAction = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setBatchBusy(true);
|
||||
const ids = [...selected];
|
||||
try {
|
||||
if (batchMode === "approve") {
|
||||
await hsapApi.batchApprove(ids);
|
||||
} else if (batchMode === "reject") {
|
||||
await hsapApi.batchReject(ids, rejectComment || undefined, rejectCategory || undefined);
|
||||
}
|
||||
setSelected(new Set());
|
||||
setBatchMode("");
|
||||
load(offset, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setBatchBusy(false);
|
||||
};
|
||||
|
||||
const handleSingleApprove = async (id: string) => {
|
||||
try { await hsapApi.approve(id); load(offset, limit); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>审核队列</h1>
|
||||
<p>审核平台中所有操作请求</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter + Batch bar */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<input className="form-input w-44" placeholder="搜索操作/提单人..." value={search} onChange={(e) => { setSearch(e.target.value); }} />
|
||||
<select className="form-input w-auto" value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setSelected(new Set()); }}>
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
</select>
|
||||
{isReviewer && selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">{selected.size} 项已选</span>
|
||||
{!batchMode ? (
|
||||
<>
|
||||
<Button size="small" variant="success" onClick={() => setBatchMode("approve")}>批量通过</Button>
|
||||
<Button size="small" variant="danger" onClick={() => setBatchMode("reject")}>批量驳回</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-sm font-medium">{batchMode === "approve" ? "批量通过" : "批量驳回"}</span>
|
||||
{batchMode === "reject" && (
|
||||
<>
|
||||
<select className="form-input w-auto text-xs" value={rejectCategory} onChange={(e) => setRejectCategory(e.target.value)}>
|
||||
<option value="">选择原因</option>
|
||||
{categories.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<input className="form-input w-40 text-xs" value={rejectComment} onChange={(e) => setRejectComment(e.target.value)} placeholder="备注(可选)" />
|
||||
</>
|
||||
)}
|
||||
<Button size="small" variant={batchMode === "approve" ? "success" : "danger"} onClick={handleBatchAction} loading={batchBusy}>确认</Button>
|
||||
<Button size="small" variant="default" onClick={() => { setBatchMode(""); setRejectCategory(""); setRejectComment(""); }}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={approvals.length === 0} emptyMessage="暂无审核记录">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{isReviewer && <th className="w-8"><input type="checkbox" onChange={toggleAll} checked={selected.size > 0 && selected.size === approvals.filter((a) => a.status === "pending").length} /></th>}
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>详情</th>
|
||||
<th>提单人</th>
|
||||
<th>审核人</th>
|
||||
<th>驳回原因</th>
|
||||
<th>时间</th>
|
||||
{isReviewer && <th>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.map((a) => {
|
||||
const params = a.params as Record<string, unknown> | undefined;
|
||||
const detail = params
|
||||
? Object.entries(params).filter(([k]) => !["project", "submitted_by"].includes(k)).map(([k, v]) => `${k}=${v}`).join(", ")
|
||||
: "";
|
||||
return (
|
||||
<tr key={a.id as string} className={a.status === "pending" && selected.has(a.id as string) ? "bg-blue-50" : ""}>
|
||||
{isReviewer && (
|
||||
<td>
|
||||
{a.status === "pending" && (
|
||||
<input type="checkbox" checked={selected.has(a.id as string)} onChange={() => toggleSelect(a.id as string)} />
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Link to={`/system/audit/${a.id}`} className="text-blue-600 hover:underline text-sm font-medium">
|
||||
{a.action_label as string || a.action as string || "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td><StatusBadge status={(a.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500 max-w-xs truncate">{detail || "—"}</td>
|
||||
<td>{a.submitted_by as string || "—"}</td>
|
||||
<td>{a.reviewed_by as string || "—"}</td>
|
||||
<td className="text-xs">
|
||||
{a.status === "rejected" && a.rejection_category ? (
|
||||
<Badge variant="danger" size="small">{a.rejection_category as string}</Badge>
|
||||
) : a.status === "rejected" ? (
|
||||
<span className="text-gray-400">—</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-xs text-gray-500 whitespace-nowrap">{a.submitted_at as string || a.created_at as string || "—"}</td>
|
||||
{isReviewer && (
|
||||
<td>
|
||||
{a.status === "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<Button size="small" variant="success" onClick={() => handleSingleApprove(a.id as string)}>通过</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
platform/web/src/modules/system/pages/ExecutionLogsPage.tsx
Normal file
91
platform/web/src/modules/system/pages/ExecutionLogsPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
export const ExecutionLogsPage: React.FC = () => {
|
||||
const [traces, setTraces] = useState<string[]>([]);
|
||||
const [selectedTrace, setSelectedTrace] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTraces = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listTraces(50);
|
||||
setTraces(res.trace_ids || []);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { loadTraces(); }, []);
|
||||
|
||||
const handleViewTrace = async (traceId: string) => {
|
||||
try {
|
||||
const t = await hsapApi.getTrace(traceId);
|
||||
setSelectedTrace(t);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>执行日志</h1>
|
||||
<p>Agent 执行 Trace 与 Span 日志查看</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Trace list */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>Trace 列表</span>
|
||||
<Button size="small" variant="default" onClick={loadTraces}>刷新</Button>
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={traces.length === 0} emptyMessage="暂无 Trace 记录">
|
||||
<div className="max-h-96 overflow-y-auto space-y-1">
|
||||
{traces.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleViewTrace(t)}
|
||||
className={`w-full text-left px-3 py-2 text-xs font-mono rounded hover:bg-blue-50 transition-colors ${
|
||||
selectedTrace && (selectedTrace.trace_id as string) === t ? "bg-blue-100 text-blue-800" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t.slice(0, 32)}...
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
|
||||
{/* Trace detail */}
|
||||
<div className="card col-span-2">
|
||||
<div className="card-header">Trace 详情</div>
|
||||
{selectedTrace ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2 font-mono">
|
||||
Trace ID: {selectedTrace.trace_id as string}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Spans: {(selectedTrace.spans as unknown[])?.length || 0}
|
||||
</p>
|
||||
<pre className="text-xs overflow-auto max-h-96 bg-gray-50 p-3 rounded">
|
||||
{JSON.stringify(selectedTrace.spans, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
请从左侧选择一个 Trace 查看详情
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
platform/web/src/modules/system/pages/JobMonitorPage.tsx
Normal file
108
platform/web/src/modules/system/pages/JobMonitorPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
export const JobMonitorPage: React.FC = () => {
|
||||
const [jobs, setJobs] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listJobs({
|
||||
status: filterStatus || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setJobs((res.items || []) as Record<string, unknown>[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [filterStatus]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => load(offset, limit), 10000);
|
||||
return () => clearInterval(t);
|
||||
}, [offset, limit, load]);
|
||||
|
||||
const handleView = async (jobId: string) => {
|
||||
try {
|
||||
const job = await hsapApi.getJob(jobId);
|
||||
alert(JSON.stringify(job, null, 2));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>任务监控</h1>
|
||||
<p>查看异步任务执行状态,每 10 秒自动刷新</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>手动刷新</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<select className="form-input w-auto" value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={jobs.length === 0} emptyMessage="暂无任务记录">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>完成时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id as string}>
|
||||
<td className="font-mono text-xs">{(j.id as string || "").slice(0, 16)}...</td>
|
||||
<td>{j.action as string || "—"}</td>
|
||||
<td><StatusBadge status={(j.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500">{j.created_at as string || "—"}</td>
|
||||
<td className="text-xs text-gray-500">{j.completed_at as string || "—"}</td>
|
||||
<td>
|
||||
<Button size="small" variant="default" onClick={() => handleView(j.id as string)}>
|
||||
详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
215
platform/web/src/modules/system/pages/UserManagementPage.tsx
Normal file
215
platform/web/src/modules/system/pages/UserManagementPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type UserRecord = {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
feishu_open_id?: string;
|
||||
feishu_union_id?: string;
|
||||
feishu_user_id?: string;
|
||||
feishu_department_ids?: string[];
|
||||
roles: { code: string; name: string }[];
|
||||
permissions: string[];
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ code: "admin", name: "管理员", color: "danger" as const },
|
||||
{ code: "reviewer", name: "审核员", color: "warning" as const },
|
||||
{ code: "engineer", name: "工程师", color: "info" as const },
|
||||
{ code: "labeler", name: "标注员", color: "success" as const },
|
||||
{ code: "viewer", name: "观察者", color: "default" as const },
|
||||
];
|
||||
|
||||
export const UserManagementPage: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [editingUser, setEditingUser] = useState<UserRecord | null>(null);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listUsers({
|
||||
search: search || undefined,
|
||||
role: roleFilter || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setUsers((res.items || []) as unknown as UserRecord[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search, roleFilter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleEditRoles = (user: UserRecord) => {
|
||||
setEditingUser(user);
|
||||
setSelectedRoles(user.roles.map((r) => r.code));
|
||||
};
|
||||
|
||||
const handleSaveRoles = async () => {
|
||||
if (!editingUser) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await hsapApi.setUserRoles(editingUser.id, selectedRoles);
|
||||
setEditingUser(null);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const roleBadge = (code: string) => {
|
||||
const opt = ROLE_OPTIONS.find((r) => r.code === code);
|
||||
return <Badge key={code} variant={opt?.color || "default"} size="small">{opt?.name || code}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>管理平台用户角色权限,关联飞书账号信息</p>
|
||||
</div>
|
||||
<Button variant="primary" size="small" onClick={async () => {
|
||||
try {
|
||||
const res = await hsapApi.syncFeishuUsers();
|
||||
alert(`同步完成!共 ${res.total} 人(新增 ${res.created},更新 ${res.updated})`);
|
||||
load(0, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
}}>
|
||||
同步飞书用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
className="form-input max-w-xs"
|
||||
placeholder="搜索姓名或邮箱..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOffset(0); }}
|
||||
/>
|
||||
<select className="form-input w-auto" value={roleFilter} onChange={(e) => { setRoleFilter(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部角色</option>
|
||||
{ROLE_OPTIONS.map((r) => <option key={r.code} value={r.code}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={users.length === 0} emptyMessage="暂无用户">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>邮箱</th>
|
||||
<th>飞书 ID</th>
|
||||
<th>飞书部门</th>
|
||||
<th>角色</th>
|
||||
<th>权限数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Userpic username={u.name} avatarUrl={u.avatar_url} size={28} />
|
||||
<span className="font-medium">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">{u.email || "—"}</td>
|
||||
<td className="text-xs font-mono text-gray-400">
|
||||
{u.feishu_user_id ? (
|
||||
<span title={`OpenID: ${u.feishu_open_id || ""} UnionID: ${u.feishu_union_id || ""}`}>
|
||||
{(u.feishu_user_id as string).slice(0, 12)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">非飞书用户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">
|
||||
{u.feishu_department_ids?.length ? `${u.feishu_department_ids.length} 个部门` : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1 flex-wrap">{u.roles.map((r) => roleBadge(r.code))}</div>
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">{u.permissions?.length || 0}</td>
|
||||
<td>
|
||||
<Button size="small" variant="default" onClick={() => handleEditRoles(u)}>编辑角色</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
|
||||
{/* Role Edit Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setEditingUser(null)}>
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-1">编辑角色 — {editingUser.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{editingUser.email || "—"}</p>
|
||||
|
||||
{editingUser.feishu_open_id && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-xs text-gray-500 space-y-1">
|
||||
<p><span className="font-medium">飞书 Open ID:</span> {editingUser.feishu_open_id}</p>
|
||||
{editingUser.feishu_union_id && <p><span className="font-medium">Union ID:</span> {editingUser.feishu_union_id}</p>}
|
||||
{editingUser.feishu_user_id && <p><span className="font-medium">User ID:</span> {editingUser.feishu_user_id}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label key={role.code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 rounded p-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles.includes(role.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedRoles([...selectedRoles, role.code]);
|
||||
else setSelectedRoles(selectedRoles.filter((r) => r !== role.code));
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<Badge variant={role.color} size="small">{role.name}</Badge>
|
||||
<span className="text-xs text-gray-400">{role.code}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="default" size="small" onClick={() => setEditingUser(null)}>取消</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSaveRoles} loading={saving}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,367 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,651 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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>DMS(YOLO)一键训练/评估/可视化</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>Lane(UFLD)一键训练/评估{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
72
platform/web/src/pages/LoginPage.tsx
Normal file
72
platform/web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const { authConfig, loginDev, loginFeishu, loading } = useAuth();
|
||||
const [devName, setDevName] = useState("开发用户");
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-400 animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 w-full max-w-sm">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">HSAP</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">华胥 Sentinel 主动安全平台</p>
|
||||
</div>
|
||||
|
||||
{/* Feishu login */}
|
||||
{authConfig?.feishu_enabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full mb-3"
|
||||
onClick={loginFeishu}
|
||||
>
|
||||
飞书账号登录
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Dev login */}
|
||||
{authConfig?.dev_auth_enabled && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-xs text-gray-400 mb-2 text-center">开发模式</p>
|
||||
<input
|
||||
className="form-input mb-2"
|
||||
value={devName}
|
||||
onChange={(e) => setDevName(e.target.value)}
|
||||
placeholder="输入用户名"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
loading={loggingIn}
|
||||
onClick={async () => {
|
||||
setLoggingIn(true);
|
||||
try {
|
||||
await loginDev(devName);
|
||||
} catch {
|
||||
// error handled by AuthContext
|
||||
}
|
||||
setLoggingIn(false);
|
||||
}}
|
||||
>
|
||||
开发登录
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authConfig?.feishu_enabled && !authConfig?.dev_auth_enabled && (
|
||||
<p className="text-sm text-red-500 text-center">认证服务未配置</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
300
platform/web/src/styles/index.css
Normal file
300
platform/web/src/styles/index.css
Normal file
@@ -0,0 +1,300 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--lsf-primary-emphasis: #1e40af;
|
||||
--lsf-primary: #2563eb;
|
||||
--lsf-neutral-emphasis-subtle: #f3f4f6;
|
||||
--lsf-success: #16a34a;
|
||||
--lsf-warning: #f59e0b;
|
||||
--lsf-danger: #dc2626;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #fafbfc;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--lsf-primary-emphasis);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Module Group ---- */
|
||||
.module-group {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.module-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.module-group-header:hover {
|
||||
background: var(--lsf-neutral-emphasis-subtle);
|
||||
}
|
||||
|
||||
.module-group-header.active {
|
||||
color: var(--lsf-primary-emphasis);
|
||||
}
|
||||
|
||||
.module-group-header .chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.module-group-header .chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.module-group-items {
|
||||
padding: 2px 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
.module-group-items.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.module-group-items .nav-item {
|
||||
display: block;
|
||||
padding: 7px 16px 7px 40px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.module-group-items .nav-item:hover {
|
||||
background: var(--lsf-neutral-emphasis-subtle);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.module-group-items .nav-item.active {
|
||||
color: var(--lsf-primary-emphasis);
|
||||
background: #eff6ff;
|
||||
border-left-color: var(--lsf-primary-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Main Layout ---- */
|
||||
.main-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content > .module-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- Module Tab Bar ---- */
|
||||
.module-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
padding: 0 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.module-tab {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.module-tab:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.module-tab.active {
|
||||
color: var(--lsf-primary-emphasis);
|
||||
border-bottom-color: var(--lsf-primary-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Shared UI ---- */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-auto {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table-auto th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.table-auto td {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.table-auto tr:hover td {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* ---- Status colors ---- */
|
||||
.status-pending { color: #d97706; }
|
||||
.status-approved { color: #059669; }
|
||||
.status-rejected { color: #dc2626; }
|
||||
.status-running { color: #2563eb; }
|
||||
.status-completed { color: #059669; }
|
||||
.status-failed { color: #dc2626; }
|
||||
|
||||
/* ---- Form ---- */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--lsf-primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
select.form-input {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
8
platform/web/src/vite-env.d.ts
vendored
8
platform/web/src/vite-env.d.ts
vendored
@@ -1,9 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user