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:
@ -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),
|
||||
)
|
||||
|
||||
@ -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] = []
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
75
frontend/src/components/admin/ExportColumnsModal.tsx
Normal file
75
frontend/src/components/admin/ExportColumnsModal.tsx
Normal file
@ -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<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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
207
frontend/src/pages/admin/AuditUsagePanel.tsx
Normal file
207
frontend/src/pages/admin/AuditUsagePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<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 !== "")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user