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