feat: 审计修复 Phase 6-7 — SSE 推送/工作流补全/消息群发/前端收尾
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 时间范围校验
This commit is contained in:
@@ -21,10 +21,14 @@ export default function NotificationPanel() {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
const { fetchUnreadCount, fetchRecentMessages } = useMessageStore.getState();
|
||||
const { fetchUnreadCount, fetchRecentMessages, connectSSE } = useMessageStore.getState();
|
||||
fetchUnreadCount();
|
||||
fetchRecentMessages();
|
||||
|
||||
// SSE 实时推送,收到消息即刷新
|
||||
const disconnectSSE = connectSSE();
|
||||
|
||||
// 降级轮询(SSE 断开时兜底)
|
||||
const interval = setInterval(() => {
|
||||
fetchUnreadCount();
|
||||
fetchRecentMessages();
|
||||
@@ -32,6 +36,7 @@ export default function NotificationPanel() {
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
disconnectSSE();
|
||||
initializedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function PluginAdmin() {
|
||||
title="确定要清除该插件记录吗?"
|
||||
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
|
||||
>
|
||||
<Button size="small" danger disabled={!['uninstalled', 'disabled', 'uploaded'].includes(record.status)}>
|
||||
<Button size="small" danger disabled={!['uninstalled', 'disabled', 'uploaded', 'installed'].includes(record.status)}>
|
||||
清除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
@@ -30,6 +30,14 @@ export default function NotificationPreferences() {
|
||||
dnd_end: values.dnd_range?.[1]?.format('HH:mm'),
|
||||
};
|
||||
|
||||
if (req.dnd_enabled && req.dnd_start && req.dnd_end) {
|
||||
if (req.dnd_start >= req.dnd_end) {
|
||||
message.error('免打扰开始时间必须早于结束时间');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.put('/message-subscriptions', {
|
||||
dnd_enabled: req.dnd_enabled,
|
||||
dnd_start: req.dnd_start,
|
||||
@@ -63,7 +71,11 @@ export default function NotificationPreferences() {
|
||||
</Form.Item>
|
||||
|
||||
{dndEnabled && (
|
||||
<Form.Item name="dnd_range" label="免打扰时段">
|
||||
<Form.Item
|
||||
name="dnd_range"
|
||||
label="免打扰时段"
|
||||
rules={[{ required: true, message: '请选择免打扰时段' }]}
|
||||
>
|
||||
<TimePicker.RangePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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: 'position', 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 }> = {
|
||||
@@ -40,6 +53,8 @@ export default function AuditLogViewer() {
|
||||
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);
|
||||
@@ -53,6 +68,34 @@ export default function AuditLogViewer() {
|
||||
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]);
|
||||
@@ -126,11 +169,14 @@ export default function AuditLogViewer() {
|
||||
key: 'user_id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
render: (v: string) => {
|
||||
const name = userNameCache.current[v];
|
||||
return (
|
||||
<span title={v} style={{ fontSize: 13, color: isDark ? '#CBD5E1' : '#334155' }}>
|
||||
{name || v}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
|
||||
@@ -56,12 +56,20 @@ export default function ChangePassword() {
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度不能少于6位' },
|
||||
{ min: 8, message: '密码长度不能少于8位' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('current_password') !== value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('新密码不能与当前密码相同'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
placeholder="请输入新密码(至少8位)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { Button, message, Modal, Table, Tag } from 'antd';
|
||||
import { EyeOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -33,6 +33,50 @@ export default function InstanceMonitor() {
|
||||
const [viewerLoading, setViewerLoading] = useState(false);
|
||||
const isDark = useThemeMode();
|
||||
|
||||
// 流程定义缓存:definition_id -> nodes 映射
|
||||
const definitionCache = useRef<Record<string, NodeDef[]>>({});
|
||||
|
||||
const resolveNodeNames = useCallback((defId: string, nodeIds: string[]): string => {
|
||||
const nodes = definitionCache.current[defId];
|
||||
if (!nodes) return nodeIds.join(', ');
|
||||
return nodeIds
|
||||
.map((nid) => {
|
||||
const node = nodes.find((n) => n.id === nid);
|
||||
return node ? node.name : nid;
|
||||
})
|
||||
.join(', ');
|
||||
}, []);
|
||||
|
||||
// 加载当前页实例对应的流程定义
|
||||
const loadDefinitions = useCallback(async (instances: ProcessInstanceInfo[]) => {
|
||||
const uncached = new Set<string>();
|
||||
for (const inst of instances) {
|
||||
if (!definitionCache.current[inst.definition_id]) {
|
||||
uncached.add(inst.definition_id);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(uncached).map(async (defId) => {
|
||||
try {
|
||||
const def = await getProcessDefinition(defId);
|
||||
definitionCache.current[defId] = def.nodes;
|
||||
} catch {
|
||||
// 静默,将显示原始 node_id
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 触发重渲染当定义缓存更新后
|
||||
const [, forceUpdate] = useState(0);
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
loadDefinitions(data).then(() => forceUpdate((n) => n + 1));
|
||||
}
|
||||
// 仅在 data 变化时触发,避免无限循环
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -146,7 +190,12 @@ export default function InstanceMonitor() {
|
||||
title: '当前节点',
|
||||
key: 'current_nodes',
|
||||
width: 150,
|
||||
render: (_, record) => record.active_tokens.map(t => t.node_id).join(', ') || '-',
|
||||
render: (_, record) => {
|
||||
const nodeIds = record.active_tokens.map((t) => t.node_id);
|
||||
if (nodeIds.length === 0) return '-';
|
||||
const resolved = resolveNodeNames(record.definition_id, nodeIds);
|
||||
return <span>{resolved}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '发起时间',
|
||||
|
||||
@@ -7,6 +7,7 @@ interface MessageState {
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchRecentMessages: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
connectSSE: () => () => void;
|
||||
}
|
||||
|
||||
// 请求去重:记录正在进行的请求,防止并发重复调用
|
||||
@@ -68,4 +69,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
set({ unreadCount: prev.unreadCount, recentMessages: prev.recentMessages });
|
||||
}
|
||||
},
|
||||
|
||||
connectSSE: () => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return () => {};
|
||||
|
||||
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`;
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.addEventListener('message', () => {
|
||||
// 收到新消息推送,立即刷新未读数和最近消息
|
||||
get().fetchUnreadCount();
|
||||
get().fetchRecentMessages();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
// SSE 连接断开时 EventSource 会自动重连
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user