feat(admin): 新增「操作审计」页面 + 收敛前端角色判断

- pages/admin/AdminAuditLogPage.tsx:审计日志查询页。支持操作人/模块/动作/
  结果状态/日期区间筛选,表格按时间倒序,行内「详情」抽屉展示完整 URL、
  UA、request_id、错误信息与变更详情。
  中文标签(模块/动作)由服务端下发,前端不维护枚举映射 —— 与
  constants/task.ts 里状态映射的既有做法一致,避免两端各写一份开始漂移。
- services/auditApi.ts:审计接口客户端与类型定义。
- 路由 /admin/audit + 侧边栏「操作审计」入口。
- AdminProductsPage 里手写的角色判断改为复用 isAdminRole():这是同一份
  「管理员角色」规则的第 4 处副本,本次一并对齐(另一处 TaskFlowView 已在用)。
  constants/task.ts 的注释同步指向后端新位置 core/roles.py。

验证:tsc --noEmit 通过;vite build 通过(产出独立 chunk
AdminAuditLogPage-*.js);对接真实后端校验响应字段与 TS 接口定义逐字段一致。
This commit is contained in:
openhands
2026-09-21 02:28:17 +00:00
parent 7635802a42
commit 14f707461d
6 changed files with 445 additions and 4 deletions

View File

