diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index 939cda2..55de865 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -3,22 +3,29 @@ 与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/ 时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到 结构化日志里的那一次请求。 + +另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。 """ from __future__ import annotations +import csv +import io from datetime import datetime, time, timedelta +from typing import Any, Callable -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.deps import require_admin -from app.core.time_utils import BEIJING_TZ +from app.core.time_utils import BEIJING_TZ, get_beijing_time from app.schemas.audit import ( AuditLogListResponse, AuditLogResponse, AuditOption, AuditOptionsResponse, + DailyUsageResponse, + DailyUsageRow, ) from app.services import audit_service from app.services.audit_service import ACTION_LABELS, MODULE_LABELS @@ -92,8 +99,194 @@ async def get_audit_logs( async def get_audit_options( current_user: dict = Depends(require_admin), ) -> AuditOptionsResponse: - """筛选项:模块与动作的中文下拉""" + """筛选项:模块与动作的中文下拉;顺带下发导出可选列""" return AuditOptionsResponse( modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()], actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()], + log_export_columns=[ + AuditOption(value=k, label=v[0]) for k, v in _AUDIT_LOG_COLUMNS.items() + ], + usage_export_columns=[ + AuditOption(value=k, label=v[0]) for k, v in _DAILY_USAGE_COLUMNS.items() + ], + ) + + +# ============================================================ +# CSV 导出 +# ============================================================ + +def _bj(dt: datetime | None) -> str: + """时间列统一按北京时间输出(与列表页、日活分日口径一致)。 + + 直接输出 UTC 会让导出文件里 01:00 的操作显示成前一天 17:00, + 与网页上看到的对不上 —— 导出与页面不一致是最容易被质疑的那种问题。 + """ + if dt is None: + return "" + return dt.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def _actor(log) -> str: + """操作人:优先中文名,退化为账号(与列表页的展示规则一致)""" + if not log.user_id and not log.display_name: + return "未认证" + return f"{log.display_name}({log.user_id})" if log.display_name else (log.user_id or "") + + +# 列定义:key → (表头, 取值函数)。 +# 前端只传 key 列表,中文表头与取值口径都由后端统一维护, +# 避免两端各写一份导致"导出的列和页面上的对不上"。 +_AUDIT_LOG_COLUMNS: dict[str, tuple[str, Callable[[Any], Any]]] = { + "time": ("时间", lambda r: _bj(r.created_at)), + "user": ("操作人", _actor), + "role": ("角色", lambda r: r.role or ""), + "module": ("模块", lambda r: MODULE_LABELS.get(r.module, r.module)), + "action": ("动作", lambda r: ACTION_LABELS.get(r.action, r.action)), + "method": ("方法", lambda r: r.method or ""), + "url": ("请求路径", lambda r: r.url or ""), + "status": ("结果", lambda r: r.status_code if r.status_code is not None else ""), + "ip": ("来源IP", lambda r: r.ip_address or ""), + "target": ("目标", lambda r: f"{r.target_type or ''}:{r.target_id or ''}".strip(":")), + "error": ("错误信息", lambda r: r.error_message or ""), + "request_id": ("请求ID", lambda r: r.request_id or ""), + "user_agent": ("User-Agent", lambda r: r.user_agent or ""), +} + +_DAILY_USAGE_COLUMNS: dict[str, tuple[str, Callable[[dict], Any]]] = { + "day": ("日期", lambda r: r["day"]), + "user": ("操作人", lambda r: f"{r['display_name']}({r['user_id']})" if r["display_name"] else (r["user_id"] or "")), + "role": ("角色", lambda r: r["role"] or ""), + # 上线/下线时间 = 当天首次/末次活动(非登录时间), + # 登录/登出次数单独成列,两者不再混为一谈 + "first_active": ("上线时间", lambda r: _bj(r["first_active_at"])), + "last_active": ("下线时间", lambda r: _bj(r["last_active_at"])), + "login_count": ("登录次数", lambda r: r["login_count"]), + "logout_count": ("登出次数", lambda r: r["logout_count"]), + "op_count": ("操作次数", lambda r: r["op_count"]), +} + + +def _csv_response( + columns: dict[str, tuple[str, Callable]], keys: list[str], rows: list, filename: str, +) -> Response: + """把行数据渲染成 CSV 响应。 + + ⚠️ 必须带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头与内容全是乱码。 + 这是 CSV 导出最常见、也最容易被忽略的坑。 + """ + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([columns[k][0] for k in keys]) + for row in rows: + writer.writerow([columns[k][1](row) for k in keys]) + + return Response( + content=b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8"), + media_type="text/csv; charset=utf-8", + # 文件名用纯 ASCII:中文文件名要走 RFC 5987,各浏览器行为不一致, + # 内部系统没必要为它引入兼容成本。 + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _resolve_keys(raw: str | None, columns: dict) -> list[str]: + """解析前端传来的列 key。缺省 = 全部列;未知 key 直接忽略(不报错)。""" + if not raw: + return list(columns) + keys = [k.strip() for k in raw.split(",") if k.strip() in columns] + return keys or list(columns) + + +@router.get("/logs/export") +async def export_audit_logs( + user_id: str | None = Query(None, description="操作人账号(模糊匹配)"), + module: str | None = Query(None, description="业务模块"), + action: str | None = Query(None, description="动作类型"), + target_id: str | None = Query(None, description="目标ID"), + request_id: str | None = Query(None, description="请求ID"), + status_code: int | None = Query(None, description="响应状态码"), + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"), + columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> Response: + """审计明细 CSV 导出 —— 筛选维度与 /logs 完全一致,保证"看到什么就能导出什么"。""" + start = _parse_day(start_date) + end_exclusive = _parse_day(end_date, end_of_day=True) + + rows, truncated = await audit_service.export_audit_logs( + db, + user_id=user_id, module=module, action=action, target_id=target_id, + request_id=request_id, status_code=status_code, + start=start, + end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None, + ) + + keys = _resolve_keys(columns, _AUDIT_LOG_COLUMNS) + resp = _csv_response(_AUDIT_LOG_COLUMNS, keys, rows, "audit_logs.csv") + if truncated: + # 用响应头传递"已截断",前端据此提示用户收窄筛选条件 + resp.headers["X-Export-Truncated"] = "1" + resp.headers["X-Export-Max-Rows"] = str(audit_service.EXPORT_MAX_ROWS) + resp.headers["Access-Control-Expose-Headers"] = "X-Export-Truncated, X-Export-Max-Rows" + return resp + + +@router.get("/daily-usage/export") +async def export_daily_usage( + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"), + columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> Response: + """日活统计 CSV 导出 —— 每人一行:上线/下线次数与时间、操作次数。""" + start = _parse_day(start_date) or datetime.combine( + get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ, + ) + end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1)) + + items = await audit_service.get_daily_usage(db, start=start, end=end) + keys = _resolve_keys(columns, _DAILY_USAGE_COLUMNS) + return _csv_response(_DAILY_USAGE_COLUMNS, keys, items, "daily_usage.csv") + + +@router.get("/daily-usage", response_model=DailyUsageResponse) +async def get_daily_usage( + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> DailyUsageResponse: + """日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合。 + + 回答的是「每天有哪些人用了系统、用了多少」: + · 上线时间 / 下线时间:当天**首次 / 末次活动**时间(任意审计记录) + · 操作次数:当天该用户的全部审计记录数(使用深度) + · 登录次数 / 登出次数:真实的手动登录 / 登出行为计数 + + ⚠️ 上线时间【不取登录时间】:token 有效期内(refresh 7 天)用户不重新登录, + 按登录算会让「周一登录、周二继续用」的周二变成"登录次数 0、上线时间空, + 但操作次数 35"——报表自相矛盾。改用活动口径后,当天的第一次操作即上线时间。 + + ⚠️ 登出次数天然小于登录次数:用户直接关浏览器、断网、token 过期都不会 + 产生登出记录。这是真实情况,不做任何"补齐"推算。 + """ + # 起始日:未传则取北京的今天。_parse_day 返回的是北京时间当日 00:00。 + start = _parse_day(start_date) or datetime.combine( + get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ, + ) + # 结束日:_parse_day(end_of_day=True) 已给出「次日 00:00」,正好当作半开上界。 + # 未传则默认单日查询(= 起始日当天)。 + end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1)) + + items = await audit_service.get_daily_usage(db, start=start, end=end) + + return DailyUsageResponse( + start_date=start.astimezone(BEIJING_TZ).strftime("%Y-%m-%d"), + end_date=(end - timedelta(days=1)).astimezone(BEIJING_TZ).strftime("%Y-%m-%d"), + items=[DailyUsageRow(**row) for row in items], + total=len(items), ) diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py index 0e26eaa..b63877a 100644 --- a/backend/app/schemas/audit.py +++ b/backend/app/schemas/audit.py @@ -45,6 +45,32 @@ class AuditLogListResponse(BaseModel): total: int +class DailyUsageRow(BaseModel): + """某个操作人在某一天的用量汇总(北京时间自然日)""" + day: str # YYYY-MM-DD(北京时间) + user_id: str | None = None + display_name: str | None = None + role: str | None = None + + login_count: int = 0 # 登录次数(当天成功登录) + logout_count: int = 0 # 登出次数(当天成功登出) + op_count: int = 0 # 操作次数(当天全部审计记录数) + + # ⚠️ 上线/下线时间取【当天首次/末次活动】,不是登录/登出时间: + # token 有效期内(refresh 7 天)用户不会重新登录,按登录算会导致 + # 「登录次数 0 但操作 35 次」这种自相矛盾。 + first_active_at: datetime | None = None # 上线时间(当天首次活动) + last_active_at: datetime | None = None # 下线时间(当天末次活动) + + +class DailyUsageResponse(BaseModel): + """日活 / 使用统计""" + start_date: str + end_date: str + items: list[DailyUsageRow] + total: int # 行数(= 天数 × 人数),不是审计记录数 + + class AuditOption(BaseModel): """筛选项(value/label 结构,直接喂给前端下拉)""" value: str @@ -55,3 +81,8 @@ class AuditOptionsResponse(BaseModel): """筛选项集合""" modules: list[AuditOption] actions: list[AuditOption] + # 导出可选的列(value=后端列 key,label=中文表头)。 + # 由后端下发而非前端硬编码:列的中文名与取值口径都在后端, + # 两端各写一份迟早会出现"导出的列和页面上的对不上"。 + log_export_columns: list[AuditOption] = [] + usage_export_columns: list[AuditOption] = [] diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index 532e17c..e032902 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -18,7 +18,7 @@ import logging import uuid from datetime import datetime -from sqlalchemy import func, select +from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import AsyncSessionLocal @@ -165,6 +165,54 @@ async def list_audit_logs( 真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。 """ + filters = _log_filters( + user_id=user_id, module=module, action=action, target_id=target_id, + request_id=request_id, status_code=status_code, start=start, end=end, + ) + + total = await db.scalar( + select(func.count()).select_from(AuditLog).where(*filters) + ) or 0 + + rows = ( + await db.execute( + select(AuditLog) + .where(*filters) + .order_by(AuditLog.created_at.desc()) + .offset(skip) + .limit(limit) + ) + ).scalars().all() + + return list(rows), total + + +# ============================================================ +# 导出 +# ============================================================ + +# 单次导出的行数上限。审计表只增不减,全量导出迟早会撑爆内存与浏览器, +# 故设硬上限;超出时向上层返回 truncated=True,由前端明确提示「已截断」—— +# 静默截断会让使用者以为导全了,比报错更危险。 +EXPORT_MAX_ROWS = 50000 + + +def _log_filters( + *, + user_id: str | None = None, + module: str | None = None, + action: str | None = None, + target_id: str | None = None, + request_id: str | None = None, + status_code: int | None = None, + start: datetime | None = None, + end: datetime | None = None, +) -> list: + """审计日志的筛选条件 —— list_audit_logs 与 export_audit_logs 共用。 + + 抽出来的唯一目的:保证「列表看到的」和「导出出去的」永远是同一批数据。 + 两处各写一份迟早会漂移,而导出与列表不一致是最让人不信任的那种 bug。 + """ filters = [] if user_id: filters.append(AuditLog.user_id.ilike(f"%{user_id}%")) @@ -182,19 +230,110 @@ async def list_audit_logs( filters.append(AuditLog.created_at >= start) if end: filters.append(AuditLog.created_at <= end) + return filters - total = await db.scalar( - select(func.count()).select_from(AuditLog).where(*filters) - ) or 0 +async def export_audit_logs( + db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs, +) -> tuple[list[AuditLog], bool]: + """导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。 + + 多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。 + """ rows = ( await db.execute( select(AuditLog) - .where(*filters) + .where(*_log_filters(**kwargs)) .order_by(AuditLog.created_at.desc()) - .offset(skip) - .limit(limit) + .limit(limit + 1) ) ).scalars().all() + truncated = len(rows) > limit + return list(rows[:limit]), truncated - return list(rows), total + +# ============================================================ +# 日活 / 使用统计 +# ============================================================ + +# 成功 = 2xx/3xx。登录失败(401)也要留痕,但不应计入"上线次数"。 +_OK_STATUS_UPPER = 400 + + +async def get_daily_usage( + db: AsyncSession, *, start: datetime, end: datetime, +) -> list[dict]: + """按【北京时间自然日 × 操作人】聚合用量 —— 日活报表的数据源。 + + start/end 为半开区间 [start, end),调用方按北京时间日界传入。 + + 全部指标由**一个 GROUP BY 查询**算出,不用窗口函数: + · 登录/登出次数 = 成功登录 / 成功登出数(最终凭证是 login_count,不是"上线次数") + · 操作频次 = 当天该用户的全部审计记录数(代表系统使用深度) + · 上线/下线时间 = 当天**首次 / 末次活动**时间(任意审计记录) + + ⚠️ 上线/下线时间【不能】取登录/登出时间。 + Access/Refresh Token 有效期内(refresh 7 天)用户无需重新登录, + 于是"周一登录、周二到周日继续用"会导致周二~周日: + 登录次数=0、登录时间=空,但操作次数却是几十 —— 报表自相矛盾。 + 改用活动口径后,工人当天的第一次操作就是真实上线时间。 + (审计中间件是全站的,移动端的接收/转交/完工同样入账,故自动覆盖移动端, + 无需前端上报心跳。) + + 为什么用 `count(*) FILTER (WHERE ...)`:分组内一次扫描同时算出多个条件计数, + 比多次子查询或 UNION 简单得多,且语义一眼可读。Postgres 原生支持。 + + ⚠️ 按【北京时间】分日:created_at 是 timestamptz(实存 UTC), + 直接按 UTC 分日会让 00:00~08:00 的早班操作掉到前一天。 + """ + day_col = func.date(func.timezone("Asia/Shanghai", AuditLog.created_at)) + + login_ok = and_( + AuditLog.action == "login", AuditLog.status_code < _OK_STATUS_UPPER, + ) + logout_ok = and_( + AuditLog.action == "logout", AuditLog.status_code < _OK_STATUS_UPPER, + ) + + stmt = ( + select( + day_col.label("day"), + AuditLog.user_id.label("user_id"), + # 同一用户的 display_name / role 是一致的,取 max 只是为了 + # 在 GROUP BY 下拿到一个非空代表值(避免再套一层 DISTINCT ON) + func.max(AuditLog.display_name).label("display_name"), + func.max(AuditLog.role).label("role"), + func.count().filter(login_ok).label("login_count"), + func.count().filter(logout_ok).label("logout_count"), + func.count().label("op_count"), + # 上线/下线时间取「任意记录」的首末,而不是登录/登出的首末(原因见 docstring) + func.min(AuditLog.created_at).label("first_active_at"), + func.max(AuditLog.created_at).label("last_active_at"), + ) + .where( + AuditLog.created_at >= start, + AuditLog.created_at < end, + # 只统计"人":未认证请求(如登录前的探测、refresh)没有操作人, + # 混进来会让"日活人数"虚高。若要排查匿名异常流量,走日志列表页按 + # 结果/来源 IP 过滤更合适。 + AuditLog.user_id.isnot(None), + ) + .group_by(day_col, AuditLog.user_id) + .order_by(day_col.desc(), func.count().desc()) + ) + + rows = (await db.execute(stmt)).all() + return [ + { + "day": r.day.strftime("%Y-%m-%d") if hasattr(r.day, "strftime") else str(r.day), + "user_id": r.user_id, + "display_name": r.display_name, + "role": r.role, + "login_count": r.login_count or 0, + "logout_count": r.logout_count or 0, + "op_count": r.op_count or 0, + "first_active_at": r.first_active_at, + "last_active_at": r.last_active_at, + } + for r in rows + ] diff --git a/frontend/src/components/admin/ExportColumnsModal.tsx b/frontend/src/components/admin/ExportColumnsModal.tsx new file mode 100644 index 0000000..e228b67 --- /dev/null +++ b/frontend/src/components/admin/ExportColumnsModal.tsx @@ -0,0 +1,75 @@ +/** + * 导出列选择弹窗 —— 勾选要写进 CSV 的列。 + * + * 列清单由后端 /audit/options 下发(value=后端列 key,label=中文表头), + * 前端不硬编码表头:否则两端各维护一份,迟早出现「导出的列和页面对不上」。 + * + * 默认全选 —— 大多数人只是想"全部导出来",不该逼他们先勾一遍。 + */ +import { useEffect, useState } from "react"; +import { Modal, Checkbox, Button } from "antd"; +import type { AuditOption } from "../../services/auditApi"; + +export default function ExportColumnsModal({ + open, + columns, + submitting, + onCancel, + onConfirm, +}: { + open: boolean; + columns: AuditOption[]; + submitting?: boolean; + onCancel: () => void; + /** 传出当前勾选的列 key(顺序 = 后端下发顺序,保证表头稳定) */ + onConfirm: (keys: string[]) => void; +}) { + const [checked, setChecked] = useState([]); + + // 每次打开都重置为全选:上一次的勾选残留会让用户莫名少导几列 + useEffect(() => { + if (open) setChecked(columns.map((c) => c.value)); + }, [open, columns]); + + return ( + +
+ + +
+
+ + +
+ + } + > + {checked.length === 0 && ( +

至少勾选一列才能导出。

+ )} + setChecked(v as string[])} + className="grid grid-cols-3 gap-y-2" + options={columns.map((c) => ({ value: c.value, label: c.label }))} + /> +
+ ); +} diff --git a/frontend/src/pages/admin/AdminAuditLogPage.tsx b/frontend/src/pages/admin/AdminAuditLogPage.tsx index 08b1cd9..faffa5e 100644 --- a/frontend/src/pages/admin/AdminAuditLogPage.tsx +++ b/frontend/src/pages/admin/AdminAuditLogPage.tsx @@ -1,11 +1,17 @@ /** 操作审计日志 — 谁 / 何时 / 从哪 / 对什么 / 做了什么事 / 结果如何 */ import { useCallback, useEffect, useState } from "react"; -import { ScrollText, Loader2, AlertCircle, RefreshCw, Search, X } from "lucide-react"; +import { ScrollText, Loader2, AlertCircle, RefreshCw, Search, X, Download, BarChart3 } 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 { + fetchAuditLogs, fetchAuditOptions, exportAuditLogsCsv, + type AuditLogItem, type AuditOption, +} from "../../services/auditApi"; import { extractErrorMessage } from "../../utils/errorMessage"; +import { useToast } from "../../components/ui/Toast"; +import AuditUsagePanel from "./AuditUsagePanel"; +import ExportColumnsModal from "../../components/admin/ExportColumnsModal"; const { RangePicker } = DatePicker; @@ -36,8 +42,15 @@ export default function AdminAuditLogPage() { const [error, setError] = useState(null); const [detail, setDetail] = useState(null); + const { toast } = useToast(); const [modules, setModules] = useState([]); const [actions, setActions] = useState([]); + /** 导出可选列 —— 由后端下发,前端不硬编码表头 */ + const [logColumns, setLogColumns] = useState([]); + + const [usageOpen, setUsageOpen] = useState(false); // 人员统计抽屉 + const [exportPickerOpen, setExportPickerOpen] = useState(false); + const [exporting, setExporting] = useState(false); // 筛选条件(user_id 用受控输入,其余即时生效) const [userInput, setUserInput] = useState(""); @@ -81,12 +94,43 @@ export default function AdminAuditLogPage() { .then((o) => { setModules(o.modules); setActions(o.actions); + setLogColumns(o.log_export_columns || []); }) .catch(() => { /* 筛选项拉取失败不影响列表本身 */ }); }, []); + /** + * 导出当前筛选条件下的明细。 + * 刻意复用与列表完全相同的筛选参数 —— 导出与"看到的"必须是同一批数据, + * 否则使用者会怀疑到底哪份才是真的。 + */ + async function handleExport(columns: string[]) { + setExporting(true); + try { + const truncated = await exportAuditLogsCsv({ + 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"), + columns, + }); + setExportPickerOpen(false); + // 截断必须显式告知:静默少几万行比报错更危险 + toast( + truncated ? "已导出,但数据超上限已被截断,请收窄筛选条件" : "已导出 CSV", + truncated ? "error" : "success", + ); + } catch (e) { + toast(extractErrorMessage(e, "导出失败"), "error"); + } finally { + setExporting(false); + } + } + const hasFilter = !!(userId || module || action || statusCode || range); const resetFilters = () => { @@ -185,9 +229,23 @@ export default function AdminAuditLogPage() { 所有写操作(含被拒绝的请求)自动留痕,共 {total} 条

- +
+ {/* 人员统计刻意做成抽屉而不是标签页:两个视图的粒度不同 + (一行一次操作 vs 一人一天一行),并列成 Tab 会让筛选状态互相干扰 */} + + + +
{/* 筛选区 */} @@ -350,6 +408,17 @@ export default function AdminAuditLogPage() { )} + + {/* 人员统计:独立抽屉,本页表格与筛选完全不受影响 */} + setUsageOpen(false)} /> + + setExportPickerOpen(false)} + onConfirm={handleExport} + /> ); } diff --git a/frontend/src/pages/admin/AuditUsagePanel.tsx b/frontend/src/pages/admin/AuditUsagePanel.tsx new file mode 100644 index 0000000..8232d90 --- /dev/null +++ b/frontend/src/pages/admin/AuditUsagePanel.tsx @@ -0,0 +1,207 @@ +/** + * 人员统计(日活报表)—— 以抽屉形式挂在操作审计页旁边。 + * + * 回答的是「每天有哪些人用了系统、用了多少」: + * 上线次数 / 上线时间、下线次数 / 下线时间、操作次数。 + * + * 刻意不复用审计明细页的表格:两者的粒度不同(一个是一行一次操作, + * 一个是一人一天一行),合在一起筛选状态会互相干扰。 + */ +import { useCallback, useEffect, useState } from "react"; +import { Drawer, Table, DatePicker, Button, Alert, Tag, Empty } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { Download, Loader2, RefreshCw } from "lucide-react"; +import dayjs, { type Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { + fetchDailyUsage, fetchAuditOptions, exportDailyUsageCsv, + type DailyUsageRow, type AuditOption, +} from "../../services/auditApi"; +import { extractErrorMessage } from "../../utils/errorMessage"; +import { useToast } from "../../components/ui/Toast"; +import ExportColumnsModal from "../../components/admin/ExportColumnsModal"; + +// 后端返回的是 UTC,而统计按【北京时间自然日】分组。 +// 必须显式按 +08:00 渲染 —— 依赖浏览器本地时区的话,一旦有人机器不在东八区, +// 时间就会和「日期」列对不上(比如显示 17:00 而日期是次日)。 +dayjs.extend(utc); +const BJ_OFFSET_MIN = 8 * 60; + +function bjTime(v: string | null): string { + if (!v) return "—"; + return dayjs.utc(v).utcOffset(BJ_OFFSET_MIN).format("HH:mm"); +} + +/** 「上线 vs 下线」次数配色:有记录就显眼,0 就淡化 */ +function countTag(n: number, cls: string) { + if (!n) return 0; + return {n}; +} + +export default function AuditUsagePanel({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); + + const [usageColumns, setUsageColumns] = useState([]); + const [pickerOpen, setPickerOpen] = useState(false); + const [exporting, setExporting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchDailyUsage({ + start_date: range[0].format("YYYY-MM-DD"), + end_date: range[1].format("YYYY-MM-DD"), + }); + setRows(res.items); + } catch (err: unknown) { + setError(extractErrorMessage(err, "加载使用统计失败")); + setRows([]); + } finally { + setLoading(false); + } + }, [range]); + + useEffect(() => { + if (open) load(); + }, [open, load]); + + // 列清单只需拉一次;失败不阻断表格本身 + useEffect(() => { + if (!open || usageColumns.length) return; + fetchAuditOptions() + .then((o) => setUsageColumns(o.usage_export_columns || [])) + .catch(() => { /* 拉不到列清单只影响导出,不影响查看 */ }); + }, [open, usageColumns.length]); + + async function handleExport(columns: string[]) { + setExporting(true); + try { + const truncated = await exportDailyUsageCsv({ + start_date: range[0].format("YYYY-MM-DD"), + end_date: range[1].format("YYYY-MM-DD"), + columns, + }); + setPickerOpen(false); + toast(truncated ? "已导出(数据超上限,已截断)" : "已导出 CSV", truncated ? "error" : "success"); + } catch (err: unknown) { + toast(extractErrorMessage(err, "导出失败"), "error"); + } finally { + setExporting(false); + } + } + + const multiDay = range[0].format("YYYY-MM-DD") !== range[1].format("YYYY-MM-DD"); + + const columns: ColumnsType = [ + // 单日查询时日期列是冗余的,自动隐藏,少一列噪音 + ...(multiDay + ? [{ title: "日期", dataIndex: "day", width: 110, + sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.day.localeCompare(b.day) }] + : []), + { + title: "操作人", dataIndex: "display_name", width: 160, + render: (_: unknown, r: DailyUsageRow) => ( +
+
{r.display_name || r.user_id || "—"}
+ {r.display_name &&
{r.user_id}
} +
+ ), + }, + // 上线/下线时间 = 当天首次/末次【活动】。token 有效期内用户不重新登录, + // 若取登录时间会得出"登录 0 次却操作 35 次"的矛盾数据(见后端 docstring) + { title: "上线时间", dataIndex: "first_active_at", width: 100, align: "center", + render: (v: string | null) => {bjTime(v)} }, + { title: "下线时间", dataIndex: "last_active_at", width: 100, align: "center", + render: (v: string | null) => {bjTime(v)} }, + { title: "操作次数", dataIndex: "op_count", width: 110, align: "center", + render: (v: number) => {v}, + sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.op_count - b.op_count, + defaultSortOrder: "descend" as const }, + // 登录/登出次数是真实的手动行为计数,与上面的活动时间并列展示,不混为一谈 + { title: "登录次数", dataIndex: "login_count", width: 100, align: "center", + render: (v: number) => countTag(v, "bg-emerald-100 text-emerald-700"), + sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.login_count - b.login_count }, + { title: "登出次数", dataIndex: "logout_count", width: 100, align: "center", + render: (v: number) => countTag(v, "bg-blue-100 text-blue-700"), + sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.logout_count - b.logout_count }, + ]; + + return ( + } onClick={load} disabled={loading}> + 刷新 + + } + > +
+ { if (v?.[0] && v?.[1]) setRange([v[0], v[1]]); }} + presets={[ + { label: "今天", value: [dayjs(), dayjs()] }, + { label: "昨天", value: [dayjs().subtract(1, "day"), dayjs().subtract(1, "day")] }, + { label: "近 7 天", value: [dayjs().subtract(6, "day"), dayjs()] }, + { label: "本月", value: [dayjs().startOf("month"), dayjs()] }, + ]} + /> + + + {rows.length > 0 && `共 ${rows.length} 人·天`} | 时间均为北京时间 + +
+ + {error && ( + + )} + + {/* 两处口径容易被误读,直接写在表格上方 */} +

