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:
212
platform/web/src/modules/system/pages/AuditDetailPage.tsx
Normal file
212
platform/web/src/modules/system/pages/AuditDetailPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type RejectionCategory = { key: string; label: string };
|
||||
|
||||
export const AuditDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { hasPermission: can } = useAuth();
|
||||
const isReviewer = can("write:approval_review") || can("*");
|
||||
|
||||
const [approval, setApproval] = useState<Record<string, unknown> | null>(null);
|
||||
const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
|
||||
const [images, setImages] = useState<{ id: string; url?: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [rejectCategory, setRejectCategory] = useState("");
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [categories, setCategories] = useState<RejectionCategory[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
hsapApi.getApproval(id),
|
||||
hsapApi.getApprovalPreview(id).catch(() => null),
|
||||
]);
|
||||
setApproval(a); setPreview(p as Record<string, unknown> | null);
|
||||
const imgRes = await hsapApi.listApprovalImages(id, 0, 60).catch(() => null);
|
||||
const items = (imgRes?.items || []) as { id: string }[];
|
||||
const withUrls = await Promise.all(items.slice(0, 12).map(async (img) => {
|
||||
try { return { ...img, url: await hsapApi.fetchApprovalImageBlob(id, img.id, true) }; }
|
||||
catch { return img; }
|
||||
}));
|
||||
setImages(withUrls);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [id]);
|
||||
useEffect(() => {
|
||||
if (isReviewer) { hsapApi.approvalRejectionCategories().then((r) => setCategories(r.categories || [])); }
|
||||
}, [isReviewer]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setBusy(true);
|
||||
try { await hsapApi.approve(id!); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setBusy(true);
|
||||
try { await hsapApi.reject(id!, rejectComment || undefined, rejectCategory || undefined); load(); setRejecting(false); }
|
||||
catch (e) { setError(String(e)); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<Link to="/system/audit" className="text-blue-600 text-sm hover:underline mb-2 inline-block">← 返回审核队列</Link>
|
||||
<h1>审核详情</h1>
|
||||
<p className="font-mono text-xs text-gray-400">ID: {id}</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{approval && (
|
||||
<>
|
||||
{/* Summary card */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>{approval.action_label as string || approval.action as string || "审核单"}</span>
|
||||
<StatusBadge status={(approval.status as string) || "pending"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">提单人: </span><span>{approval.submitted_by as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">审核人: </span><span>{approval.reviewed_by as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">提交时间: </span><span className="text-xs">{approval.submitted_at as string || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">审核时间: </span><span className="text-xs">{approval.reviewed_at as string || "—"}</span>
|
||||
</div>
|
||||
{approval.note != null && String(approval.note) && <div className="col-span-2"><span className="text-gray-500">备注: </span>{String(approval.note)}</div>}
|
||||
{approval.review_comment != null && String(approval.review_comment) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">审核意见: </span>
|
||||
<span className="text-gray-800">{String(approval.review_comment ?? "")}</span>
|
||||
</div>
|
||||
)}
|
||||
{approval.rejection_category != null && String(approval.rejection_category) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">驳回分类: </span>
|
||||
<Badge variant="danger">{String(approval.rejection_category ?? "")}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job link */}
|
||||
{approval.job_id != null && String(approval.job_id) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">关联任务: </span>
|
||||
<Link to={`/system/jobs`} className="text-blue-600 text-sm hover:underline font-mono">
|
||||
{String(approval.job_id ?? "")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview - structured view */}
|
||||
{preview && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">变更详情</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{(preview.task as string) && (
|
||||
<div><span className="text-gray-500">任务: </span><Badge variant="info">{preview.task as string}</Badge></div>
|
||||
)}
|
||||
{(preview.pack as string) && (
|
||||
<div><span className="text-gray-500">数据包: </span><Badge variant="default">{preview.pack as string}</Badge></div>
|
||||
)}
|
||||
{(preview.scope_label as string) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">范围: </span>
|
||||
<span>{String(preview.scope_label ?? "")}</span>
|
||||
</div>
|
||||
)}
|
||||
{(preview.class_names as Record<string, string>) && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500 text-xs">类别: </span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(preview.class_names as Record<string, string>).map(([k, v]) => (
|
||||
<Badge key={k} size="small" variant="default">{v || k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Params detail */}
|
||||
{approval.params != null && typeof approval.params === "object" && Object.keys(approval.params as Record<string, unknown>).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 font-semibold">参数明细</span>
|
||||
<pre className="text-xs mt-1 bg-gray-50 p-2 rounded max-h-40 overflow-auto">
|
||||
{JSON.stringify(approval.params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">图片预览 ({images.length})</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="w-24 h-24 bg-gray-100 rounded overflow-hidden">
|
||||
{img.url ? (
|
||||
<img src={img.url} alt={img.id} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-xs">{img.id.slice(0, 8)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === "pending" && isReviewer && (
|
||||
<div className="card">
|
||||
{!rejecting ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="success" onClick={handleApprove} loading={busy}>✓ 通过</Button>
|
||||
<Button variant="danger" onClick={() => setRejecting(true)}>✗ 驳回</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">驳回审核</p>
|
||||
<div className="space-y-3">
|
||||
<select className="form-input max-w-xs" value={rejectCategory} onChange={(e) => setRejectCategory(e.target.value)}>
|
||||
<option value="">选择驳回原因分类</option>
|
||||
{categories.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<textarea className="form-input max-w-md" value={rejectComment} onChange={(e) => setRejectComment(e.target.value)} placeholder="补充说明(可选)" rows={3} />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="danger" onClick={handleReject} loading={busy} disabled={!rejectCategory}>确认驳回</Button>
|
||||
<Button variant="default" onClick={() => { setRejecting(false); setRejectCategory(""); setRejectComment(""); }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
platform/web/src/modules/system/pages/AuditLogPage.tsx
Normal file
111
platform/web/src/modules/system/pages/AuditLogPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type LogEntry = {
|
||||
id: number; timestamp: string; user_id: number; user_name: string;
|
||||
category: string; action: string; target_type: string; target_id: string;
|
||||
summary: string; detail: Record<string, unknown> | null; ip_address: string;
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { label: string; color: "info" | "success" | "warning" | "danger" | "default" }> = {
|
||||
auth: { label: "认证", color: "info" },
|
||||
data: { label: "数据", color: "success" },
|
||||
labeling: { label: "标注", color: "warning" },
|
||||
audit: { label: "审核", color: "danger" },
|
||||
training: { label: "训练", color: "info" },
|
||||
system: { label: "系统", color: "default" },
|
||||
};
|
||||
|
||||
export const AuditLogPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 30) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set("category", category);
|
||||
if (search) params.set("search", search);
|
||||
params.set("offset", String(newOffset));
|
||||
params.set("limit", String(newLimit));
|
||||
const res = await fetch(`/api/v1/system/audit-log?${params}`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setLogs((res.items || []) as LogEntry[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [category, search]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const catBadge = (cat: string) => {
|
||||
const c = CATEGORY_LABELS[cat] || { label: cat, color: "default" as const };
|
||||
return <Badge variant={c.color} size="small">{c.label}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>操作日志</h1>
|
||||
<p>平台所有关键操作的审计记录,保留 90 天</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select className="form-input w-auto" value={category} onChange={(e) => { setCategory(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<input className="form-input w-48" placeholder="搜索用户/摘要..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={logs.length === 0} emptyMessage="暂无操作记录">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="space-y-0 divide-y divide-gray-100">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="py-2.5 px-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Userpic username={log.user_name} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{log.user_name || "系统"}</span>
|
||||
{catBadge(log.category)}
|
||||
<span className="text-sm text-gray-700 truncate">{log.summary || log.action}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-gray-400">
|
||||
<span>{log.timestamp || "—"}</span>
|
||||
{log.target_type && <span>· {log.target_type}/{log.target_id?.slice(0, 16)}</span>}
|
||||
<button onClick={() => setExpandedId(expandedId === log.id ? null : log.id)} className="text-blue-500 hover:underline">
|
||||
{expandedId === log.id ? "收起" : "详情"}
|
||||
</button>
|
||||
</div>
|
||||
{expandedId === log.id && log.detail && (
|
||||
<pre className="text-xs mt-1 bg-gray-50 p-2 rounded max-h-32 overflow-auto">
|
||||
{JSON.stringify(log.detail, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
platform/web/src/modules/system/pages/AuditQueuePage.tsx
Normal file
200
platform/web/src/modules/system/pages/AuditQueuePage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type RejectionCategory = { key: string; label: string };
|
||||
|
||||
export const AuditQueuePage: React.FC = () => {
|
||||
const { hasPermission: can } = useAuth();
|
||||
const isReviewer = can("write:approval_review") || can("*");
|
||||
const [approvals, setApprovals] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchMode, setBatchMode] = useState<"" | "approve" | "reject">("");
|
||||
const [rejectCategory, setRejectCategory] = useState("");
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const [batchBusy, setBatchBusy] = useState(false);
|
||||
const [categories, setCategories] = useState<RejectionCategory[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listApprovals({ status: filterStatus || undefined, offset: newOffset, limit: newLimit });
|
||||
let items = (res.items || []) as Record<string, unknown>[];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((a) =>
|
||||
String(a.action_label || a.action || "").toLowerCase().includes(q) ||
|
||||
String(a.submitted_by || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
setApprovals(items);
|
||||
setTotal(search ? items.length : res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [filterStatus]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
if (isReviewer) { hsapApi.approvalRejectionCategories().then((r) => setCategories(r.categories || [])); }
|
||||
}, [isReviewer]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === approvals.length) { setSelected(new Set()); }
|
||||
else { setSelected(new Set(approvals.filter((a) => a.status === "pending").map((a) => a.id as string))); }
|
||||
};
|
||||
|
||||
const handleBatchAction = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setBatchBusy(true);
|
||||
const ids = [...selected];
|
||||
try {
|
||||
if (batchMode === "approve") {
|
||||
await hsapApi.batchApprove(ids);
|
||||
} else if (batchMode === "reject") {
|
||||
await hsapApi.batchReject(ids, rejectComment || undefined, rejectCategory || undefined);
|
||||
}
|
||||
setSelected(new Set());
|
||||
setBatchMode("");
|
||||
load(offset, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setBatchBusy(false);
|
||||
};
|
||||
|
||||
const handleSingleApprove = async (id: string) => {
|
||||
try { await hsapApi.approve(id); load(offset, limit); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>审核队列</h1>
|
||||
<p>审核平台中所有操作请求</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter + Batch bar */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<input className="form-input w-44" placeholder="搜索操作/提单人..." value={search} onChange={(e) => { setSearch(e.target.value); }} />
|
||||
<select className="form-input w-auto" value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setSelected(new Set()); }}>
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
</select>
|
||||
{isReviewer && selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">{selected.size} 项已选</span>
|
||||
{!batchMode ? (
|
||||
<>
|
||||
<Button size="small" variant="success" onClick={() => setBatchMode("approve")}>批量通过</Button>
|
||||
<Button size="small" variant="danger" onClick={() => setBatchMode("reject")}>批量驳回</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-sm font-medium">{batchMode === "approve" ? "批量通过" : "批量驳回"}</span>
|
||||
{batchMode === "reject" && (
|
||||
<>
|
||||
<select className="form-input w-auto text-xs" value={rejectCategory} onChange={(e) => setRejectCategory(e.target.value)}>
|
||||
<option value="">选择原因</option>
|
||||
{categories.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<input className="form-input w-40 text-xs" value={rejectComment} onChange={(e) => setRejectComment(e.target.value)} placeholder="备注(可选)" />
|
||||
</>
|
||||
)}
|
||||
<Button size="small" variant={batchMode === "approve" ? "success" : "danger"} onClick={handleBatchAction} loading={batchBusy}>确认</Button>
|
||||
<Button size="small" variant="default" onClick={() => { setBatchMode(""); setRejectCategory(""); setRejectComment(""); }}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={approvals.length === 0} emptyMessage="暂无审核记录">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{isReviewer && <th className="w-8"><input type="checkbox" onChange={toggleAll} checked={selected.size > 0 && selected.size === approvals.filter((a) => a.status === "pending").length} /></th>}
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>详情</th>
|
||||
<th>提单人</th>
|
||||
<th>审核人</th>
|
||||
<th>驳回原因</th>
|
||||
<th>时间</th>
|
||||
{isReviewer && <th>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.map((a) => {
|
||||
const params = a.params as Record<string, unknown> | undefined;
|
||||
const detail = params
|
||||
? Object.entries(params).filter(([k]) => !["project", "submitted_by"].includes(k)).map(([k, v]) => `${k}=${v}`).join(", ")
|
||||
: "";
|
||||
return (
|
||||
<tr key={a.id as string} className={a.status === "pending" && selected.has(a.id as string) ? "bg-blue-50" : ""}>
|
||||
{isReviewer && (
|
||||
<td>
|
||||
{a.status === "pending" && (
|
||||
<input type="checkbox" checked={selected.has(a.id as string)} onChange={() => toggleSelect(a.id as string)} />
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Link to={`/system/audit/${a.id}`} className="text-blue-600 hover:underline text-sm font-medium">
|
||||
{a.action_label as string || a.action as string || "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td><StatusBadge status={(a.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500 max-w-xs truncate">{detail || "—"}</td>
|
||||
<td>{a.submitted_by as string || "—"}</td>
|
||||
<td>{a.reviewed_by as string || "—"}</td>
|
||||
<td className="text-xs">
|
||||
{a.status === "rejected" && a.rejection_category ? (
|
||||
<Badge variant="danger" size="small">{a.rejection_category as string}</Badge>
|
||||
) : a.status === "rejected" ? (
|
||||
<span className="text-gray-400">—</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-xs text-gray-500 whitespace-nowrap">{a.submitted_at as string || a.created_at as string || "—"}</td>
|
||||
{isReviewer && (
|
||||
<td>
|
||||
{a.status === "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<Button size="small" variant="success" onClick={() => handleSingleApprove(a.id as string)}>通过</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
platform/web/src/modules/system/pages/ExecutionLogsPage.tsx
Normal file
91
platform/web/src/modules/system/pages/ExecutionLogsPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
export const ExecutionLogsPage: React.FC = () => {
|
||||
const [traces, setTraces] = useState<string[]>([]);
|
||||
const [selectedTrace, setSelectedTrace] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTraces = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listTraces(50);
|
||||
setTraces(res.trace_ids || []);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { loadTraces(); }, []);
|
||||
|
||||
const handleViewTrace = async (traceId: string) => {
|
||||
try {
|
||||
const t = await hsapApi.getTrace(traceId);
|
||||
setSelectedTrace(t);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>执行日志</h1>
|
||||
<p>Agent 执行 Trace 与 Span 日志查看</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Trace list */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>Trace 列表</span>
|
||||
<Button size="small" variant="default" onClick={loadTraces}>刷新</Button>
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={traces.length === 0} emptyMessage="暂无 Trace 记录">
|
||||
<div className="max-h-96 overflow-y-auto space-y-1">
|
||||
{traces.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleViewTrace(t)}
|
||||
className={`w-full text-left px-3 py-2 text-xs font-mono rounded hover:bg-blue-50 transition-colors ${
|
||||
selectedTrace && (selectedTrace.trace_id as string) === t ? "bg-blue-100 text-blue-800" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t.slice(0, 32)}...
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
|
||||
{/* Trace detail */}
|
||||
<div className="card col-span-2">
|
||||
<div className="card-header">Trace 详情</div>
|
||||
{selectedTrace ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2 font-mono">
|
||||
Trace ID: {selectedTrace.trace_id as string}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Spans: {(selectedTrace.spans as unknown[])?.length || 0}
|
||||
</p>
|
||||
<pre className="text-xs overflow-auto max-h-96 bg-gray-50 p-3 rounded">
|
||||
{JSON.stringify(selectedTrace.spans, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
请从左侧选择一个 Trace 查看详情
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
platform/web/src/modules/system/pages/JobMonitorPage.tsx
Normal file
108
platform/web/src/modules/system/pages/JobMonitorPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
export const JobMonitorPage: React.FC = () => {
|
||||
const [jobs, setJobs] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listJobs({
|
||||
status: filterStatus || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setJobs((res.items || []) as Record<string, unknown>[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [filterStatus]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => load(offset, limit), 10000);
|
||||
return () => clearInterval(t);
|
||||
}, [offset, limit, load]);
|
||||
|
||||
const handleView = async (jobId: string) => {
|
||||
try {
|
||||
const job = await hsapApi.getJob(jobId);
|
||||
alert(JSON.stringify(job, null, 2));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>任务监控</h1>
|
||||
<p>查看异步任务执行状态,每 10 秒自动刷新</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>手动刷新</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<select className="form-input w-auto" value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={jobs.length === 0} emptyMessage="暂无任务记录">
|
||||
<div className="card">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>完成时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id as string}>
|
||||
<td className="font-mono text-xs">{(j.id as string || "").slice(0, 16)}...</td>
|
||||
<td>{j.action as string || "—"}</td>
|
||||
<td><StatusBadge status={(j.status as string) || "pending"} /></td>
|
||||
<td className="text-xs text-gray-500">{j.created_at as string || "—"}</td>
|
||||
<td className="text-xs text-gray-500">{j.completed_at as string || "—"}</td>
|
||||
<td>
|
||||
<Button size="small" variant="default" onClick={() => handleView(j.id as string)}>
|
||||
详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
215
platform/web/src/modules/system/pages/UserManagementPage.tsx
Normal file
215
platform/web/src/modules/system/pages/UserManagementPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Userpic } from "@/components/ui/Userpic";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
|
||||
type UserRecord = {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
feishu_open_id?: string;
|
||||
feishu_union_id?: string;
|
||||
feishu_user_id?: string;
|
||||
feishu_department_ids?: string[];
|
||||
roles: { code: string; name: string }[];
|
||||
permissions: string[];
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ code: "admin", name: "管理员", color: "danger" as const },
|
||||
{ code: "reviewer", name: "审核员", color: "warning" as const },
|
||||
{ code: "engineer", name: "工程师", color: "info" as const },
|
||||
{ code: "labeler", name: "标注员", color: "success" as const },
|
||||
{ code: "viewer", name: "观察者", color: "default" as const },
|
||||
];
|
||||
|
||||
export const UserManagementPage: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [editingUser, setEditingUser] = useState<UserRecord | null>(null);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listUsers({
|
||||
search: search || undefined,
|
||||
role: roleFilter || undefined,
|
||||
offset: newOffset,
|
||||
limit: newLimit,
|
||||
});
|
||||
setUsers((res.items || []) as unknown as UserRecord[]);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search, roleFilter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleEditRoles = (user: UserRecord) => {
|
||||
setEditingUser(user);
|
||||
setSelectedRoles(user.roles.map((r) => r.code));
|
||||
};
|
||||
|
||||
const handleSaveRoles = async () => {
|
||||
if (!editingUser) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await hsapApi.setUserRoles(editingUser.id, selectedRoles);
|
||||
setEditingUser(null);
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const roleBadge = (code: string) => {
|
||||
const opt = ROLE_OPTIONS.find((r) => r.code === code);
|
||||
return <Badge key={code} variant={opt?.color || "default"} size="small">{opt?.name || code}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>管理平台用户角色权限,关联飞书账号信息</p>
|
||||
</div>
|
||||
<Button variant="primary" size="small" onClick={async () => {
|
||||
try {
|
||||
const res = await hsapApi.syncFeishuUsers();
|
||||
alert(`同步完成!共 ${res.total} 人(新增 ${res.created},更新 ${res.updated})`);
|
||||
load(0, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
}}>
|
||||
同步飞书用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
className="form-input max-w-xs"
|
||||
placeholder="搜索姓名或邮箱..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOffset(0); }}
|
||||
/>
|
||||
<select className="form-input w-auto" value={roleFilter} onChange={(e) => { setRoleFilter(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部角色</option>
|
||||
{ROLE_OPTIONS.map((r) => <option key={r.code} value={r.code}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={users.length === 0} emptyMessage="暂无用户">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>邮箱</th>
|
||||
<th>飞书 ID</th>
|
||||
<th>飞书部门</th>
|
||||
<th>角色</th>
|
||||
<th>权限数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Userpic username={u.name} avatarUrl={u.avatar_url} size={28} />
|
||||
<span className="font-medium">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">{u.email || "—"}</td>
|
||||
<td className="text-xs font-mono text-gray-400">
|
||||
{u.feishu_user_id ? (
|
||||
<span title={`OpenID: ${u.feishu_open_id || ""} UnionID: ${u.feishu_union_id || ""}`}>
|
||||
{(u.feishu_user_id as string).slice(0, 12)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">非飞书用户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">
|
||||
{u.feishu_department_ids?.length ? `${u.feishu_department_ids.length} 个部门` : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1 flex-wrap">{u.roles.map((r) => roleBadge(r.code))}</div>
|
||||
</td>
|
||||
<td className="text-xs text-gray-500">{u.permissions?.length || 0}</td>
|
||||
<td>
|
||||
<Button size="small" variant="default" onClick={() => handleEditRoles(u)}>编辑角色</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
</div>
|
||||
</PageQueryState>
|
||||
|
||||
{/* Role Edit Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setEditingUser(null)}>
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-1">编辑角色 — {editingUser.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{editingUser.email || "—"}</p>
|
||||
|
||||
{editingUser.feishu_open_id && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-xs text-gray-500 space-y-1">
|
||||
<p><span className="font-medium">飞书 Open ID:</span> {editingUser.feishu_open_id}</p>
|
||||
{editingUser.feishu_union_id && <p><span className="font-medium">Union ID:</span> {editingUser.feishu_union_id}</p>}
|
||||
{editingUser.feishu_user_id && <p><span className="font-medium">User ID:</span> {editingUser.feishu_user_id}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label key={role.code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 rounded p-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles.includes(role.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedRoles([...selectedRoles, role.code]);
|
||||
else setSelectedRoles(selectedRoles.filter((r) => r !== role.code));
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<Badge variant={role.color} size="small">{role.name}</Badge>
|
||||
<span className="text-xs text-gray-400">{role.code}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="default" size="small" onClick={() => setEditingUser(null)}>取消</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSaveRoles} loading={saving}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user