@ -41,6 +41,7 @@ const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage")); const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage")); const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage")); const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard")); const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard")); const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard")); const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
@ -79,6 +80,7 @@ export default function App() {
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} /> <Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
<Route path="/admin/analytics" element={<AnalyticsDashboard />} /> <Route path="/admin/analytics" element={<AnalyticsDashboard />} />
<Route path="/admin/matrix" element={<MatrixBoard />} /> <Route path="/admin/matrix" element={<MatrixBoard />} />
<Route path="/admin/audit" element={<AdminAuditLogPage />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv } from "lucide-react"; import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
const MENU = [ const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2, icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表", description: "规格型号 × 人员/工序 在制品透视表",
}, },
{
title: "操作审计",
path: "/admin/audit",
icon: ScrollText,
description: "谁在何时操作了什么 · 含失败与被拒请求",
},
{ {
title: "管理层大屏", title: "管理层大屏",
path: "/admin/screen", path: "/admin/screen",

View File

@ -227,7 +227,8 @@ export function overallOptionsFor(
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */ /** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
/** /**
* 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。 * 管理角色 — 必须与后端 app/core/roles.py 的 ADMIN_ROLES 保持一致
* (后端那份已从 task_service 收敛到 core.roles,是全项目唯一事实来源)。
* *
* ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage), * ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage),
* 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。 * 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。

View File

@ -0,0 +1,355 @@
/** 操作审计日志 — 谁 / 何时 / 从哪 / 对什么 / 做了什么事 / 结果如何 */
import { useCallback, useEffect, useState } from "react";
import { ScrollText, Loader2, AlertCircle, RefreshCw, Search, X } from "lucide-react";
import { Table, Tag, Input, Select, DatePicker, Button, Tooltip, Drawer, Descriptions } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import { fetchAuditLogs, fetchAuditOptions, type AuditLogItem, type AuditOption } from "../../services/auditApi";
import { extractErrorMessage } from "../../utils/errorMessage";
const { RangePicker } = DatePicker;
/** HTTP 方法配色 —— 让「这是读还是写」一眼可辨 */
const METHOD_CLS: Record<string, string> = {
GET: "bg-slate-100 text-slate-600",
POST: "bg-emerald-100 text-emerald-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-amber-100 text-amber-700",
DELETE: "bg-red-100 text-red-700",
};
/** 结果状态:2xx 正常 / 4xx 被拒 / 5xx 服务异常 */
function statusCls(code: number | null): string {
if (code === null) return "bg-slate-100 text-slate-500";
if (code >= 500) return "bg-red-100 text-red-700";
if (code >= 400) return "bg-orange-100 text-orange-700";
return "bg-emerald-100 text-emerald-700";
}
const PAGE_SIZE = 50;
export default function AdminAuditLogPage() {
const [rows, setRows] = useState<AuditLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<AuditLogItem | null>(null);
const [modules, setModules] = useState<AuditOption[]>([]);
const [actions, setActions] = useState<AuditOption[]>([]);
// 筛选条件(user_id 用受控输入,其余即时生效)
const [userInput, setUserInput] = useState("");
const [userId, setUserId] = useState("");
const [module, setModule] = useState<string | undefined>();
const [action, setAction] = useState<string | undefined>();
const [statusCode, setStatusCode] = useState<number | undefined>();
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchAuditLogs({
user_id: userId || undefined,
module,
action,
status_code: statusCode,
start_date: range?.[0]?.format("YYYY-MM-DD"),
// 后端按「含当天」处理结束日期,这里直接传所选日期即可
end_date: range?.[1]?.format("YYYY-MM-DD"),
page,
page_size: PAGE_SIZE,
});
setRows(res.items);
// total 取自后端 count 查询的真实总数,而非当前页条数
setTotal(res.total);
} catch (e) {
setError(extractErrorMessage(e));
} finally {
setLoading(false);
}
}, [userId, module, action, statusCode, range, page]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
fetchAuditOptions()
.then((o) => {
setModules(o.modules);
setActions(o.actions);
})
.catch(() => {
/* 筛选项拉取失败不影响列表本身 */
});
}, []);
const hasFilter = !!(userId || module || action || statusCode || range);
const resetFilters = () => {
setUserInput("");
setUserId("");
setModule(undefined);
setAction(undefined);
setStatusCode(undefined);
setRange(null);
setPage(1);
};
const columns: ColumnsType<AuditLogItem> = [
{
title: "时间",
dataIndex: "created_at",
width: 165,
render: (v: string) => (
<span className="whitespace-nowrap text-gray-600">{dayjs(v).format("YYYY-MM-DD HH:mm:ss")}</span>
),
},
{
title: "操作人",
dataIndex: "user_id",
width: 140,
render: (_, r) =>
r.user_id ? (
<div className="leading-tight">
<div className="text-gray-900">{r.display_name || r.user_id}</div>
<div className="text-xs text-gray-400">{r.user_id}</div>
</div>
) : (
<span className="text-gray-400">未认证</span>
),
},
{
title: "模块",
dataIndex: "module_label",
width: 110,
render: (v, r) => <span>{v || r.module}</span>,
},
{
title: "动作",
dataIndex: "action_label",
width: 100,
render: (v, r) => <Tag color="blue">{v || r.action}</Tag>,
},
{
title: "请求",
dataIndex: "method",
width: 210,
render: (_, r) => (
<div className="flex items-center gap-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-mono ${METHOD_CLS[r.method || ""] || "bg-slate-100 text-slate-600"}`}>
{r.method}
</span>
<span className="truncate font-mono text-xs text-gray-500" title={r.url || ""}>
{r.url}
</span>
</div>
),
},
{
title: "结果",
dataIndex: "status_code",
width: 80,
render: (v: number | null) => <span className={`rounded px-2 py-0.5 text-xs font-mono ${statusCls(v)}`}>{v ?? "-"}</span>,
},
{
title: "来源 IP",
dataIndex: "ip_address",
width: 130,
render: (v: string | null) => <span className="font-mono text-xs text-gray-500">{v || "-"}</span>,
},
{
title: "",
key: "op",
width: 70,
render: (_, r) => (
<Button type="link" size="small" onClick={() => setDetail(r)}>
详情
</Button>
),
},
];
return (
<div className="p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold text-gray-900">
<ScrollText className="h-5 w-5 text-blue-600" />
操作审计
</h1>
<p className="mt-1 text-sm text-gray-500">
所有写操作(含被拒绝的请求)自动留痕,共 {total} 条
</p>
</div>
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={() => void load()} loading={loading}>
刷新
</Button>
</div>
{/* 筛选区 */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="操作人账号"
prefix={<Search className="h-4 w-4 text-gray-400" />}
value={userInput}
allowClear
style={{ width: 180 }}
onChange={(e) => setUserInput(e.target.value)}
onPressEnter={() => {
setPage(1);
setUserId(userInput.trim());
}}
onBlur={() => {
setPage(1);
setUserId(userInput.trim());
}}
/>
<Select
placeholder="模块"
allowClear
style={{ width: 140 }}
value={module}
options={modules}
onChange={(v) => {
setPage(1);
setModule(v);
}}
/>
<Select
placeholder="动作"
allowClear
style={{ width: 130 }}
value={action}
options={actions}
onChange={(v) => {
setPage(1);
setAction(v);
}}
/>
<Select
placeholder="结果"
allowClear
style={{ width: 130 }}
value={statusCode}
options={[
{ value: 200, label: "成功 (2xx/3xx)" },
{ value: 401, label: "未认证 401" },
{ value: 403, label: "无权限 403" },
{ value: 422, label: "参数错误 422" },
{ value: 500, label: "服务异常 500" },
]}
onChange={(v) => {
setPage(1);
setStatusCode(v);
}}
/>
<RangePicker
value={range}
onChange={(v) => {
setPage(1);
setRange(v as [Dayjs, Dayjs] | null);
}}
/>
{hasFilter && (
<Button icon={<X className="h-4 w-4" />} onClick={resetFilters}>
清空
</Button>
)}
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Table<AuditLogItem>
rowKey="id"
columns={columns}
dataSource={rows}
loading={loading && { indicator: <Loader2 className="h-5 w-5 animate-spin text-blue-600" /> }}
size="small"
scroll={{ x: 1000 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `共 ${t} 条`,
onChange: setPage,
}}
/>
{/* 详情抽屉:完整 URL / UA / request_id / error_message 都在这里 */}
<Drawer
title="审计详情"
width={560}
open={!!detail}
onClose={() => setDetail(null)}
>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="时间">
{dayjs(detail.created_at).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="操作人">
{detail.user_id ? `${detail.display_name || ""} (${detail.user_id})` : "未认证"}
</Descriptions.Item>
<Descriptions.Item label="角色">{detail.role || "-"}</Descriptions.Item>
<Descriptions.Item label="模块 / 动作">
{detail.module_label || detail.module} / {detail.action_label || detail.action}
</Descriptions.Item>
<Descriptions.Item label="目标">
{detail.target_id ? (
<>
{detail.target_name || detail.target_id}
<span className="ml-1 text-xs text-gray-400">({detail.target_type})</span>
</>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="请求">
<span className="font-mono text-xs">
{detail.method} {detail.url}
</span>
</Descriptions.Item>
<Descriptions.Item label="结果">{detail.status_code ?? "-"}</Descriptions.Item>
{detail.error_message && (
<Descriptions.Item label="错误">
<span className="break-all font-mono text-xs text-red-600">{detail.error_message}</span>
</Descriptions.Item>
)}
<Descriptions.Item label="来源 IP">
<span className="font-mono text-xs">{detail.ip_address || "-"}</span>
</Descriptions.Item>
<Descriptions.Item label="User-Agent">
<span className="break-all text-xs text-gray-500">{detail.user_agent || "-"}</span>
</Descriptions.Item>
<Descriptions.Item label="Request ID">
{detail.request_id ? (
<Tooltip title="可在后端结构化日志中用它定位同一次请求">
<span className="break-all font-mono text-xs">{detail.request_id}</span>
</Tooltip>
) : (
"-"
)}
</Descriptions.Item>
{detail.details && (
<Descriptions.Item label="变更详情">
<pre className="max-h-60 overflow-auto rounded bg-gray-50 p-2 text-xs">
{JSON.stringify(detail.details, null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
)}
</Drawer>
</div>
);
}

View File

@ -13,7 +13,7 @@ import {
} from "../../services/printApi"; } from "../../services/printApi";
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig, lifecycleBadge } from "../../constants/task"; import { getStatusConfig, lifecycleBadge, isAdminRole } from "../../constants/task";
import { extractErrorMessage } from "../../utils/errorMessage"; import { extractErrorMessage } from "../../utils/errorMessage";
const QR_BASE = "/api/v1/products/qrcode"; const QR_BASE = "/api/v1/products/qrcode";
@ -31,7 +31,7 @@ interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWar
export default function AdminProductsPage() { export default function AdminProductsPage() {
const { toast } = useToast(); const { toast } = useToast();
const { user: authUser } = useAuth(); const { user: authUser } = useAuth();
const isAdmin = authUser?.role === "SUPER_ADMIN" || authUser?.role === "SUPERVISOR"; const isAdmin = isAdminRole(authUser?.role);
const [products, setProducts] = useState<ProductResponse[]>([]); const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

View File

@ -0,0 +1,77 @@
/** 操作审计日志 API */
import api from "./api";
export interface AuditLogItem {
id: string;
user_id: string | null;
display_name: string | null;
role: string | null;
action: string;
/** 服务端补的中文标签,前端不再各自维护枚举映射 */
action_label: string | null;
module: string;
module_label: string | null;
target_type: string | null;
target_id: string | null;
target_name: string | null;
details: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
method: string | null;
url: string | null;
status_code: number | null;
error_message: string | null;
/** 拿着它可在后端结构化日志中定位同一次请求 */
request_id: string | null;
created_at: string;
}
export interface AuditLogListResponse {
items: AuditLogItem[];
total: number;
}
export interface AuditOption {
value: string;
label: string;
}
export interface AuditOptionsResponse {
modules: AuditOption[];
actions: AuditOption[];
}
export interface AuditLogQuery {
user_id?: string;
module?: string;
action?: string;
target_id?: string;
request_id?: string;
status_code?: number;
/** YYYY-MM-DD */
start_date?: string;
/** YYYY-MM-DD(含当天) */
end_date?: string;
page?: number;
page_size?: number;
}
/** 分页查询审计日志(按时间倒序) */
export async function fetchAuditLogs(q: AuditLogQuery = {}): Promise<AuditLogListResponse> {
const params = Object.fromEntries(
Object.entries(q).filter(([, v]) => v !== undefined && v !== null && v !== "")
);
const { data } = await api.get<AuditLogListResponse>("/audit/logs", { params });
return data;
}
/** 获取模块/动作筛选项 */
export async function fetchAuditOptions(): Promise<AuditOptionsResponse> {
const { data } = await api.get<AuditOptionsResponse>("/audit/options");
return data;
}