diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c075be8..e774b91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage")); const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage")); const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage")); const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage")); +const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage")); const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard")); const MatrixBoard = lazy(() => import("./pages/MatrixBoard")); const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard")); @@ -79,6 +80,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index a8595e5..f4197c1 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,5 +1,5 @@ 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"; const MENU = [ @@ -39,6 +39,12 @@ const MENU = [ icon: Table2, description: "规格型号 × 人员/工序 在制品透视表", }, + { + title: "操作审计", + path: "/admin/audit", + icon: ScrollText, + description: "谁在何时操作了什么 · 含失败与被拒请求", + }, { title: "管理层大屏", path: "/admin/screen", diff --git a/frontend/src/constants/task.ts b/frontend/src/constants/task.ts index fec0b5d..ec02d44 100644 --- a/frontend/src/constants/task.ts +++ b/frontend/src/constants/task.ts @@ -227,7 +227,8 @@ export function overallOptionsFor( /** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */ /** - * 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。 + * 管理角色 — 必须与后端 app/core/roles.py 的 ADMIN_ROLES 保持一致 + * (后端那份已从 task_service 收敛到 core.roles,是全项目唯一事实来源)。 * * ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage), * 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。 diff --git a/frontend/src/pages/admin/AdminAuditLogPage.tsx b/frontend/src/pages/admin/AdminAuditLogPage.tsx new file mode 100644 index 0000000..08b1cd9 --- /dev/null +++ b/frontend/src/pages/admin/AdminAuditLogPage.tsx @@ -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 = { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + const [modules, setModules] = useState([]); + const [actions, setActions] = useState([]); + + // 筛选条件(user_id 用受控输入,其余即时生效) + const [userInput, setUserInput] = useState(""); + const [userId, setUserId] = useState(""); + const [module, setModule] = useState(); + const [action, setAction] = useState(); + const [statusCode, setStatusCode] = useState(); + 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 = [ + { + title: "时间", + dataIndex: "created_at", + width: 165, + render: (v: string) => ( + {dayjs(v).format("YYYY-MM-DD HH:mm:ss")} + ), + }, + { + title: "操作人", + dataIndex: "user_id", + width: 140, + render: (_, r) => + r.user_id ? ( +
+
{r.display_name || r.user_id}
+
{r.user_id}
+
+ ) : ( + 未认证 + ), + }, + { + title: "模块", + dataIndex: "module_label", + width: 110, + render: (v, r) => {v || r.module}, + }, + { + title: "动作", + dataIndex: "action_label", + width: 100, + render: (v, r) => {v || r.action}, + }, + { + title: "请求", + dataIndex: "method", + width: 210, + render: (_, r) => ( +
+ + {r.method} + + + {r.url} + +
+ ), + }, + { + title: "结果", + dataIndex: "status_code", + width: 80, + render: (v: number | null) => {v ?? "-"}, + }, + { + title: "来源 IP", + dataIndex: "ip_address", + width: 130, + render: (v: string | null) => {v || "-"}, + }, + { + title: "", + key: "op", + width: 70, + render: (_, r) => ( + + ), + }, + ]; + + return ( +
+
+
+

+ + 操作审计 +

+

+ 所有写操作(含被拒绝的请求)自动留痕,共 {total} 条 +

+
+ +
+ + {/* 筛选区 */} +
+ } + value={userInput} + allowClear + style={{ width: 180 }} + onChange={(e) => setUserInput(e.target.value)} + onPressEnter={() => { + setPage(1); + setUserId(userInput.trim()); + }} + onBlur={() => { + setPage(1); + setUserId(userInput.trim()); + }} + /> + { + setPage(1); + setAction(v); + }} + /> +