Phase 6 功能补全: - P1-3: 消息 SSE 实时推送端点 + 前端 EventSource 连接 - P1-6: ServiceTask HTTP 调用能力 (reqwest GET/POST) - P1-7: user.deleted 事件处理 — 终止相关流程实例 - P1-8: 任务认领 (claim) 端点 + handler - P1-9: 超时检查器发布 task.timeout 事件 - P1-15: 组织/部门名称唯一性校验 (create + update) - P1-18: 消息群发 fan-out (role/department/all 批量投递) Phase 7 P3-P4 收尾: - PluginAdmin purge 按钮状态修复 - ChangePassword 最小 8 字符 + 新旧密码不同验证 - AuditLogViewer 用户名缓存 + 扩展资源类型 - InstanceMonitor 通过 definition 缓存解析 node_name - NotificationPreferences DND 时间范围校验
253 lines
7.4 KiB
TypeScript
253 lines
7.4 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Table, Select, Input, Tag, message } from 'antd';
|
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
|
import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs';
|
|
import { listUsers } from '../../api/users';
|
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
|
|
|
const RESOURCE_TYPE_OPTIONS = [
|
|
{ value: 'user', label: '用户' },
|
|
{ value: 'role', label: '角色' },
|
|
{ value: 'position', label: '岗位' },
|
|
{ value: 'organization', label: '组织' },
|
|
{ value: 'department', label: '部门' },
|
|
{ value: 'process_instance', label: '流程实例' },
|
|
{ value: 'process_definition', label: '流程定义' },
|
|
{ value: 'task', label: '流程任务' },
|
|
{ value: 'dictionary', label: '字典' },
|
|
{ value: 'menu', label: '菜单' },
|
|
{ value: 'setting', label: '设置' },
|
|
{ value: 'numbering_rule', label: '编号规则' },
|
|
{ value: 'patient', label: '患者' },
|
|
{ value: 'patient_tag', label: '患者标签' },
|
|
{ value: 'patient_family_member', label: '家庭成员' },
|
|
{ value: 'patient_doctor_relation', label: '医患关系' },
|
|
{ value: 'points_transaction', label: '积分流水' },
|
|
{ value: 'points_product', label: '积分商品' },
|
|
{ value: 'points_order', label: '积分订单' },
|
|
{ value: 'points_rule', label: '积分规则' },
|
|
{ value: 'offline_event', label: '线下活动' },
|
|
{ value: 'offline_event_registration', label: '活动签到' },
|
|
];
|
|
|
|
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
|
|
create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
|
|
update: { bg: '#eff6ff', color: '#2563eb', text: '更新' },
|
|
delete: { bg: '#FEF2F2', color: '#dc2626', text: '删除' },
|
|
};
|
|
|
|
function formatDateTime(value: string): string {
|
|
return new Date(value).toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
});
|
|
}
|
|
|
|
export default function AuditLogViewer() {
|
|
const [logs, setLogs] = useState<AuditLogItem[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 20 });
|
|
const isDark = useThemeMode();
|
|
const userNameCache = useRef<Record<string, string>>({});
|
|
const cacheLoaded = useRef(false);
|
|
|
|
const fetchLogs = useCallback(async (params: AuditLogQuery) => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await listAuditLogs(params);
|
|
setLogs(result.data);
|
|
setTotal(result.total);
|
|
} catch {
|
|
message.error('加载审计日志失败');
|
|
}
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
// 加载用户名称缓存(分页遍历所有用户)
|
|
useEffect(() => {
|
|
if (cacheLoaded.current) return;
|
|
let cancelled = false;
|
|
const loadAllUsers = async () => {
|
|
try {
|
|
let currentPage = 1;
|
|
const pageSize = 100;
|
|
let hasMore = true;
|
|
while (hasMore && !cancelled) {
|
|
const result = await listUsers(currentPage, pageSize);
|
|
for (const user of result.data) {
|
|
userNameCache.current[user.id] = user.display_name || user.username;
|
|
}
|
|
hasMore = result.data.length >= pageSize;
|
|
currentPage += 1;
|
|
}
|
|
if (!cancelled) {
|
|
cacheLoaded.current = true;
|
|
}
|
|
} catch {
|
|
// 静默失败,将显示 UUID
|
|
}
|
|
};
|
|
loadAllUsers();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchLogs(query);
|
|
}, [query, fetchLogs]);
|
|
|
|
const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => {
|
|
setQuery((prev) => ({
|
|
...prev,
|
|
[field]: value || undefined,
|
|
page: 1,
|
|
}));
|
|
};
|
|
|
|
const handleTableChange = (pagination: TablePaginationConfig) => {
|
|
setQuery((prev) => ({
|
|
...prev,
|
|
page: pagination.current,
|
|
page_size: pagination.pageSize,
|
|
}));
|
|
};
|
|
|
|
const columns: ColumnsType<AuditLogItem> = [
|
|
{
|
|
title: '操作',
|
|
dataIndex: 'action',
|
|
key: 'action',
|
|
width: 100,
|
|
render: (action: string) => {
|
|
const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action };
|
|
return (
|
|
<Tag style={{
|
|
background: info.bg,
|
|
border: 'none',
|
|
color: info.color,
|
|
fontWeight: 500,
|
|
}}>
|
|
{info.text}
|
|
</Tag>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: '资源类型',
|
|
dataIndex: 'resource_type',
|
|
key: 'resource_type',
|
|
width: 120,
|
|
render: (v: string) => (
|
|
<Tag style={{
|
|
background: isDark ? '#0f172a' : '#f8fafc',
|
|
border: 'none',
|
|
color: isDark ? '#CBD5E1' : '#475569',
|
|
}}>
|
|
{v}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '资源 ID',
|
|
dataIndex: 'resource_id',
|
|
key: 'resource_id',
|
|
width: 200,
|
|
ellipsis: true,
|
|
render: (v: string) => (
|
|
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
|
|
{v}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
title: '操作用户',
|
|
dataIndex: 'user_id',
|
|
key: 'user_id',
|
|
width: 200,
|
|
ellipsis: true,
|
|
render: (v: string) => {
|
|
const name = userNameCache.current[v];
|
|
return (
|
|
<span title={v} style={{ fontSize: 13, color: isDark ? '#CBD5E1' : '#334155' }}>
|
|
{name || v}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: '时间',
|
|
dataIndex: 'created_at',
|
|
key: 'created_at',
|
|
width: 180,
|
|
render: (value: string) => (
|
|
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
|
{formatDateTime(value)}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{/* 筛选工具栏 */}
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
marginBottom: 16,
|
|
padding: 12,
|
|
background: isDark ? '#111827' : '#FFFFFF',
|
|
borderRadius: 10,
|
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
|
}}>
|
|
<Select
|
|
allowClear
|
|
placeholder="资源类型"
|
|
style={{ width: 160 }}
|
|
options={RESOURCE_TYPE_OPTIONS}
|
|
value={query.resource_type}
|
|
onChange={(value) => handleFilterChange('resource_type', value)}
|
|
/>
|
|
<Input
|
|
allowClear
|
|
placeholder="操作用户 ID"
|
|
style={{ width: 240 }}
|
|
value={query.user_id ?? ''}
|
|
onChange={(e) => handleFilterChange('user_id', e.target.value)}
|
|
/>
|
|
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8', marginLeft: 'auto' }}>
|
|
共 {total} 条日志
|
|
</span>
|
|
</div>
|
|
|
|
{/* 表格 */}
|
|
<div style={{
|
|
background: isDark ? '#111827' : '#FFFFFF',
|
|
borderRadius: 12,
|
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
|
overflow: 'hidden',
|
|
}}>
|
|
<Table
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={logs}
|
|
loading={loading}
|
|
onChange={handleTableChange}
|
|
pagination={{
|
|
current: query.page,
|
|
pageSize: query.page_size,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
scroll={{ x: 900 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|