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

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

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

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

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

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