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:
2026-06-03 11:40:21 +08:00
parent 7c43b44c57
commit e72bc061c5
5487 changed files with 979207 additions and 6197 deletions

View 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;
}

View 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>
);

View 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>
);
};

View 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>
);
};

View 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);
}