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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user