+ ⓘ 「上线/下线时间」= 当天首次/末次活动时间,不是登录时间 —— + 登录状态可保持 7 天,当天不登录也会正常统计。 + 「登录/登出次数」是真实的手动登录行为计数,登出通常少于登录(关浏览器、断网不产生登出记录)。 +

+ + + rowKey={(r) => `${r.day}|${r.user_id ?? ""}`} + size="small" + columns={columns} + dataSource={rows} + loading={{ spinning: loading, indicator: }} + pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} 条` }} + locale={{ emptyText: }} + /> + + setPickerOpen(false)} + onConfirm={handleExport} + /> +
+ ); +} diff --git a/frontend/src/services/auditApi.ts b/frontend/src/services/auditApi.ts index 195cab7..75439e4 100644 --- a/frontend/src/services/auditApi.ts +++ b/frontend/src/services/auditApi.ts @@ -44,6 +44,34 @@ export interface AuditOption { export interface AuditOptionsResponse { modules: AuditOption[]; actions: AuditOption[]; + /** 导出可选列(value=后端列 key,label=中文表头)—— 由后端下发,前端不再硬编码 */ + log_export_columns: AuditOption[]; + usage_export_columns: AuditOption[]; +} + +/** 日活统计的单行(某人在某一天的用量) */ +export interface DailyUsageRow { + day: string; + user_id: string | null; + display_name: string | null; + role: string | null; + login_count: number; + logout_count: number; + op_count: number; + /** + * 上线 / 下线时间 = 当天首次 / 末次【活动】时间(不是登录时间)。 + * token 有效期内用户不重新登录,按登录算会得出"登录 0 次却操作 35 次"的矛盾数据。 + * ISO(UTC),展示前必须转北京时间,否则会和 day 列对不上。 + */ + first_active_at: string | null; + last_active_at: string | null; +} + +export interface DailyUsageResponse { + start_date: string; + end_date: string; + items: DailyUsageRow[]; + total: number; } export interface AuditLogQuery { @@ -70,8 +98,74 @@ export async function fetchAuditLogs(q: AuditLogQuery = {}): Promise { const { data } = await api.get("/audit/options"); return data; } + +/** 日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合 */ +export async function fetchDailyUsage(params: { + start_date?: string; + end_date?: string; +} = {}): Promise { + const { data } = await api.get("/audit/daily-usage", { params }); + return data; +} + +/** + * 触发浏览器下载一个 CSV。 + * + * ⚠️ 不能直接用 或 window.open:本项目是 Bearer Token 鉴权 + * (token 在 localStorage,不在 Cookie),普通链接带不上 Authorization 头, + * 后端会直接 401。必须先经 axios 取回 blob 再本地落盘。 + * + * @returns 是否因超出后端行数上限而被截断(调用方据此提示用户,不要静默) + */ +async function downloadCsv( + path: string, + params: Record, + filename: string, +): Promise { + const resp = await api.get(path, { params, responseType: "blob" }); + const url = URL.createObjectURL(resp.data as Blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + return resp.headers["x-export-truncated"] === "1"; +} + +/** 导出审计明细(列可自定义,columns 为后端列 key 数组;不传=全部列) */ +export function exportAuditLogsCsv( + q: AuditLogQuery & { columns?: string[] }, +): Promise { + const { columns, ...rest } = q; + return downloadCsv( + "/audit/logs/export", + { ...clean(rest), columns: columns?.join(",") }, + "audit_logs.csv", + ); +} + +/** 导出日活统计(每人一行,列可自定义) */ +export function exportDailyUsageCsv( + params: { start_date?: string; end_date?: string; columns?: string[] }, +): Promise { + const { columns, ...rest } = params; + return downloadCsv( + "/audit/daily-usage/export", + { ...clean(rest), columns: columns?.join(",") }, + "daily_usage.csv", + ); +} + +/** 去掉 undefined / null / 空串,避免拼出 ?a=&b= 这类空参数 */ +function clean(o: Record): Record { + return Object.fromEntries( + Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "") + ); +}