feat(audit): 日活使用统计 + 双 CSV 导出(审计/上下线,列可自定义)

【日活统计:GET /audit/daily-usage】
按【北京时间自然日 × 操作人】聚合,单个 GROUP BY 完成(count(*) FILTER),
不用窗口函数。指标:上线/下线时间、操作次数、登录/登出次数。

⚠️ 上线/下线时间取【当天首次/末次活动】,刻意不取登录时间:
   Refresh Token 有效期 7 天,用户不必每天重新登录。按登录算会出现
   「登录次数 0、上线时间空,但操作次数 35」——报表自相矛盾。
   时间一律按 +08:00 分日与渲染,否则早班(00:00~08:00)操作会掉到前一天。

【CSV 导出:两个端点 + 列自定义】
- /audit/logs/export        整体审计导出,筛选维度与列表页完全一致
- /audit/daily-usage/export 上下线导出,每人一行
- 列清单由后端统一维护并经 /audit/options 下发(log_export_columns /
  usage_export_columns),前端不硬编码表头,避免两端漂移
- ⚠️ 响应带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头全乱码
- 单次上限 5 万行,超出经 X-Export-Truncated 头告知前端明确提示
  (静默截断比报错更危险)
- list_audit_logs 与 export_audit_logs 共用 _log_filters,
  保证「看到的」与「导出的」永远是同一批数据

【前端】
- 审计页页头新增「人员统计」「导出 CSV」两个按钮,现有表格与筛选零改动
- 人员统计走抽屉(AuditUsagePanel):日期范围+快捷键、日活表格、
  上下线次数彩色标签、北京时间渲染、导出前弹列勾选面板
- ExportColumnsModal 为两处导出共用,默认全选
This commit is contained in:
2026-09-21 12:06:43 +08:00
parent 5290d83463
commit 1fea30b03b
7 changed files with 825 additions and 17 deletions

View File

@ -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 BOMExcel 靠它识别编码,否则中文表头与内容全是乱码。
这是 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),
)

View File

@ -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=后端列 keylabel=中文表头)。
# 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
# 两端各写一份迟早会出现"导出的列和页面上的对不上"。
log_export_columns: list[AuditOption] = []
usage_export_columns: list[AuditOption] = []

View File

@ -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
]

View File

@ -0,0 +1,75 @@
/**
* 导出列选择弹窗 —— 勾选要写进 CSV 的列。
*
* 列清单由后端 /audit/options 下发value=后端列 keylabel=中文表头),
* 前端不硬编码表头:否则两端各维护一份,迟早出现「导出的列和页面对不上」。
*
* 默认全选 —— 大多数人只是想"全部导出来",不该逼他们先勾一遍。
*/
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<string[]>([]);
// 每次打开都重置为全选:上一次的勾选残留会让用户莫名少导几列
useEffect(() => {
if (open) setChecked(columns.map((c) => c.value));
}, [open, columns]);
return (
<Modal
open={open}
title="选择导出列"
onCancel={onCancel}
width={520}
footer={
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Button size="small" onClick={() => setChecked(columns.map((c) => c.value))}>
</Button>
<Button size="small" onClick={() => setChecked([])}>
</Button>
</div>
<div className="flex gap-2">
<Button onClick={onCancel}></Button>
<Button
type="primary"
loading={submitting}
disabled={checked.length === 0}
onClick={() => onConfirm(checked)}
>
({checked.length} )
</Button>
</div>
</div>
}
>
{checked.length === 0 && (
<p className="mb-2 text-xs text-amber-600"></p>
)}
<Checkbox.Group
value={checked}
onChange={(v) => setChecked(v as string[])}
className="grid grid-cols-3 gap-y-2"
options={columns.map((c) => ({ value: c.value, label: c.label }))}
/>
</Modal>
);
}

View File

@ -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<string | null>(null);
const [detail, setDetail] = useState<AuditLogItem | null>(null);
const { toast } = useToast();
const [modules, setModules] = useState<AuditOption[]>([]);
const [actions, setActions] = useState<AuditOption[]>([]);
/** 导出可选列 —— 由后端下发,前端不硬编码表头 */
const [logColumns, setLogColumns] = useState<AuditOption[]>([]);
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}
</p>
</div>
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={() => void load()} loading={loading}>
</Button>
<div className="flex items-center gap-2">
{/* 人员统计刻意做成抽屉而不是标签页:两个视图的粒度不同
(一行一次操作 vs 一人一天一行),并列成 Tab 会让筛选状态互相干扰 */}
<Button icon={<BarChart3 className="h-4 w-4" />} onClick={() => setUsageOpen(true)}>
</Button>
<Button
icon={<Download className="h-4 w-4" />}
disabled={total === 0}
onClick={() => setExportPickerOpen(true)}
>
CSV
</Button>
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={() => void load()} loading={loading}>
</Button>
</div>
</div>
{/* 筛选区 */}
@ -350,6 +408,17 @@ export default function AdminAuditLogPage() {
</Descriptions>
)}
</Drawer>
{/* 人员统计:独立抽屉,本页表格与筛选完全不受影响 */}
<AuditUsagePanel open={usageOpen} onClose={() => setUsageOpen(false)} />
<ExportColumnsModal
open={exportPickerOpen}
columns={logColumns}
submitting={exporting}
onCancel={() => setExportPickerOpen(false)}
onConfirm={handleExport}
/>
</div>
);
}

