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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user