| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886 |
- import { useCallback, useEffect, useMemo, useState } from "react";
- import {
- Activity,
- BarChart2,
- Download,
- Filter,
- RefreshCw,
- ScrollText,
- Search,
- Users,
- X,
- } from "lucide-react";
- import {
- Bar,
- BarChart,
- CartesianGrid,
- Cell,
- Line,
- LineChart,
- Pie,
- PieChart,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
- } from "recharts";
- import { AuditOperationResult } from "../../shared/api/enums";
- import { useDictionaries } from "../dictionaries/dictionaryContext";
- import {
- formatAuditDate,
- toUtcIso,
- } from "./auditLabels";
- import {
- useAuditBreakdowns,
- useAuditLogs,
- useAuditTrend,
- type RemoteState,
- } from "./auditState";
- import {
- AuditGranularity,
- type AuditActionStatisticsItem,
- type AuditLogQuery,
- type AuditTrendItem,
- type AuditUserStatisticsItem,
- } from "./auditTypes";
- import { systemApi } from "../system/systemApi";
- import {
- LOG_LEVELS,
- type SystemLogLevel,
- type SystemLogTail,
- } from "../system/systemTypes";
- const NAVY = "var(--dms-deep)";
- const BLUE = "var(--dms-bright)";
- const WHITE = "#fff";
- // 多彩配色(不再拘泥蓝色系):蓝为主色,辅以橙/绿/紫/红/青等,扇区按序取色。
- // 类别色板是内容语义色(计划例外),不随界面主题换肤。
- const COLORS = ["#3191eb", "#f5a623", "#34a853", "#a55eea", "#e74c3c",
- "#00bcd4", "#f8e71c", "#ff7043", "#7cb342", "#5c6bc0"];
- const EMPTY = "—";
- function ResourceState<T>({
- state,
- onRetry,
- label,
- }: {
- state: RemoteState<T>;
- onRetry: () => void;
- label: string;
- }) {
- if (state.loading && !state.data) {
- return (
- <div className="p-4 text-base font-bold" style={{ color: NAVY }}>
- <RefreshCw size={12} className="inline animate-spin mr-2" />
- 正在加载{label}…
- </div>
- );
- }
- if (state.error) {
- return (
- <div className="p-4 text-base font-bold" style={{ color: NAVY }}>
- <div>
- {state.error.httpStatus === 403
- ? `当前账号无权读取${label}`
- : state.error.message}
- </div>
- {state.error.requestId && (
- <div className="opacity-60 mt-1">
- 错误编号:{state.error.requestId}
- </div>
- )}
- <button
- onClick={onRetry}
- className="mt-2 px-3 py-1 text-white"
- style={{ background: BLUE }}
- >
- 重试
- </button>
- </div>
- );
- }
- if (Array.isArray(state.data) && state.data.length === 0) {
- return (
- <div className="p-4 text-base font-bold opacity-60" style={{ color: NAVY }}>
- 当前条件下暂无{label}
- </div>
- );
- }
- return null;
- }
- function StatsPanel({
- title,
- icon,
- children,
- }: {
- title: string;
- icon: React.ReactNode;
- children: React.ReactNode;
- }) {
- return (
- <section
- className="bg-white p-3 min-h-[170px]"
- style={{ border: "1px solid rgba(var(--dms-line-rgb),.18)" }}
- >
- <div className="flex items-center gap-2 mb-2">
- {icon}
- <h3 className="text-base font-black" style={{ color: NAVY }}>
- {title}
- </h3>
- </div>
- {children}
- </section>
- );
- }
- /** 运行日志查看窗口(契约1.23,manageAuditLog 权限;读取服务端日志文件尾部)。 */
- function RuntimeLogModal({ onClose }: { onClose: () => void }) {
- const [lines, setLines] = useState(500);
- const [level, setLevel] = useState<SystemLogLevel | "">("");
- const [data, setData] = useState<SystemLogTail | null>(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
- const [downloading, setDownloading] = useState(false);
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- setData(await systemApi.readLogTail({ lines, level }));
- } catch (loadError) {
- setError(
- loadError instanceof Error ? loadError.message : "运行日志读取失败",
- );
- } finally {
- setLoading(false);
- }
- }, [lines, level]);
- useEffect(() => {
- void load();
- }, [load]);
- const handleDownload = async () => {
- setDownloading(true);
- setError(null);
- try {
- const result = await systemApi.downloadLog();
- const url = URL.createObjectURL(result.blob);
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = "dms-runtime.log";
- document.body.appendChild(anchor);
- anchor.click();
- anchor.remove();
- URL.revokeObjectURL(url);
- } catch (downloadError) {
- setError(
- downloadError instanceof Error ? downloadError.message : "日志下载失败",
- );
- } finally {
- setDownloading(false);
- }
- };
- const controlStyle = {
- borderColor: "rgba(var(--dms-bright-rgb),0.4)",
- color: NAVY,
- } as const;
- return (
- <div
- className="fixed inset-0 z-[80] flex items-center justify-center p-6"
- style={{ background: "rgba(0,0,0,0.5)" }}
- >
- <div
- className="w-full h-full max-w-5xl max-h-[85vh] rounded-sm shadow-2xl flex flex-col"
- style={{ background: WHITE }}
- >
- <div
- className="flex items-center justify-between px-5 py-3 border-b"
- style={{ background: BLUE, color: WHITE }}
- >
- <h3 className="text-base font-black tracking-wide flex items-center gap-2">
- <ScrollText size={16} />
- 运行日志
- </h3>
- <button
- type="button"
- onClick={onClose}
- className="opacity-80 hover:opacity-100"
- style={{ color: WHITE }}
- >
- <X size={16} />
- </button>
- </div>
- <div
- className="flex flex-wrap items-center gap-2 px-5 py-3 border-b"
- style={{ borderColor: "rgba(var(--dms-bright-rgb),0.2)", color: NAVY }}
- >
- <select
- value={lines}
- onChange={(e) => setLines(Number(e.target.value))}
- className="h-9 px-2 border text-base font-bold"
- style={controlStyle}
- >
- {[200, 500, 1000, 2000, 5000].map((count) => (
- <option key={count} value={count}>
- 尾部 {count} 行
- </option>
- ))}
- </select>
- <select
- value={level}
- onChange={(e) =>
- setLevel(e.target.value as SystemLogLevel | "")
- }
- className="h-9 px-2 border text-base font-bold"
- style={controlStyle}
- >
- <option value="">全部级别</option>
- {LOG_LEVELS.map((name) => (
- <option key={name} value={name}>
- {name}
- </option>
- ))}
- </select>
- <button
- type="button"
- onClick={() => void load()}
- disabled={loading}
- className="flex items-center gap-1 h-9 px-3 text-base font-bold rounded-sm border disabled:opacity-40"
- style={{ ...controlStyle, background: WHITE }}
- >
- <RefreshCw size={13} className={loading ? "animate-spin" : ""} />
- 刷新
- </button>
- <button
- type="button"
- onClick={() => void handleDownload()}
- disabled={downloading}
- className="flex items-center gap-1 h-9 px-3 text-base font-bold rounded-sm border disabled:opacity-40"
- style={{ ...controlStyle, background: WHITE }}
- >
- <Download size={13} />
- {downloading ? "下载中…" : "下载全文"}
- </button>
- </div>
- {error ? (
- <div className="flex-1 p-4 text-base font-bold" style={{ color: "#C0392B" }}>
- {error}
- </div>
- ) : (
- <div className="flex-1 flex flex-col min-h-0">
- <div
- className="px-5 py-2 text-sm border-b"
- style={{
- color: NAVY,
- borderColor: "rgba(var(--dms-bright-rgb),0.12)",
- }}
- >
- {data?.file ? (
- <>
- 文件:{data.file}
- {data.truncated && "(级别过滤为全量扫描后取尾部,非完整结果)"}
- </>
- ) : (
- "未配置运行日志文件(DMS_LOG_FILE),或文件尚未生成。"
- )}
- </div>
- <pre
- className="flex-1 overflow-auto px-4 py-2 m-0 text-sm leading-5"
- style={{ color: NAVY, fontFamily: "Consolas, 'Courier New', monospace" }}
- >
- {data?.error
- ? `读取失败:${data.error}`
- : data && data.lines.length > 0
- ? data.lines.join("\n")
- : "(无日志内容)"}
- </pre>
- </div>
- )}
- </div>
- </div>
- );
- }
- export function AuditDashboard() {
- const dictionaries = useDictionaries();
- const [keyword, setKeyword] = useState("");
- const [actionType, setActionType] = useState("");
- const [operationResult, setOperationResult] = useState("");
- const [targetType, setTargetType] = useState("");
- const [userId, setUserId] = useState("");
- const [organizationId, setOrganizationId] = useState("");
- const [targetId, setTargetId] = useState("");
- const [createdFromLocal, setCreatedFromLocal] = useState("");
- const [createdToLocal, setCreatedToLocal] = useState("");
- const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(20);
- const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
- const [granularity, setGranularity] =
- useState<AuditGranularity>(AuditGranularity.DAY);
- const [runtimeLogOpen, setRuntimeLogOpen] = useState(false);
- const createdFrom = toUtcIso(createdFromLocal);
- const createdTo = toUtcIso(createdToLocal);
- const query: AuditLogQuery = {
- keyword: keyword.trim() || undefined,
- actionType: actionType
- ? (actionType as AuditLogQuery["actionType"])
- : undefined,
- operationResult: operationResult
- ? (operationResult as AuditLogQuery["operationResult"])
- : undefined,
- targetType: targetType
- ? (targetType as AuditLogQuery["targetType"])
- : undefined,
- userId: userId.trim() || undefined,
- organizationId: organizationId.trim() || undefined,
- targetId: targetId.trim() || undefined,
- createdFrom,
- createdTo,
- page,
- pageSize,
- sortDirection,
- };
- const logs = useAuditLogs(query);
- const trend = useAuditTrend({ createdFrom, createdTo, granularity });
- const breakdowns = useAuditBreakdowns(
- { createdFrom, createdTo },
- { createdFrom, createdTo, limit: 6 },
- );
- const pieData = useMemo(
- () =>
- (breakdowns.actions.data ?? []).map((item) => ({
- name: dictionaries.label("auditActions", item.actionType),
- value: item.count,
- actionType: item.actionType,
- })),
- [breakdowns.actions.data, dictionaries.data],
- );
- const activeUsers = useMemo(
- () =>
- (breakdowns.users.data ?? []).map((item) => ({
- ...item,
- label: item.realName ?? `用户 ${item.userId}`,
- })),
- [breakdowns.users.data],
- );
- const resetPage = () => setPage(1);
- const clearFilters = () => {
- setKeyword("");
- setActionType("");
- setOperationResult("");
- setTargetType("");
- setUserId("");
- setOrganizationId("");
- setTargetId("");
- setCreatedFromLocal("");
- setCreatedToLocal("");
- setSortDirection("desc");
- setPage(1);
- };
- return (
- <>
- <aside
- className="w-[286px] flex-shrink-0 m-3 overflow-y-auto bg-white"
- style={{ border: "1px solid rgba(var(--dms-line-rgb),.2)", color: NAVY }}
- >
- <div
- className="px-4 py-3 flex items-center gap-2 border-b"
- style={{ borderColor: "rgba(var(--dms-line-rgb),.14)" }}
- >
- <Filter size={14} style={{ color: BLUE }} />
- <span className="text-base font-black">日志筛选</span>
- <button
- type="button"
- onClick={() => setRuntimeLogOpen(true)}
- className="ml-auto flex items-center gap-1 px-2 py-1 text-base font-bold rounded-sm border"
- style={{ borderColor: BLUE, color: NAVY }}
- title="查看服务端运行日志文件(需日志审计权限)"
- >
- <ScrollText size={13} />
- 运行日志
- </button>
- </div>
- <div className="p-3 space-y-2">
- <label className="flex h-9 items-center border px-2">
- <Search size={13} className="mr-2" style={{ color: BLUE }} />
- <input
- value={keyword}
- onChange={(event) => {
- setKeyword(event.target.value);
- resetPage();
- }}
- className="min-w-0 flex-1 outline-none text-base font-bold"
- placeholder="名称、用户、requestId"
- />
- </label>
- <select
- value={actionType}
- onChange={(event) => {
- setActionType(event.target.value);
- resetPage();
- }}
- className="w-full h-9 border px-2 text-base font-bold"
- >
- <option value="">全部动作</option>
- {dictionaries.options("auditActions").map((option) => (
- <option key={option.code} value={option.code}>
- {option.label}
- </option>
- ))}
- </select>
- <select
- value={operationResult}
- onChange={(event) => {
- setOperationResult(event.target.value);
- resetPage();
- }}
- className="w-full h-9 border px-2 text-base font-bold"
- >
- <option value="">全部结果</option>
- {dictionaries.options("auditResults").map((option) => (
- <option key={option.code} value={option.code}>
- {option.label}
- </option>
- ))}
- </select>
- <select
- value={targetType}
- onChange={(event) => {
- setTargetType(event.target.value);
- resetPage();
- }}
- className="w-full h-9 border px-2 text-base font-bold"
- >
- <option value="">全部对象类型</option>
- {dictionaries.options("auditTargets").map((option) => (
- <option key={option.code} value={option.code}>
- {option.label}
- </option>
- ))}
- </select>
- {[
- ["用户ID", userId, setUserId],
- ["组织ID", organizationId, setOrganizationId],
- ["目标ID", targetId, setTargetId],
- ].map(([label, value, setter]) => (
- <input
- key={label as string}
- value={value as string}
- inputMode="numeric"
- onChange={(event) => {
- (setter as (value: string) => void)(
- event.target.value.replace(/\D/g, ""),
- );
- resetPage();
- }}
- placeholder={label as string}
- className="w-full h-9 border px-2 text-base font-bold"
- />
- ))}
- <label className="block text-base font-black">
- 起始时间(含)
- <input
- type="datetime-local"
- value={createdFromLocal}
- onChange={(event) => {
- setCreatedFromLocal(event.target.value);
- resetPage();
- }}
- className="mt-1 w-full h-9 border px-2 text-base font-bold"
- />
- </label>
- <label className="block text-base font-black">
- 结束时间(不含)
- <input
- type="datetime-local"
- value={createdToLocal}
- onChange={(event) => {
- setCreatedToLocal(event.target.value);
- resetPage();
- }}
- className="mt-1 w-full h-9 border px-2 text-base font-bold"
- />
- </label>
- <div className="flex gap-2">
- <button
- onClick={clearFilters}
- className="flex-1 h-9 border text-base font-black"
- style={{ borderColor: BLUE }}
- >
- 清空
- </button>
- <button
- onClick={() => {
- logs.refresh();
- trend.refresh();
- breakdowns.refresh();
- }}
- className="flex-1 h-9 text-white text-base font-black"
- style={{ background: BLUE }}
- >
- 重新加载
- </button>
- </div>
- <div className="text-base leading-5 opacity-60">
- 时间按本地输入并转换为 UTC Z;查询区间为 [起始, 结束)。
- </div>
- </div>
- </aside>
- <main className="flex-1 flex gap-3 m-3 ml-0 min-w-0 min-h-0">
- <section
- className="flex-1 min-w-0 flex flex-col bg-white"
- style={{ border: "1px solid rgba(var(--dms-line-rgb),.18)" }}
- >
- <div
- className="px-4 py-2.5 flex items-center gap-3 border-b"
- style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
- >
- <span className="font-black">操作日志</span>
- <span className="text-base font-black" style={{ color: "var(--dms-highlight)" }}>
- {logs.data?.total ?? 0} 条
- </span>
- <select
- value={sortDirection}
- onChange={(event) => {
- setSortDirection(event.target.value as "asc" | "desc");
- setPage(1);
- }}
- className="ml-auto h-8 border px-2 text-base font-bold"
- >
- <option value="desc">时间降序</option>
- <option value="asc">时间升序</option>
- </select>
- <select
- value={pageSize}
- onChange={(event) => {
- setPageSize(Number(event.target.value));
- setPage(1);
- }}
- className="h-8 border px-2 text-base font-bold"
- >
- {[20, 50, 100].map((size) => (
- <option key={size} value={size}>
- 每页 {size}
- </option>
- ))}
- </select>
- </div>
- {logs.loading && !logs.data && (
- <div className="p-8 text-base font-bold" style={{ color: NAVY }}>
- <RefreshCw size={14} className="inline animate-spin mr-2" />
- 正在读取审计日志…
- </div>
- )}
- {logs.error && (
- <div className="p-6 text-base font-bold" style={{ color: NAVY }}>
- <div>
- {logs.error.httpStatus === 403
- ? "当前账号无权读取审计日志"
- : logs.error.message}
- </div>
- {logs.error.requestId && (
- <div className="text-base opacity-60 mt-1">
- 错误编号:{logs.error.requestId}
- </div>
- )}
- <button
- onClick={logs.refresh}
- className="mt-3 px-3 py-1.5 text-base text-white"
- style={{ background: BLUE }}
- >
- 重试
- </button>
- </div>
- )}
- {logs.data && logs.data.items.length === 0 && (
- <div className="p-8 text-base font-bold opacity-60" style={{ color: NAVY }}>
- 当前筛选条件下暂无日志
- </div>
- )}
- {logs.data && logs.data.items.length > 0 && (
- <div className="flex-1 overflow-auto">
- <table className="w-full table-fixed min-w-[1000px] border-collapse">
- <colgroup>
- <col style={{ width: 195 }} />
- <col style={{ width: 125 }} />
- <col style={{ width: 120 }} />
- <col style={{ width: 115 }} />
- {/* 对象类型 / 名称:弹性列吃剩余宽度 */}
- <col />
- {/* 结果 / 失败原因:固定窄宽,容纳成功/失败徽标即可,失败原因换行展示 */}
- <col style={{ width: 130 }} />
- {/* 客户端IP:固定宽度保证完整显示 IPv4(最长 255.255.255.255) */}
- <col style={{ width: 180 }} />
- </colgroup>
- <thead className="sticky top-0 z-10 text-white" style={{ background: NAVY }}>
- <tr>
- {[
- "时间",
- "用户名 / 姓名",
- "组织",
- "动作",
- "对象类型 / 名称",
- "结果 / 失败原因",
- "客户端IP",
- ].map((heading) => (
- <th key={heading} className="px-3 py-2 text-left text-base font-black">
- {heading}
- </th>
- ))}
- </tr>
- </thead>
- <tbody>
- {logs.data.items.map((log, index) =>
- "invalid" in log ? (
- <tr
- key={`invalid-${index}`}
- className="border-b"
- style={{
- borderColor: "rgba(183,28,28,.25)",
- background: "rgba(183,28,28,.06)",
- color: "#B71C1C",
- }}
- >
- <td colSpan={7} className="px-3 py-2 text-base font-bold">
- 该行数据不符合DMS接口契约,无法展示
- </td>
- </tr>
- ) : (
- <tr
- key={log.id}
- className={`border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] ${index % 2 ? "bg-[rgba(var(--dms-line-rgb),.04)]" : "bg-white"}`}
- style={{
- color: NAVY,
- borderColor: "rgba(var(--dms-line-rgb),.1)",
- }}
- >
- <td
- className="px-3 py-2 text-base font-bold truncate"
- title={formatAuditDate(log.createdAt)}
- >
- {formatAuditDate(log.createdAt)}
- </td>
- <td className="px-3 py-2 text-base font-bold">
- <div className="truncate" title={log.username ?? EMPTY}>
- {log.username ?? EMPTY}
- </div>
- <div className="truncate opacity-60" title={log.realName ?? EMPTY}>
- {log.realName ?? EMPTY}
- </div>
- </td>
- <td
- className="px-3 py-2 text-base font-bold truncate"
- title={log.organizationName ?? EMPTY}
- >
- {log.organizationName ?? EMPTY}
- </td>
- <td
- className="px-3 py-2 text-base font-black truncate"
- title={dictionaries.label("auditActions", log.actionType)}
- >
- {dictionaries.label("auditActions", log.actionType)}
- </td>
- <td className="px-3 py-2 text-base font-bold">
- <div
- className="truncate"
- title={
- log.targetType
- ? dictionaries.label("auditTargets", log.targetType)
- : EMPTY
- }
- >
- {log.targetType
- ? dictionaries.label("auditTargets", log.targetType)
- : EMPTY}
- </div>
- <div
- className="truncate opacity-60"
- title={
- log.targetName
- ? `${log.targetName}${log.targetId ? `(${log.targetId})` : ""}`
- : EMPTY
- }
- >
- {log.targetName ?? EMPTY}
- {log.targetId ? `(${log.targetId})` : ""}
- </div>
- </td>
- <td className="px-3 py-2 text-base font-bold">
- <span
- className="inline-block px-2 py-0.5 text-white"
- style={{
- background:
- log.operationResult === AuditOperationResult.SUCCESS
- ? BLUE
- : NAVY,
- }}
- >
- {dictionaries.label("auditResults", log.operationResult)}
- </span>
- <div className="mt-1 opacity-60 break-words">
- {log.failureReason ?? EMPTY}
- </div>
- </td>
- <td
- className="px-3 py-2 text-base font-bold truncate"
- title={log.clientIp ?? EMPTY}
- >
- {log.clientIp ?? EMPTY}
- </td>
- </tr>
- )
- )}
- </tbody>
- </table>
- </div>
- )}
- <div
- className="p-2 border-t flex items-center justify-center gap-3 text-base font-bold"
- style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
- >
- <button
- disabled={page <= 1}
- onClick={() => setPage((value) => Math.max(1, value - 1))}
- className="px-3 py-1 border disabled:opacity-30"
- >
- 上一页
- </button>
- <span>
- 第 {logs.data?.page ?? page} / {logs.data?.totalPages ?? 0} 页
- </span>
- <button
- disabled={!logs.data || page >= logs.data.totalPages}
- onClick={() => setPage((value) => value + 1)}
- className="px-3 py-1 border disabled:opacity-30"
- >
- 下一页
- </button>
- </div>
- </section>
- <aside className="w-[330px] flex-shrink-0 overflow-y-auto space-y-3">
- <StatsPanel
- title="操作趋势"
- icon={<BarChart2 size={14} style={{ color: BLUE }} />}
- >
- <div className="flex gap-1 mb-2">
- {Object.values(AuditGranularity).map((value) => (
- <button
- key={value}
- onClick={() => setGranularity(value)}
- className="flex-1 py-1 text-base font-black"
- style={{
- color: granularity === value ? WHITE : NAVY,
- background: granularity === value ? NAVY : "var(--dms-canvas)",
- }}
- >
- {value}
- </button>
- ))}
- </div>
- <ResourceState<AuditTrendItem[]>
- state={trend}
- onRetry={trend.refresh}
- label="趋势数据"
- />
- {trend.data && trend.data.length > 0 && (
- <>
- <ResponsiveContainer width="100%" height={115}>
- <LineChart data={trend.data}>
- <CartesianGrid strokeDasharray="3 3" stroke="rgba(var(--dms-line-rgb),.12)" />
- <XAxis dataKey="period" tick={{ fontSize: 18, fill: NAVY }} />
- <YAxis tick={{ fontSize: 18, fill: NAVY }} />
- <Tooltip />
- <Line
- dataKey="operationCount"
- name="操作次数"
- stroke={BLUE}
- strokeWidth={2}
- />
- </LineChart>
- </ResponsiveContainer>
- </>
- )}
- </StatsPanel>
- <StatsPanel
- title="动作分布"
- icon={<Activity size={14} style={{ color: BLUE }} />}
- >
- <ResourceState<AuditActionStatisticsItem[]>
- state={breakdowns.actions}
- onRetry={breakdowns.refresh}
- label="动作分布"
- />
- {pieData.length > 0 && (
- <>
- <ResponsiveContainer width="100%" height={120}>
- <PieChart>
- <Pie
- data={pieData}
- dataKey="value"
- nameKey="name"
- innerRadius={25}
- outerRadius={45}
- >
- {pieData.map((item, index) => (
- <Cell
- key={item.actionType}
- fill={COLORS[index % COLORS.length]}
- />
- ))}
- </Pie>
- <Tooltip />
- </PieChart>
- </ResponsiveContainer>
- </>
- )}
- </StatsPanel>
- <StatsPanel
- title="活跃用户 TOP 6"
- icon={<Users size={14} style={{ color: BLUE }} />}
- >
- <ResourceState<AuditUserStatisticsItem[]>
- state={breakdowns.users}
- onRetry={breakdowns.refresh}
- label="活跃用户"
- />
- {activeUsers.length > 0 && (
- <>
- <ResponsiveContainer width="100%" height={115}>
- <BarChart data={activeUsers} layout="vertical">
- <CartesianGrid strokeDasharray="3 3" horizontal={false} />
- <XAxis type="number" tick={{ fontSize: 18 }} />
- <YAxis
- type="category"
- dataKey="label"
- width={76}
- tick={{ fontSize: 18, fill: NAVY }}
- />
- <Tooltip />
- <Bar
- dataKey="operationCount"
- name="操作次数"
- fill={BLUE}
- />
- </BarChart>
- </ResponsiveContainer>
- </>
- )}
- </StatsPanel>
- </aside>
- </main>
- {runtimeLogOpen && <RuntimeLogModal onClose={() => setRuntimeLogOpen(false)} />}
- </>
- );
- }
|