View File

@ -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 <span className="text-gray-300">0</span>;
return <Tag className={`${cls} border-0 font-semibold`}>{n}</Tag>;
}
export default function AuditUsagePanel({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [rows, setRows] = useState<DailyUsageRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [usageColumns, setUsageColumns] = useState<AuditOption[]>([]);
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<DailyUsageRow> = [
// 单日查询时日期列是冗余的,自动隐藏,少一列噪音
...(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) => (
<div className="leading-tight">
<div className="text-gray-900">{r.display_name || r.user_id || "—"}</div>
{r.display_name && <div className="text-xs text-gray-400">{r.user_id}</div>}
</div>
),
},
// 上线/下线时间 = 当天首次/末次【活动】。token 有效期内用户不重新登录,
// 若取登录时间会得出"登录 0 次却操作 35 次"的矛盾数据(见后端 docstring
{ title: "上线时间", dataIndex: "first_active_at", width: 100, align: "center",
render: (v: string | null) => <span className="font-mono text-gray-700">{bjTime(v)}</span> },
{ title: "下线时间", dataIndex: "last_active_at", width: 100, align: "center",
render: (v: string | null) => <span className="font-mono text-gray-700">{bjTime(v)}</span> },
{ title: "操作次数", dataIndex: "op_count", width: 110, align: "center",
render: (v: number) => <span className="font-bold text-blue-600">{v}</span>,
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 (
<Drawer
open={open}
onClose={onClose}
width={1000}
title="📊 人员统计(日活)"
extra={
<Button icon={<RefreshCw className="h-3.5 w-3.5" />} onClick={load} disabled={loading}>
</Button>
}
>
<div className="mb-4 flex flex-wrap items-center gap-2">
<DatePicker.RangePicker
value={range}
allowClear={false}
onChange={(v) => { 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()] },
]}
/>
<Button
type="primary"
icon={<Download className="h-3.5 w-3.5" />}
disabled={rows.length === 0}
onClick={() => setPickerOpen(true)}
>
CSV
</Button>
<span className="text-xs text-gray-400">
{rows.length > 0 && `${rows.length} 人·天`}
</span>
</div>
{error && (
<Alert type="error" showIcon className="mb-3" message={error} />
)}
{/* 两处口径容易被误读,直接写在表格上方 */}
<p className="mb-3 text-xs text-gray-400">
线/线= /<strong></strong>
7
/
</p>
<Table<DailyUsageRow>
rowKey={(r) => `${r.day}|${r.user_id ?? ""}`}
size="small"
columns={columns}
dataSource={rows}
loading={{ spinning: loading, indicator: <Loader2 className="h-5 w-5 animate-spin text-blue-500" /> }}
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t}` }}
locale={{ emptyText: <Empty description="该时段没有使用记录" /> }}
/>
<ExportColumnsModal
open={pickerOpen}
columns={usageColumns}
submitting={exporting}
onCancel={() => setPickerOpen(false)}
onConfirm={handleExport}
/>
</Drawer>
);
}

View File

@ -44,6 +44,34 @@ export interface AuditOption {
export interface AuditOptionsResponse {
modules: AuditOption[];
actions: AuditOption[];
/** 导出可选列value=后端列 keylabel=中文表头)—— 由后端下发,前端不再硬编码 */
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<AuditLogLis
return data;
}
/** 获取模块/动作筛选项 */
/** 获取模块/动作筛选项(含导出可选列) */
export async function fetchAuditOptions(): Promise<AuditOptionsResponse> {
const { data } = await api.get<AuditOptionsResponse>("/audit/options");
return data;
}
/** 日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合 */
export async function fetchDailyUsage(params: {
start_date?: string;
end_date?: string;
} = {}): Promise<DailyUsageResponse> {
const { data } = await api.get<DailyUsageResponse>("/audit/daily-usage", { params });
return data;
}
/**
* 触发浏览器下载一个 CSV。
*
* ⚠️ 不能直接用 <a href="/api/..."> 或 window.open本项目是 Bearer Token 鉴权
* token 在 localStorage不在 Cookie普通链接带不上 Authorization 头,
* 后端会直接 401。必须先经 axios 取回 blob 再本地落盘。
*
* @returns 是否因超出后端行数上限而被截断(调用方据此提示用户,不要静默)
*/
async function downloadCsv(
path: string,
params: Record<string, unknown>,
filename: string,
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "")
);
}