refactor: mock清理+安全配置+通用组件+菜单优化

P0安全: SECRET_KEY/ADMIN_PASSWORD/CORS环境变量化, seed仅admin

P1Mock: mock.ts 301->88行, 删8数据mock+5generateMock, net-1300行

P2组件: useToast/useFetchList+ConfirmDialog/FormDialog

P3菜单: menuSections/menuItems->constants/menu.ts

P4API: NotificationsPage/WorkflowPage重写+RolesPage演示

测试: 1050passed, tsc clean
This commit is contained in:
logan committed 2026-05-24 02:42:29 +08:00
1 parent ec3a03cf38
commit 8b921c6c83
33 files changed
+1734 -2006

No files matched your search

+160 -146
View File
@@ -1,165 +1,179 @@
import { useState } from 'react';
import { Send, Bell, MessageSquare, Navigation, Mail, Check, ToggleLeft, ToggleRight, Webhook, Settings, TestTube } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { Bell, Webhook, Check, Settings, TestTube, ToggleLeft, ToggleRight, Loader2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { notificationChannels } from '@/lib/mock';
import { notificationApi } from '@/api/notification';
const iconMap: Record<string, React.ElementType> = {
Send, Bell, MessageSquare, Navigation, Mail,
interface NotificationChannel {
id: number;
name: string;
channel_type: string;
config: Record<string, any>;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
const channelStyle: Record<string, { bg: string; color: string; label: string }> = {
webhook_feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]', label: '飞书' },
dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]', label: '钉钉' },
email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]', label: '邮件' },
};
const channelStyle: Record<string, { bg: string; color: string }> = {
feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]' },
dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]' },
wechat: { bg: 'bg-[rgba(7,193,96,0.15)]', color: 'text-[#07c160]' },
telegram: { bg: 'bg-[rgba(0,136,204,0.15)]', color: 'text-[#0088cc]' },
email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]' },
};
const allEvents = [
{ key: 'ticket_created', label: '工单创建' },
{ key: 'ticket_approved', label: '审批通过' },
{ key: 'ticket_rejected', label: '审批拒绝' },
{ key: 'ticket_executed', label: '执行完成' },
{ key: 'ticket_failed', label: '执行失败' },
{ key: 'ticket_rollback', label: '工单回滚' },
];
export default function NotificationsPage() {
const [channels, setChannels] = useState(notificationChannels);
const [editChannel, setEditChannel] = useState<typeof notificationChannels[0] | null>(null);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ id: number; success: boolean; message: string } | null>(null);
const toggleChannel = (id: number) => {
setChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c));
};
const fetchChannels = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await notificationApi.list();
setChannels(data);
} catch (err: any) {
setError(err?.response?.data?.detail || '获取通知渠道失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const toggleChannel = useCallback(async (id: number) => {
const ch = channels.find(c => c.id === id);
if (!ch) return;
try {
await notificationApi.update(id, { is_enabled: !ch.is_enabled });
setChannels(prev => prev.map(c => c.id === id ? { ...c, is_enabled: !c.is_enabled } : c));
} catch {}
}, [channels]);
const handleTest = useCallback(async (id: number) => {
setTestingId(id);
setTestResult(null);
try {
const res = await notificationApi.test(id);
setTestResult({ id, success: res.success, message: res.message });
} catch (err: any) {
setTestResult({ id, success: false, message: err?.response?.data?.detail || '测试失败' });
} finally {
setTestingId(null);
setTimeout(() => setTestResult(null), 5000);
}
}, []);
const handleDelete = useCallback(async (id: number) => {
if (!confirm('确认删除该通知渠道?')) return;
try {
await notificationApi.delete(id);
setChannels(prev => prev.filter(c => c.id !== id));
} catch {}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<AlertCircle className="w-12 h-12 mb-3 text-red-400" />
<p className="text-sm">{error}</p>
<button onClick={fetchChannels} className="mt-3 px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors">
重试
</button>
</div>
);
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">通知渠道配置</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、企业微信、Telegram</p>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、邮件</p>
</div>
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all">
<Settings className="w-4 h-4" /> 全局设置
</button>
</div>
<div className="grid grid-cols-5 gap-4">
{[
{ label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: channels.filter(c => c.enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '未启用', value: channels.filter(c => !c.enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-2 gap-4">
{channels.map(channel => {
const Icon = iconMap[channel.icon] || Bell;
const style = channelStyle[channel.type] || channelStyle.email;
return (
<div
key={channel.id}
className={`dg-card p-5 cursor-pointer transition-all ${channel.enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}
onClick={() => setEditChannel(channel)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
<Icon className={`w-6 h-6 ${style.color}`} />
</div>
<div>
<div className="text-base font-semibold">{channel.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{channel.description}</div>
{channel.enabled && channel.events && (
<div className="flex flex-wrap gap-1 mt-2">
{channel.events.slice(0, 3).map(e => (
<span key={e} className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[10px] rounded">{allEvents.find(a => a.key === e)?.label || e}</span>
))}
{channel.events.length > 3 && <span className="px-1.5 py-0.5 text-[var(--dg-text-muted)] text-[10px]">+{channel.events.length - 3}</span>}
</div>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${channel.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
{channel.enabled ? '已启用' : '未启用'}
</span>
<button
onClick={e => { e.stopPropagation(); toggleChannel(channel.id); }}
className="transition-colors"
>
{channel.enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
</button>
</div>
</div>
</div>
);
})}
</div>
{/* 编辑弹窗 */}
{editChannel && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setEditChannel(null)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="text-xl font-semibold mb-5">配置 {editChannel.name}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Webhook URL</label>
<input
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
defaultValue={editChannel.webhook || ''}
placeholder={`请输入 ${editChannel.name} Webhook 地址`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">密钥 (Secret)</label>
<input
type="password"
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
placeholder="请输入密钥"
/>
</div>
<div>
<label className="block text-sm font-medium mb-3">通知事件</label>
<div className="grid grid-cols-2 gap-2">
{allEvents.map(evt => (
<label key={evt.key} className="flex items-center gap-2 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] cursor-pointer hover:border-[var(--dg-text-muted)] transition-colors">
<input type="checkbox" defaultChecked={editChannel.events?.includes(evt.key)} className="w-4 h-4 rounded border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-blue-500 focus:ring-blue-500/20" />
<span className="text-sm">{evt.label}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">@ 提醒用户</label>
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
<option>审批人</option>
<option>申请人</option>
<option>执行人</option>
<option>全部相关人员</option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
取消
</button>
<button className="flex items-center gap-2 px-5 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
<TestTube className="w-4 h-4" /> 测试
</button>
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
保存
</button>
</div>
{channels.length === 0 ? (
<div className="dg-card p-12 text-center">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
<p className="text-sm text-[var(--dg-text-muted)]">暂无通知渠道,请联系管理员配置</p>
</div>
) : (
<>
<div className="grid grid-cols-5 gap-4">
{[
{ label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: channels.filter(c => c.is_enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '未启用', value: channels.filter(c => !c.is_enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{channels.map(channel => {
const style = channelStyle[channel.channel_type] || { bg: 'bg-gray-400/10', color: 'text-gray-400', label: channel.channel_type };
return (
<div key={channel.id} className={`dg-card p-5 transition-all ${channel.is_enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
<Webhook className={`w-6 h-6 ${style.color}`} />
</div>
<div>
<div className="text-base font-semibold">{channel.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{style.label}
{channel.config?.webhook_url && <span className="ml-2 opacity-60">{channel.config.webhook_url.substring(0, 40)}...</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(channel.id)}
disabled={testingId === channel.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors disabled:opacity-50"
title="测试"
>
{testingId === channel.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <TestTube className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(channel.id)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
<button onClick={() => toggleChannel(channel.id)} className="transition-colors">
{channel.is_enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
</button>
</div>
</div>
{testResult && testResult.id === channel.id && (
<div className={`mt-3 px-3 py-2 rounded-lg text-xs ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.message}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
);