Initial commit: SQL audit platform v0.1.0
FastAPI backend with auth, user, instance, ticket, query, dashboard APIs. React + TypeScript frontend with mock data UI (10 pages). SQLite + async SQLAlchemy, JWT auth, role-based access control. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
commit
439155c92e
290 files changed
+61950
No files matched your search
@@ -0,0 +1,355 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Megaphone, Plus, Edit2, Trash2, Pin, PinOff, Eye, Clock,
|
||||
CheckCircle, X, Save, AlertTriangle, Send, Calendar
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
type AnnouncementPriority = 'urgent' | 'high' | 'normal' | 'low';
|
||||
|
||||
type Announcement = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: AnnouncementPriority;
|
||||
pinned: boolean;
|
||||
enabled: boolean;
|
||||
author: string;
|
||||
publishTime: string;
|
||||
expireTime: string;
|
||||
readCount: number;
|
||||
targetRoles: string[];
|
||||
};
|
||||
|
||||
const priorityLabels: Record<AnnouncementPriority, string> = {
|
||||
urgent: '紧急',
|
||||
high: '重要',
|
||||
normal: '普通',
|
||||
low: '低',
|
||||
};
|
||||
|
||||
const priorityColors: Record<AnnouncementPriority, string> = {
|
||||
urgent: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
high: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
normal: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
low: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ id: 'admin', label: '超级管理员' },
|
||||
{ id: 'dba', label: '数据库管理员' },
|
||||
{ id: 'developer', label: '开发人员' },
|
||||
{ id: 'guest', label: '访客' },
|
||||
];
|
||||
|
||||
const initialAnnouncements: Announcement[] = [
|
||||
{
|
||||
id: 'ann-1', title: '【系统维护】本周日凌晨 2:00-4:00 数据库升级维护',
|
||||
content: '计划于本周日(2024年1月21日)凌晨 2:00-4:00 进行数据库版本升级,期间以下服务将不可用:\n1. 数据变更工单提交和审批\n2. 在线 SQL 查询\n3. 数据导出功能\n\n请提前安排工作,避免在维护时段提交重要工单。如有紧急情况,请联系 DBA 团队。',
|
||||
priority: 'urgent', pinned: true, enabled: true, author: 'admin',
|
||||
publishTime: '2024-01-15 10:00:00', expireTime: '2024-01-22 00:00:00',
|
||||
readCount: 156, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
||||
},
|
||||
{
|
||||
id: 'ann-2', title: '【安全提醒】禁止使用 SELECT * 查询生产数据库',
|
||||
content: '为加强数据安全,即日起严禁使用 SELECT * 查询生产环境数据库。所有查询必须明确指定所需字段名。\n\n审核规则已更新(规则编号 R002),违规查询将被自动拦截并记录审计日志。',
|
||||
priority: 'high', pinned: true, enabled: true, author: 'admin',
|
||||
publishTime: '2024-01-14 09:00:00', expireTime: '2024-02-14 00:00:00',
|
||||
readCount: 234, targetRoles: ['admin', 'dba', 'developer'],
|
||||
},
|
||||
{
|
||||
id: 'ann-3', title: '【新功能】SQL 变更自动回滚功能已上线',
|
||||
content: 'SQL 变更工单现已支持自动回滚功能。对于 UPDATE 和 DELETE 操作,系统会自动:\n1. 基于 Binlog ROW 格式生成逆向回滚语句\n2. 执行前备份受影响数据\n3. 提供一键回滚入口(执行后 72 小时内有效)\n\n使用要求:\n- MySQL 必须开启 Binlog ROW 格式\n- 操作表必须有主键\n- 单次操作影响行数不超过 10,000 行',
|
||||
priority: 'normal', pinned: false, enabled: true, author: 'zhangsan',
|
||||
publishTime: '2024-01-13 14:00:00', expireTime: '2024-04-13 00:00:00',
|
||||
readCount: 89, targetRoles: ['admin', 'dba', 'developer'],
|
||||
},
|
||||
{
|
||||
id: 'ann-4', title: '【培训通知】SQL 审核规范培训 - 1月25日',
|
||||
content: '时间:2024年1月25日 14:00-16:00\n地点:会议室 A\n讲师:DBA 团队负责人\n\n培训内容:\n1. SQL 变更工单提交流程\n2. 审核规则详解\n3. 回滚操作最佳实践\n4. 常见问题排查\n\n请相关人员准时参加。',
|
||||
priority: 'normal', pinned: false, enabled: true, author: 'lisi',
|
||||
publishTime: '2024-01-12 10:00:00', expireTime: '2024-01-26 00:00:00',
|
||||
readCount: 67, targetRoles: ['developer'],
|
||||
},
|
||||
{
|
||||
id: 'ann-5', title: '【版本更新】Web-Lite v2.5.0 更新日志',
|
||||
content: '本次更新内容:\n\n新增功能:\n- 支持多数据源统一管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题',
|
||||
priority: 'low', pinned: false, enabled: false, author: 'admin',
|
||||
publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00',
|
||||
readCount: 45, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AnnouncementPage() {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>(initialAnnouncements);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingAnn, setEditingAnn] = useState<Announcement | null>(null);
|
||||
const [previewAnn, setPreviewAnn] = useState<Announcement | null>(null);
|
||||
|
||||
// 表单
|
||||
const [formTitle, setFormTitle] = useState('');
|
||||
const [formContent, setFormContent] = useState('');
|
||||
const [formPriority, setFormPriority] = useState<AnnouncementPriority>('normal');
|
||||
const [formPinned, setFormPinned] = useState(false);
|
||||
const [formTargetRoles, setFormTargetRoles] = useState<string[]>(['admin', 'dba', 'developer', 'guest']);
|
||||
const [formExpireTime, setFormExpireTime] = useState('');
|
||||
|
||||
const resetForm = () => {
|
||||
setFormTitle('');
|
||||
setFormContent('');
|
||||
setFormPriority('normal');
|
||||
setFormPinned(false);
|
||||
setFormTargetRoles(['admin', 'dba', 'developer', 'guest']);
|
||||
setFormExpireTime('');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm();
|
||||
setEditingAnn(null);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const openEdit = (ann: Announcement) => {
|
||||
setFormTitle(ann.title);
|
||||
setFormContent(ann.content);
|
||||
setFormPriority(ann.priority);
|
||||
setFormPinned(ann.pinned);
|
||||
setFormTargetRoles([...ann.targetRoles]);
|
||||
setFormExpireTime(ann.expireTime);
|
||||
setEditingAnn(ann);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const toggleRole = (role: string) => {
|
||||
setFormTargetRoles(prev =>
|
||||
prev.includes(role) ? prev.filter(r => r !== role) : [...prev, role]
|
||||
);
|
||||
};
|
||||
|
||||
const saveAnnouncement = () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) return;
|
||||
const now = '2024-01-15 12:00:00';
|
||||
if (editingAnn) {
|
||||
setAnnouncements(prev => prev.map(a => a.id === editingAnn.id ? {
|
||||
...a,
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
priority: formPriority,
|
||||
pinned: formPinned,
|
||||
targetRoles: [...formTargetRoles],
|
||||
expireTime: formExpireTime || a.expireTime,
|
||||
} : a));
|
||||
} else {
|
||||
const newAnn: Announcement = {
|
||||
id: `ann-${Date.now()}`,
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
priority: formPriority,
|
||||
pinned: formPinned,
|
||||
enabled: true,
|
||||
author: 'admin',
|
||||
publishTime: now,
|
||||
expireTime: formExpireTime || '2024-12-31 00:00:00',
|
||||
readCount: 0,
|
||||
targetRoles: [...formTargetRoles],
|
||||
};
|
||||
setAnnouncements(prev => [newAnn, ...prev]);
|
||||
}
|
||||
setShowCreate(false);
|
||||
};
|
||||
|
||||
const togglePin = (id: string) => {
|
||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, pinned: !a.pinned } : a));
|
||||
};
|
||||
|
||||
const toggleEnable = (id: string) => {
|
||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, enabled: !a.enabled } : a));
|
||||
};
|
||||
|
||||
const deleteAnnouncement = (id: string) => {
|
||||
setAnnouncements(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const sortedAnnouncements = [...announcements].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
||||
});
|
||||
|
||||
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">发布和管理系统公告,支持按角色定向推送</p>
|
||||
</div>
|
||||
<button onClick={openCreate} className="flex items-center gap-2 px-4 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">
|
||||
<Plus className="w-4 h-4" /> 发布公告
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '公告总数', value: announcements.length, icon: Megaphone, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已置顶', value: announcements.filter(a => a.pinned).length, icon: Pin, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ label: '生效中', value: announcements.filter(a => a.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '已失效', value: announcements.filter(a => !a.enabled).length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-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="space-y-3">
|
||||
{sortedAnnouncements.map(ann => (
|
||||
<Card key={ann.id} className={`dg-card ${!ann.enabled ? 'opacity-50' : ''} ${ann.pinned ? 'border-l-2 border-l-purple-500' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{ann.pinned && <Pin className="w-4 h-4 text-purple-400" />}
|
||||
<h3 className="font-semibold text-sm">{ann.title}</h3>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[ann.priority]}`}>
|
||||
{priorityLabels[ann.priority]}
|
||||
</span>
|
||||
{!ann.enabled && <span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">已失效</span>}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mb-3">{ann.content.replace(/\n/g, ' ')}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {ann.author}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.publishTime}</span>
|
||||
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> 到期: {ann.expireTime}</span>
|
||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {ann.readCount} 次阅读</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>可见范围:</span>
|
||||
{ann.targetRoles.map(r => (
|
||||
<span key={r} className="text-[10px] px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">
|
||||
{r === 'admin' ? '管理员' : r === 'dba' ? 'DBA' : r === 'developer' ? '开发' : '访客'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<button onClick={() => togglePin(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-purple-500/15 hover:text-purple-400 transition-colors" title={ann.pinned ? '取消置顶' : '置顶'}>
|
||||
{ann.pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => setPreviewAnn(ann)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(ann)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => toggleEnable(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={ann.enabled ? '失效' : '生效'}>
|
||||
{ann.enabled ? <CheckCircle className="w-4 h-4 text-green-400" /> : <AlertTriangle className="w-4 h-4 text-yellow-400" />}
|
||||
</button>
|
||||
<button onClick={() => deleteAnnouncement(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
{previewAnn && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewAnn(null)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">{previewAnn.title}</h3>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[previewAnn.priority]}`}>
|
||||
{priorityLabels[previewAnn.priority]}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setPreviewAnn(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
|
||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {previewAnn.author}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {previewAnn.publishTime}</span>
|
||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {previewAnn.readCount} 次阅读</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-line leading-relaxed max-h-96 overflow-y-auto">
|
||||
{previewAnn.content}
|
||||
</div>
|
||||
<div className="flex justify-end mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={() => setPreviewAnn(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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold">{editingAnn ? '编辑公告' : '发布公告'}</h3>
|
||||
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">公告标题 <span className="text-red-400">*</span></label>
|
||||
<input type="text" value={formTitle} onChange={e => setFormTitle(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="输入公告标题" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">公告内容 <span className="text-red-400">*</span></label>
|
||||
<textarea value={formContent} onChange={e => setFormContent(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={8} placeholder="输入公告内容,支持多行文本..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">优先级</label>
|
||||
<select value={formPriority} onChange={e => setFormPriority(e.target.value as AnnouncementPriority)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
||||
{Object.entries(priorityLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">到期时间</label>
|
||||
<input type="datetime-local" value={formExpireTime} onChange={e => setFormExpireTime(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formPinned} onCheckedChange={setFormPinned} />
|
||||
<span className="text-sm">置顶公告</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">可见角色</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{roleOptions.map(role => (
|
||||
<button
|
||||
key={role.id}
|
||||
onClick={() => toggleRole(role.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
|
||||
formTargetRoles.includes(role.id)
|
||||
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'
|
||||
}`}
|
||||
>
|
||||
{role.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={() => setShowCreate(false)} 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 onClick={saveAnnouncement} disabled={!formTitle.trim() || !formContent.trim()} className="flex items-center gap-2 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 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Save className="w-4 h-4" /> {editingAnn ? '保存修改' : '发布公告'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Shield, AlertTriangle, AlertCircle, Info, CheckCircle, ToggleLeft, ToggleRight, FileCode } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { auditRules, dbTypeColorMap } from '@/lib/mock';
|
||||
|
||||
const levelConfig = {
|
||||
error: { icon: AlertTriangle, color: 'text-red-400 bg-red-400/10', label: '错误' },
|
||||
warning: { icon: AlertCircle, color: 'text-yellow-400 bg-yellow-400/10', label: '警告' },
|
||||
info: { icon: Info, color: 'text-blue-400 bg-blue-400/10', label: '提示' },
|
||||
};
|
||||
|
||||
export default function AuditRulesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const [rules, setRules] = useState(auditRules);
|
||||
|
||||
const filtered = rules.filter(r => {
|
||||
if (levelFilter && r.level !== levelFilter) return false;
|
||||
if (search && !r.name.includes(search) && !r.description.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleRule = (id: string) => {
|
||||
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">SQL 审核规则</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 SQL 审核规则,确保数据变更安全合规</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<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)] transition-all">
|
||||
<FileCode className="w-4 h-4" /> 导入规则
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 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">
|
||||
<Plus className="w-4 h-4" /> 添加规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '规则总数', value: rules.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已启用', value: rules.filter(r => r.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '错误级', value: rules.filter(r => r.level === 'error').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
{ label: '警告级', value: rules.filter(r => r.level === 'warning').length, icon: AlertCircle, color: 'text-yellow-400', bg: 'bg-yellow-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="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索规则名称" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
|
||||
<option value="">全部级别</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">提示</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">规则ID</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">规则名称</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">级别</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">适用数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">描述</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(rule => {
|
||||
const lc = levelConfig[rule.level];
|
||||
const LIcon = lc.icon;
|
||||
return (
|
||||
<tr key={rule.id} className={`border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors ${!rule.enabled ? 'opacity-50' : ''}`}>
|
||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{rule.id}</td>
|
||||
<td className="px-5 py-3.5 font-medium">{rule.name}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${lc.color}`}>
|
||||
<LIcon className="w-3.5 h-3.5" /> {lc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rule.dbTypes.map(dt => (
|
||||
<span key={dt} className={`inline-flex px-2 py-0.5 rounded text-[11px] font-medium ${dbTypeColorMap[dt] || ''}`}>{dt.toUpperCase()}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)] max-w-xs truncate">{rule.description}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button onClick={() => toggleRule(rule.id)} className="transition-colors">
|
||||
{rule.enabled ? (
|
||||
<ToggleRight className="w-8 h-8 text-green-400" />
|
||||
) : (
|
||||
<ToggleLeft className="w-8 h-8 text-[var(--dg-text-muted)]" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Clock, Plus, Play, Pause, Trash2, AlertTriangle,
|
||||
Calendar, Zap, Database, ChevronDown, ChevronUp, X, Save, Eye
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
type TaskFrequency = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
type TaskStatus = 'running' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
type AutoTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
frequency: TaskFrequency;
|
||||
cron: string;
|
||||
nextRun: string;
|
||||
lastRun: string;
|
||||
lastStatus: 'success' | 'failed' | 'none';
|
||||
dbType: string;
|
||||
instance: string;
|
||||
sql: string;
|
||||
status: TaskStatus;
|
||||
notifyOnFail: boolean;
|
||||
createdBy: string;
|
||||
};
|
||||
|
||||
const frequencyLabels: Record<TaskFrequency, string> = {
|
||||
once: '一次性',
|
||||
hourly: '每小时',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
};
|
||||
|
||||
const initialTasks: AutoTask[] = [
|
||||
{ id: 'task-1', name: '每日数据归档', description: '将 30 天前的日志数据归档到历史表', enabled: true, frequency: 'daily', cron: '0 2 * * *', nextRun: '2024-01-16 02:00', lastRun: '2024-01-15 02:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO logs_archive SELECT * FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);\nDELETE FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
||||
{ id: 'task-2', name: '每周统计报表', description: '生成周活跃用户统计报表', enabled: true, frequency: 'weekly', cron: '0 6 * * 1', nextRun: '2024-01-22 06:00', lastRun: '2024-01-08 06:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO report_weekly_active (week_start, week_end, active_users, new_users)\nSELECT \n DATE_SUB(NOW(), INTERVAL 7 DAY) as week_start,\n NOW() as week_end,\n COUNT(DISTINCT user_id) as active_users,\n COUNT(DISTINCT CASE WHEN created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) THEN user_id END) as new_users\nFROM user_activity\nWHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
||||
{ id: 'task-3', name: '索引健康检查', description: '检查慢查询并建议优化索引', enabled: true, frequency: 'daily', cron: '0 3 * * *', nextRun: '2024-01-16 03:00', lastRun: '2024-01-15 03:00', lastStatus: 'failed', dbType: 'mysql', instance: '生产主库', sql: 'SELECT \n table_schema,\n table_name,\n index_name,\n cardinality,\n table_rows,\n ROUND(cardinality / table_rows * 100, 2) as selectivity\nFROM information_schema.statistics\nWHERE table_schema NOT IN ("information_schema", "mysql", "performance_schema")\n AND index_name != "PRIMARY"\n AND cardinality / table_rows < 0.1\nORDER BY table_rows DESC\nLIMIT 100;', status: 'paused', notifyOnFail: true, createdBy: 'zhangsan' },
|
||||
{ id: 'task-4', name: 'Redis 键过期清理', description: '清理 Redis 中已过期的会话键', enabled: false, frequency: 'hourly', cron: '0 * * * *', nextRun: '-', lastRun: '2024-01-14 12:00', lastStatus: 'success', dbType: 'redis', instance: 'Redis集群-01', sql: 'EVAL "local keys = redis.call(\'keys\', \'session:*\')\nfor i=1,#keys,5000 do\n redis.call(\'del\', unpack(keys, i, math.min(i+4999, #keys)))\nend\nreturn #keys" 0', status: 'paused', notifyOnFail: false, createdBy: 'lisi' },
|
||||
{ id: 'task-5', name: '月度数据备份', description: '每月全量备份核心数据表', enabled: true, frequency: 'monthly', cron: '0 1 1 * *', nextRun: '2024-02-01 01:00', lastRun: '2024-01-01 01:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: '-- 调用备份脚本\nBACKUP DATABASE user_db, order_db TO S3://backups/monthly/;', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
||||
];
|
||||
|
||||
export default function AutoTaskPage() {
|
||||
const [tasks, setTasks] = useState<AutoTask[]>(initialTasks);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [previewTask, setPreviewTask] = useState<AutoTask | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formFreq, setFormFreq] = useState<TaskFrequency>('daily');
|
||||
const [formCron, setFormCron] = useState('0 2 * * *');
|
||||
const [formDbType, setFormDbType] = useState('mysql');
|
||||
const [formInstance, setFormInstance] = useState('生产主库');
|
||||
const [formSql, setFormSql] = useState('');
|
||||
const [formNotify, setFormNotify] = useState(true);
|
||||
|
||||
const toggleTask = (id: string) => {
|
||||
setTasks(prev => prev.map(t => t.id === id ? { ...t, enabled: !t.enabled, status: !t.enabled ? 'running' : 'paused' } : t));
|
||||
};
|
||||
|
||||
const deleteTask = (id: string) => {
|
||||
setTasks(prev => prev.filter(t => t.id !== id));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName('');
|
||||
setFormDesc('');
|
||||
setFormFreq('daily');
|
||||
setFormCron('0 2 * * *');
|
||||
setFormDbType('mysql');
|
||||
setFormInstance('生产主库');
|
||||
setFormSql('');
|
||||
setFormNotify(true);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const saveTask = () => {
|
||||
if (!formName.trim() || !formSql.trim()) return;
|
||||
const newTask: AutoTask = {
|
||||
id: `task-${Date.now()}`,
|
||||
name: formName,
|
||||
description: formDesc,
|
||||
enabled: true,
|
||||
frequency: formFreq,
|
||||
cron: formCron,
|
||||
nextRun: '即将计算...',
|
||||
lastRun: '-',
|
||||
lastStatus: 'none',
|
||||
dbType: formDbType,
|
||||
instance: formInstance,
|
||||
sql: formSql,
|
||||
status: 'running',
|
||||
notifyOnFail: formNotify,
|
||||
createdBy: 'admin',
|
||||
};
|
||||
setTasks(prev => [...prev, newTask]);
|
||||
setShowCreate(false);
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-500/15 text-green-400',
|
||||
paused: 'bg-yellow-500/15 text-yellow-400',
|
||||
completed: 'bg-blue-500/15 text-blue-400',
|
||||
failed: 'bg-red-500/15 text-red-400',
|
||||
};
|
||||
|
||||
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">配置定时执行的 SQL 任务,支持 Cron 表达式</p>
|
||||
</div>
|
||||
<button onClick={openCreate} className="flex items-center gap-2 px-4 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">
|
||||
<Plus className="w-4 h-4" /> 新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '任务总数', value: tasks.length, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '运行中', value: tasks.filter(t => t.status === 'running').length, icon: Play, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '已暂停', value: tasks.filter(t => t.status === 'paused').length, icon: Pause, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||
{ label: '上次失败', value: tasks.filter(t => t.lastStatus === 'failed').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-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="space-y-3">
|
||||
{tasks.map(task => {
|
||||
const isExpanded = expandedId === task.id;
|
||||
return (
|
||||
<Card key={task.id} className={`dg-card ${!task.enabled ? 'opacity-60' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-semibold">{task.name}</h3>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${statusColors[task.status]}`}>
|
||||
{task.status === 'running' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status === 'failed' ? '失败' : '完成'}
|
||||
</span>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
||||
{frequencyLabels[task.frequency]}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] px-2 py-0.5 rounded">
|
||||
{task.cron}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{task.description}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><Database className="w-3.5 h-3.5" /> {task.instance} ({task.dbType.toUpperCase()})</span>
|
||||
<span className="flex items-center gap-1"><Calendar className="w-3.5 h-3.5" /> 下次执行: {task.nextRun}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" /> 上次执行:
|
||||
<span className={task.lastStatus === 'success' ? 'text-green-400' : task.lastStatus === 'failed' ? 'text-red-400' : ''}>{task.lastRun}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1"><Zap className="w-3.5 h-3.5" /> 创建者: {task.createdBy}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-4 p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
|
||||
<pre className="text-sm text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap overflow-x-auto">{task.sql}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<button onClick={() => setPreviewTask(task)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setExpandedId(isExpanded ? null : task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => toggleTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={task.enabled ? '暂停' : '启动'}>
|
||||
{task.enabled ? <Pause className="w-4 h-4 text-yellow-400" /> : <Play className="w-4 h-4 text-green-400" />}
|
||||
</button>
|
||||
<button onClick={() => deleteTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
{previewTask && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewTask(null)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-semibold">{previewTask.name}</h3>
|
||||
<button onClick={() => setPreviewTask(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><span className="text-[var(--dg-text-muted)]">执行频率:</span> {frequencyLabels[previewTask.frequency]} ({previewTask.cron})</div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">数据库:</span> {previewTask.instance} ({previewTask.dbType.toUpperCase()})</div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">下次执行:</span> {previewTask.nextRun}</div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">上次状态:</span> <span className={previewTask.lastStatus === 'success' ? 'text-green-400' : previewTask.lastStatus === 'failed' ? 'text-red-400' : ''}>{previewTask.lastStatus === 'success' ? '成功' : previewTask.lastStatus === 'failed' ? '失败' : '未执行'}</span></div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap overflow-x-auto">{previewTask.sql}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={() => setPreviewTask(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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建弹窗 */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold">新建自动任务</h3>
|
||||
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务名称 <span className="text-red-400">*</span></label>
|
||||
<input type="text" value={formName} onChange={e => setFormName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="例如:每日数据归档" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务描述</label>
|
||||
<textarea value={formDesc} onChange={e => setFormDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={2} placeholder="描述任务用途" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">执行频率</label>
|
||||
<select value={formFreq} onChange={e => setFormFreq(e.target.value as TaskFrequency)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
||||
{Object.entries(frequencyLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Cron 表达式</label>
|
||||
<input type="text" value={formCron} onChange={e => setFormCron(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500" placeholder="0 2 * * *" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">数据库类型</label>
|
||||
<select value={formDbType} onChange={e => setFormDbType(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="redis">Redis</option>
|
||||
<option value="mongo">MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">目标实例</label>
|
||||
<input type="text" value={formInstance} onChange={e => setFormInstance(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="生产主库" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<textarea value={formSql} onChange={e => setFormSql(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500 resize-none" rows={6} placeholder="输入要执行的 SQL 语句..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formNotify} onCheckedChange={setFormNotify} />
|
||||
<span className="text-sm">执行失败时发送通知</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={() => setShowCreate(false)} 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 onClick={saveTask} disabled={!formName.trim() || !formSql.trim()} className="flex items-center gap-2 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 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Save className="w-4 h-4" /> 创建任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Send, MessageSquare, Mail, Navigation, Bell,
|
||||
Shield, Search, ClipboardList, Database, AlertTriangle,
|
||||
Save, CheckCircle, Info, Server, UserSearch, Lock, FileText
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
type NotificationChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
enabled: boolean;
|
||||
webhook: string;
|
||||
events: string[];
|
||||
testStatus: 'none' | 'success' | 'error';
|
||||
};
|
||||
|
||||
type DDLPattern = {
|
||||
id: string;
|
||||
pattern: string;
|
||||
description: string;
|
||||
action: 'block' | 'warn' | 'require_approval';
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type SubmitRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, any>;
|
||||
};
|
||||
|
||||
export default function ConfigPlatformPage() {
|
||||
const [activeTab, setActiveTab] = useState('notification');
|
||||
const [savedTab, setSavedTab] = useState<string | null>(null);
|
||||
|
||||
// 消息推送配置
|
||||
const [notifChannels, setNotifChannels] = useState<NotificationChannel[]>([
|
||||
{ id: 'feishu', name: '飞书', icon: Send, enabled: true, webhook: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx', events: ['工单创建', '审批通过', '审批拒绝', '执行完成', '执行失败'], testStatus: 'none' },
|
||||
{ id: 'dingtalk', name: '钉钉', icon: MessageSquare, enabled: true, webhook: 'https://oapi.dingtalk.com/robot/send?access_token=xxx', events: ['工单创建', '审批通过', '执行失败'], testStatus: 'none' },
|
||||
{ id: 'wechat', name: '企业微信', icon: Bell, enabled: false, webhook: '', events: ['工单创建', '执行完成'], testStatus: 'none' },
|
||||
{ id: 'telegram', name: 'Telegram', icon: Navigation, enabled: false, webhook: '', events: ['执行失败'], testStatus: 'none' },
|
||||
{ id: 'email', name: '邮件通知', icon: Mail, enabled: true, webhook: '', events: ['工单创建', '审批通过', '审批拒绝', '执行完成'], testStatus: 'none' },
|
||||
]);
|
||||
|
||||
// LDAP 配置
|
||||
const [ldapConfig, setLdapConfig] = useState({
|
||||
enabled: false,
|
||||
server: 'ldap://ldap.company.com:389',
|
||||
bindDN: 'cn=admin,dc=company,dc=com',
|
||||
bindPassword: '',
|
||||
baseDN: 'ou=users,dc=company,dc=com',
|
||||
userFilter: '(objectClass=person)',
|
||||
uidField: 'uid',
|
||||
emailField: 'mail',
|
||||
nameField: 'cn',
|
||||
groupField: 'memberOf',
|
||||
autoSync: true,
|
||||
syncInterval: 30,
|
||||
});
|
||||
|
||||
// 查询限制配置
|
||||
const [queryConfig, setQueryConfig] = useState({
|
||||
maxLimit: 10000,
|
||||
enableAudit: true,
|
||||
requireApproval: false,
|
||||
allowExport: true,
|
||||
maxExportRows: 50000,
|
||||
timeoutSeconds: 30,
|
||||
allowWriteOperation: false,
|
||||
allowedDataSources: ['mysql', 'postgres', 'redis', 'mongo'],
|
||||
forbiddenKeywords: ['DROP', 'TRUNCATE', 'DELETE FROM', 'UPDATE.*SET'],
|
||||
});
|
||||
|
||||
// 工单提交规则
|
||||
const [submitRules, setSubmitRules] = useState<SubmitRule[]>([
|
||||
{ id: 'r1', name: 'SQL 语法检查', description: '提交前自动进行 SQL 语法检查,语法错误不允许提交', enabled: true, config: {} },
|
||||
{ id: 'r2', name: '影响行数限制', description: '单次操作影响行数不得超过配置值', enabled: true, config: { maxRows: 10000 } },
|
||||
{ id: 'r3', name: '工单标题必填', description: '工单标题不能为空,且长度至少 5 个字符', enabled: true, config: { minLength: 5 } },
|
||||
{ id: 'r4', name: '变更原因必填', description: '必须填写变更原因/业务背景说明', enabled: true, config: { minLength: 10 } },
|
||||
{ id: 'r5', name: '回滚 SQL 必填', description: 'UPDATE/DELETE 操作必须提供回滚 SQL', enabled: true, config: {} },
|
||||
{ id: 'r6', name: '禁止提交时间限制', description: '配置禁止提交工单的时间段(如生产高峰期)', enabled: false, config: { forbiddenHours: ['09:00-10:00', '14:00-15:00', '20:00-09:00'] } },
|
||||
{ id: 'r7', name: '同一表并发限制', description: '同一数据表同时只能有一个进行中的变更工单', enabled: true, config: {} },
|
||||
{ id: 'r8', name: '敏感操作二次确认', description: '影响行数超过 1000 行的操作需要二次确认', enabled: true, config: { threshold: 1000 } },
|
||||
]);
|
||||
|
||||
// DDL 拦截规则
|
||||
const [ddlPatterns, setDdlPatterns] = useState<DDLPattern[]>([
|
||||
{ id: 'd1', pattern: 'DROP TABLE', description: '禁止删除表操作', action: 'block', enabled: true },
|
||||
{ id: 'd2', pattern: 'DROP DATABASE', description: '禁止删除数据库操作', action: 'block', enabled: true },
|
||||
{ id: 'd3', pattern: 'TRUNCATE TABLE', description: '禁止清空表操作', action: 'block', enabled: true },
|
||||
{ id: 'd4', pattern: 'ALTER TABLE.*DROP COLUMN', description: '删除字段需要审批', action: 'require_approval', enabled: true },
|
||||
{ id: 'd5', pattern: 'ALTER TABLE.*MODIFY', description: '修改字段类型需要审批', action: 'require_approval', enabled: true },
|
||||
{ id: 'd6', pattern: 'ALTER TABLE.*CHANGE', description: '重命名字段需要审批', action: 'require_approval', enabled: true },
|
||||
{ id: 'd7', pattern: 'RENAME TABLE', description: '重命名表需要审批', action: 'require_approval', enabled: true },
|
||||
{ id: 'd8', pattern: 'CREATE TABLE', description: '创建表警告提示', action: 'warn', enabled: true },
|
||||
{ id: 'd9', pattern: 'ALTER TABLE.*ADD COLUMN', description: '添加字段警告提示', action: 'warn', enabled: true },
|
||||
{ id: 'd10', pattern: 'CREATE INDEX', description: '创建索引警告提示', action: 'warn', enabled: true },
|
||||
]);
|
||||
|
||||
const handleSave = (tabId: string) => {
|
||||
setSavedTab(tabId);
|
||||
setTimeout(() => setSavedTab(null), 2000);
|
||||
};
|
||||
|
||||
const toggleNotifChannel = (id: string) => {
|
||||
setNotifChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
||||
};
|
||||
|
||||
const toggleSubmitRule = (id: string) => {
|
||||
setSubmitRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
|
||||
const toggleDDLPattern = (id: string) => {
|
||||
setDdlPatterns(prev => prev.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p));
|
||||
};
|
||||
|
||||
const updateNotifWebhook = (id: string, value: string) => {
|
||||
setNotifChannels(prev => prev.map(c => c.id === id ? { ...c, webhook: value } : c));
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
block: '拦截',
|
||||
warn: '警告',
|
||||
require_approval: '需审批',
|
||||
};
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
block: 'bg-red-500/15 text-red-400',
|
||||
warn: 'bg-yellow-500/15 text-yellow-400',
|
||||
require_approval: 'bg-blue-500/15 text-blue-400',
|
||||
};
|
||||
|
||||
const testWebhook = (id: string) => {
|
||||
setNotifChannels(prev => prev.map(c => {
|
||||
if (c.id === id) {
|
||||
return { ...c, testStatus: Math.random() > 0.3 ? 'success' : 'error' };
|
||||
}
|
||||
return c;
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setNotifChannels(prev => prev.map(c => c.id === id ? { ...c, testStatus: 'none' } : c));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
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">全局系统配置,影响所有用户的默认行为和功能开关</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] p-1">
|
||||
<TabsTrigger value="notification" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
||||
<Bell className="w-4 h-4 mr-2" /> 消息推送
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ldap" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
||||
<Server className="w-4 h-4 mr-2" /> LDAP 设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="query" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
||||
<Search className="w-4 h-4 mr-2" /> 查询限制
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ticket" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
||||
<ClipboardList className="w-4 h-4 mr-2" /> 工单规则
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ddl" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
||||
<Database className="w-4 h-4 mr-2" /> DDL 拦截
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 消息推送 */}
|
||||
<TabsContent value="notification" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">消息推送配置</h2>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置各渠道的通知开关和 Webhook 地址</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave('notification')}
|
||||
className="flex items-center gap-2 px-4 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"
|
||||
>
|
||||
{savedTab === 'notification' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{savedTab === 'notification' ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{notifChannels.map(channel => {
|
||||
const Icon = channel.icon;
|
||||
return (
|
||||
<Card key={channel.id} className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${channel.enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<Icon className={`w-5 h-5 ${channel.enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold">{channel.name}</h3>
|
||||
<Switch checked={channel.enabled} onCheckedChange={() => toggleNotifChannel(channel.id)} />
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${channel.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{channel.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => testWebhook(channel.id)}
|
||||
disabled={!channel.enabled || channel.testStatus !== 'none'}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
!channel.enabled ? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed' :
|
||||
channel.testStatus === 'success' ? 'bg-green-500/15 text-green-400' :
|
||||
channel.testStatus === 'error' ? 'bg-red-500/15 text-red-400' :
|
||||
'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||
}`}
|
||||
>
|
||||
{channel.testStatus === 'success' ? '连接成功' : channel.testStatus === 'error' ? '连接失败' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{channel.enabled && (
|
||||
<div className="space-y-3">
|
||||
{channel.id !== 'email' && (
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={channel.webhook}
|
||||
onChange={(e) => updateNotifWebhook(channel.id, e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-blue-500 transition-colors"
|
||||
placeholder={`请输入 ${channel.name} Webhook 地址`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{channel.id === 'email' && (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-secondary)]">
|
||||
<Info className="w-4 h-4 text-blue-400" />
|
||||
<span>邮件通知通过系统 SMTP 配置发送,请在服务器环境变量中配置 SMTP 参数</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">通知事件</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['工单创建', '审批通过', '审批拒绝', '执行完成', '执行失败'].map(event => (
|
||||
<span
|
||||
key={event}
|
||||
className={`px-2.5 py-1 rounded text-xs ${
|
||||
channel.events.includes(event)
|
||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)]'
|
||||
}`}
|
||||
>
|
||||
{event}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* LDAP 设置 */}
|
||||
<TabsContent value="ldap" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">LDAP 认证设置</h2>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置 LDAP/AD 域账号登录集成</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave('ldap')}
|
||||
className="flex items-center gap-2 px-4 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"
|
||||
>
|
||||
{savedTab === 'ldap' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{savedTab === 'ldap' ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
|
||||
<UserSearch className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">启用 LDAP 认证</h3>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">开启后用户可使用域账号登录系统</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={ldapConfig.enabled}
|
||||
onCheckedChange={(v) => setLdapConfig(prev => ({ ...prev, enabled: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ldapConfig.enabled && (
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">LDAP 服务器地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.server}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, server: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="ldap://ldap.company.com:389"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Bind DN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.bindDN}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, bindDN: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="cn=admin,dc=company,dc=com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Bind 密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={ldapConfig.bindPassword}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, bindPassword: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Base DN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.baseDN}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, baseDN: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="ou=users,dc=company,dc=com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">用户过滤条件</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.userFilter}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, userFilter: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="(objectClass=person)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">UID 字段</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.uidField}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, uidField: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">邮箱字段</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.emailField}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, emailField: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">姓名字段</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.nameField}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, nameField: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-4 pt-2 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={ldapConfig.autoSync}
|
||||
onCheckedChange={(v) => setLdapConfig(prev => ({ ...prev, autoSync: v }))}
|
||||
/>
|
||||
<span className="text-sm">自动同步用户</span>
|
||||
</div>
|
||||
{ldapConfig.autoSync && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">同步间隔</span>
|
||||
<input
|
||||
type="number"
|
||||
value={ldapConfig.syncInterval}
|
||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, syncInterval: parseInt(e.target.value) || 30 }))}
|
||||
className="w-20 px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-sm text-center focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">分钟</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 查询限制 */}
|
||||
<TabsContent value="query" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">查询限制配置</h2>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">控制查询行为、限制和安全策略</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave('query')}
|
||||
className="flex items-center gap-2 px-4 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"
|
||||
>
|
||||
{savedTab === 'query' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{savedTab === 'query' ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/15 flex items-center justify-center">
|
||||
<Search className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">查询审核审批</h3>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">开启后查询需要审批才能执行</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={queryConfig.requireApproval}
|
||||
onCheckedChange={(v) => setQueryConfig(prev => ({ ...prev, requireApproval: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!queryConfig.requireApproval && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<Info className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-yellow-400">不开启查询审核审批时,用户可以查询已授权的数据库资源,但不会被记录审计日志</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium">最大查询 Limit 限制</label>
|
||||
<span className="text-lg font-bold text-blue-400">{queryConfig.maxLimit.toLocaleString()}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[queryConfig.maxLimit]}
|
||||
onValueChange={(v) => setQueryConfig(prev => ({ ...prev, maxLimit: v[0] }))}
|
||||
min={100}
|
||||
max={100000}
|
||||
step={100}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--dg-text-muted)] mt-1">
|
||||
<span>100</span>
|
||||
<span>100,000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium">查询超时时间</label>
|
||||
<span className="text-lg font-bold text-blue-400">{queryConfig.timeoutSeconds} 秒</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[queryConfig.timeoutSeconds]}
|
||||
onValueChange={(v) => setQueryConfig(prev => ({ ...prev, timeoutSeconds: v[0] }))}
|
||||
min={5}
|
||||
max={300}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/15 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">结果导出</h3>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">允许导出查询结果</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={queryConfig.allowExport}
|
||||
onCheckedChange={(v) => setQueryConfig(prev => ({ ...prev, allowExport: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{queryConfig.allowExport && (
|
||||
<div className="pt-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm">最大导出行数</label>
|
||||
<span className="text-sm font-bold text-green-400">{queryConfig.maxExportRows.toLocaleString()}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[queryConfig.maxExportRows]}
|
||||
onValueChange={(v) => setQueryConfig(prev => ({ ...prev, maxExportRows: v[0] }))}
|
||||
min={1000}
|
||||
max={200000}
|
||||
step={1000}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/15 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">写入操作</h3>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">允许执行 INSERT/UPDATE/DELETE</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={queryConfig.allowWriteOperation}
|
||||
onCheckedChange={(v) => setQueryConfig(prev => ({ ...prev, allowWriteOperation: v }))}
|
||||
/>
|
||||
</div>
|
||||
{!queryConfig.allowWriteOperation && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<Lock className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-400">已禁止写入操作,查询模式为只读</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold mb-3">禁止的关键词</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queryConfig.forbiddenKeywords.map((kw, i) => (
|
||||
<span key={i} className="px-2.5 py-1 bg-red-500/10 text-red-400 text-xs rounded-lg border border-red-500/20">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 工单规则 */}
|
||||
<TabsContent value="ticket" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">工单提交规则</h2>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置工单提交时的校验规则和约束条件</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave('ticket')}
|
||||
className="flex items-center gap-2 px-4 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"
|
||||
>
|
||||
{savedTab === 'ticket' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{savedTab === 'ticket' ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{submitRules.map(rule => (
|
||||
<Card key={rule.id} className={`dg-card ${!rule.enabled ? 'opacity-60' : ''}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${rule.enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<Shield className={`w-4 h-4 ${rule.enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">{rule.name}</h3>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${rule.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{rule.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-0.5">{rule.description}</p>
|
||||
{rule.config.maxRows && (
|
||||
<p className="text-xs text-blue-400 mt-1">最大限制: {rule.config.maxRows.toLocaleString()} 行</p>
|
||||
)}
|
||||
{rule.config.threshold && (
|
||||
<p className="text-xs text-blue-400 mt-1">阈值: {rule.config.threshold.toLocaleString()} 行</p>
|
||||
)}
|
||||
{rule.config.minLength && (
|
||||
<p className="text-xs text-blue-400 mt-1">最小长度: {rule.config.minLength} 字符</p>
|
||||
)}
|
||||
{rule.config.forbiddenHours && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">禁止时段:</span>
|
||||
{rule.config.forbiddenHours.map((h: string) => (
|
||||
<span key={h} className="text-[11px] px-1.5 py-0.5 bg-red-500/10 text-red-400 rounded">{h}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => toggleSubmitRule(rule.id)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* DDL 拦截 */}
|
||||
<TabsContent value="ddl" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">DDL 语句拦截规则</h2>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置 DDL 操作的安全策略和拦截规则</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave('ddl')}
|
||||
className="flex items-center gap-2 px-4 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"
|
||||
>
|
||||
{savedTab === 'ddl' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{savedTab === 'ddl' ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{ddlPatterns.map(pattern => (
|
||||
<Card key={pattern.id} className={`dg-card ${!pattern.enabled ? 'opacity-60' : ''}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
pattern.action === 'block' ? 'bg-red-500/15' :
|
||||
pattern.action === 'warn' ? 'bg-yellow-500/15' : 'bg-blue-500/15'
|
||||
}`}>
|
||||
<Database className={`w-4 h-4 ${
|
||||
pattern.action === 'block' ? 'text-red-400' :
|
||||
pattern.action === 'warn' ? 'text-yellow-400' : 'text-blue-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">{pattern.pattern}</code>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${actionColors[pattern.action]}`}>
|
||||
{actionLabels[pattern.action]}
|
||||
</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${pattern.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{pattern.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{pattern.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={pattern.action}
|
||||
onChange={(e) => setDdlPatterns(prev => prev.map(p => p.id === pattern.id ? { ...p, action: e.target.value as any } : p))}
|
||||
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="block">拦截</option>
|
||||
<option value="warn">警告</option>
|
||||
<option value="require_approval">需审批</option>
|
||||
</select>
|
||||
<Switch checked={pattern.enabled} onCheckedChange={() => toggleDDLPattern(pattern.id)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||
TrendingUp, TrendingDown, Database, Megaphone, ChevronRight,
|
||||
X, AlertCircle, Info, Bell
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dashboardStats, dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||
import type { Announcement } from '@/lib/mock';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const priorityStyle: Record<string, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
|
||||
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
|
||||
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
|
||||
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
|
||||
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
|
||||
};
|
||||
|
||||
const priorityLabel: Record<string, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
||||
|
||||
const stats = [
|
||||
{ title: '今日工单', value: dashboardStats.todayTickets, trend: dashboardStats.todayTicketsTrend, icon: ClipboardList, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ title: '待审批', value: dashboardStats.pendingApproval, trend: dashboardStats.pendingTrend, icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
{ title: '查询审计', value: dashboardStats.queryAuditCount.toLocaleString(), trend: dashboardStats.queryTrend, icon: CheckCircle, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ title: '慢查询', value: dashboardStats.slowQueries, trend: dashboardStats.slowQueryTrend, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const visibleAnnouncements = announcements
|
||||
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const po = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||
if (po[a.priority] !== po[b.priority]) return po[a.priority] - po[b.priority];
|
||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
||||
});
|
||||
|
||||
const dismiss = (id: string) => setDismissedIds(prev => new Set(prev).add(id));
|
||||
const toggleExpand = (id: string) => setExpandedId(prev => prev === id ? null : id);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const chart = echarts.init(chartRef.current);
|
||||
const isLight = theme === 'light';
|
||||
const colors = isLight
|
||||
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
|
||||
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
|
||||
const option: any = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
textStyle: { color: colors.text },
|
||||
},
|
||||
legend: {
|
||||
data: ['提交工单', '执行成功', '执行失败'],
|
||||
textStyle: { color: colors.muted },
|
||||
bottom: 0,
|
||||
itemWidth: 14,
|
||||
itemHeight: 8,
|
||||
},
|
||||
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||
axisLine: { lineStyle: { color: colors.border } },
|
||||
axisLabel: { color: colors.muted, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: colors.grid } },
|
||||
axisLabel: { color: colors.muted, fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '提交工单', type: 'line', smooth: true,
|
||||
data: [120, 132, 101, 134, 90, 230, 210],
|
||||
itemStyle: { color: colors.blue },
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: isLight ? 'rgba(37, 99, 235, 0.15)' : 'rgba(88, 166, 255, 0.3)' },
|
||||
{ offset: 1, color: 'transparent' },
|
||||
]),
|
||||
},
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: '执行成功', type: 'line', smooth: true,
|
||||
data: [110, 125, 95, 128, 85, 215, 200],
|
||||
itemStyle: { color: colors.green },
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: isLight ? 'rgba(22, 163, 74, 0.15)' : 'rgba(63, 185, 80, 0.3)' },
|
||||
{ offset: 1, color: 'transparent' },
|
||||
]),
|
||||
},
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: '执行失败', type: 'line', smooth: true,
|
||||
data: [5, 3, 4, 2, 3, 8, 5],
|
||||
itemStyle: { color: colors.red },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'none',
|
||||
},
|
||||
],
|
||||
};
|
||||
chart.setOption(option);
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => { window.removeEventListener('resize', handleResize); chart.dispose(); };
|
||||
}, [theme]);
|
||||
|
||||
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
|
||||
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
|
||||
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
|
||||
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 公告通知栏 */}
|
||||
{visibleAnnouncements.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{visibleAnnouncements.slice(0, 3).map(ann => {
|
||||
const style = priorityStyle[ann.priority] || priorityStyle.normal;
|
||||
const PIcon = style.icon;
|
||||
const isExpanded = expandedId === ann.id;
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<div className="px-5 py-3.5 flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}>
|
||||
<PIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}>
|
||||
{priorityLabel[ann.priority]}
|
||||
</span>
|
||||
<span className="text-sm font-semibold truncate">{ann.title}</span>
|
||||
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0">置顶</span>}
|
||||
</div>
|
||||
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
|
||||
{ann.content}
|
||||
</div>
|
||||
{ann.content.includes('\n') && (
|
||||
<button
|
||||
onClick={() => toggleExpand(ann.id)}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 flex items-center gap-0.5 transition-colors"
|
||||
>
|
||||
{isExpanded ? '收起' : '展开全文'}
|
||||
<ChevronRight className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
|
||||
<button
|
||||
onClick={() => dismiss(ann.id)}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleAnnouncements.length > 3 && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
还有 {visibleAnnouncements.length - 3} 条公告,可在「公告管理」中查看
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-5">
|
||||
{stats.map((s, i) => (
|
||||
<Card key={i} className="dg-card dg-card-hover">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
||||
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center`}>
|
||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-[var(--dg-text-primary)] mb-2">{s.value}</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={`flex items-center gap-1 font-medium ${s.trend >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{s.trend >= 0 ? <TrendingUp className="w-3.5 h-3.5" /> : <TrendingDown className="w-3.5 h-3.5" />}
|
||||
{Math.abs(s.trend)}%
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)]">较昨日</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 图表 + 实例状态 */}
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
{/* 工单趋势图 */}
|
||||
<Card className="dg-card col-span-2">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold">工单趋势</CardTitle>
|
||||
<div className="flex gap-1.5">
|
||||
{['近7天', '近30天', '近90天'].map((p, i) => (
|
||||
<button key={p} className={`px-3 py-1.5 text-xs rounded-md transition-colors ${i === 0 ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div ref={chartRef} style={{ height: 300 }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 实例状态 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold">实例状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="p-4 border-b border-[var(--dg-border-light)]">
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{dbInstances.slice(0, 4).map(inst => (
|
||||
<div key={inst.id} className="flex items-center px-5 py-3.5 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 ${inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' : inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' : inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' : 'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'}`}>
|
||||
<Database className="w-4.5 h-4.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{inst.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</div>
|
||||
</div>
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${inst.status === 'online' ? 'bg-green-500/15 text-green-400' : inst.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{inst.status === 'online' ? '在线' : inst.status === 'warning' ? '告警' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 待处理工单 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold">待处理工单</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
|
||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingTickets.map(ticket => (
|
||||
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5 font-mono text-xs">{ticket.id}</td>
|
||||
<td className="px-5 py-3.5 max-w-xs truncate">{ticket.title}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
|
||||
{ticket.dbType.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
|
||||
{statusMap[ticket.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">查看</button>
|
||||
<button className="text-green-400 hover:text-green-300 text-xs font-medium transition-colors">审批</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Database, Table, Filter, Shield } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { dataPermissions } from '@/lib/mock';
|
||||
|
||||
export default function DataPermissionPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = dataPermissions.filter(p =>
|
||||
!search || p.user.includes(search) || p.instance.includes(search) || p.database.includes(search)
|
||||
);
|
||||
|
||||
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">配置用户对数据库、表、字段的访问权限,支持行级权限控制</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 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">
|
||||
<Plus className="w-4 h-4" /> 添加权限
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '权限规则数', value: dataPermissions.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '受控用户', value: new Set(dataPermissions.map(p => p.user)).size, icon: Database, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ label: '受控实例', value: new Set(dataPermissions.map(p => p.instance)).size, icon: Table, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '行级过滤规则', value: dataPermissions.filter(p => p.rowFilter).length, icon: Filter, color: 'text-orange-400', bg: 'bg-orange-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="relative max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索用户/实例/数据库" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">实例</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">表</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">字段权限</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">行级过滤</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作权限</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => (
|
||||
<tr key={p.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5 font-medium">{p.user}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.instance}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.database}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.table}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{p.columns === '*' ? (
|
||||
<span className="px-2 py-1 bg-green-500/15 text-green-400 text-xs rounded-md">全部字段</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.columns.split(',').map(c => (
|
||||
<span key={c} className="px-2 py-1 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{p.rowFilter ? (
|
||||
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-md">{p.rowFilter}</span>
|
||||
) : (
|
||||
<span className="text-[var(--dg-text-muted)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.operations.map(op => (
|
||||
<span key={op} className="px-2 py-1 bg-blue-500/15 text-blue-400 text-xs rounded-md">{op}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import '../App.css'
|
||||
|
||||
export default function Home() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
||||
Activity, HardDrive, Cpu, MemoryStick, Clock, ChevronRight, Trash2, Edit2,
|
||||
Undo2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dbInstances } from '@/lib/mock';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import type { DBInstance } from '@/types';
|
||||
|
||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||
mysql: { icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
||||
postgres: { icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||
redis: { icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
||||
mongo: { icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||
};
|
||||
|
||||
export default function InstancePage() {
|
||||
const { type = 'mysql' } = useParams<{ type: string }>();
|
||||
const canAdd = useActionPermission('instance.add');
|
||||
const canEdit = useActionPermission('instance.edit');
|
||||
const canDelete = useActionPermission('instance.delete');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterEnv, setFilterEnv] = useState('');
|
||||
const [selectedInstance, setSelectedInstance] = useState<DBInstance | null>(null);
|
||||
const [view, setView] = useState<'grid' | 'detail'>('grid');
|
||||
|
||||
const config: { icon: React.ElementType; color: string; bg: string; label: string } = dbTypeConfig[type] || dbTypeConfig.mysql;
|
||||
|
||||
const instances = useMemo(() => {
|
||||
return dbInstances.filter(i => {
|
||||
if (i.dbType !== type) return false;
|
||||
if (filterStatus && i.status !== filterStatus) return false;
|
||||
if (filterEnv && i.env !== filterEnv) return false;
|
||||
if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [type, filterStatus, filterEnv, searchQuery]);
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
online: instances.filter(i => i.status === 'online').length,
|
||||
warning: instances.filter(i => i.status === 'warning').length,
|
||||
offline: instances.filter(i => i.status === 'offline').length,
|
||||
}), [instances]);
|
||||
|
||||
if (view === 'detail' && selectedInstance) {
|
||||
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={config} canEdit={canEdit} canDelete={canDelete} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title={`${config.label} 实例管理`}
|
||||
description={`管理 ${config.label} 数据库实例,监控连接状态和性能指标`}
|
||||
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: () => {} }}
|
||||
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => {}, permission: 'instance.add' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center"><Activity className="w-5 h-5 text-green-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{statusCounts.online}</div><div className="text-xs text-[var(--dg-text-muted)]">在线实例</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 flex items-center justify-center"><HardDrive className="w-5 h-5 text-yellow-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{statusCounts.warning}</div><div className="text-xs text-[var(--dg-text-muted)]">告警实例</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center"><HardDrive className="w-5 h-5 text-red-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{statusCounts.offline}</div><div className="text-xs text-[var(--dg-text-muted)]">离线实例</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center"><Server className="w-5 h-5 text-blue-400" /></div>
|
||||
<div><div className="text-2xl font-bold">{instances.length}</div><div className="text-xs text-[var(--dg-text-muted)]">总实例数</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
||||
placeholder="搜索实例名称/地址"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:border-blue-400 focus:outline-none" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="warning">告警</option>
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:border-blue-400 focus:outline-none" value={filterEnv} onChange={e => setFilterEnv(e.target.value)}>
|
||||
<option value="">全部环境</option>
|
||||
<option value="prod">生产</option>
|
||||
<option value="test">测试</option>
|
||||
<option value="dev">开发</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 实例卡片网格 */}
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{instances.map(instance => (
|
||||
<div
|
||||
key={instance.id}
|
||||
className="dg-card p-5 cursor-pointer dg-card-hover group"
|
||||
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-11 h-11 rounded-xl ${config.bg} flex items-center justify-center`}>
|
||||
<config.icon className={`w-6 h-6 ${config.color}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{instance.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{tag}</span>
|
||||
))}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${instance.status === 'online' ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : instance.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-1">{instance.name}</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{config.label} {instance.version} · {instance.env === 'prod' ? '生产' : instance.env === 'test' ? '测试' : '开发'}</p>
|
||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-[var(--dg-border)]">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">{instance.connections}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">连接数</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">{instance.qps.toLocaleString()}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">QPS</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">{instance.latency}<span className="text-xs font-normal text-[var(--dg-text-muted)]">ms</span></div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">延迟</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Cpu className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-400 rounded-full transition-all" style={{ width: `${instance.cpu}%` }} />
|
||||
</div>
|
||||
<span className="text-[var(--dg-text-muted)] w-8 text-right">{instance.cpu}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<MemoryStick className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-400 rounded-full transition-all" style={{ width: `${instance.memory}%` }} />
|
||||
</div>
|
||||
<span className="text-[var(--dg-text-muted)] w-8 text-right">{instance.memory}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete }: {
|
||||
instance: DBInstance; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string };
|
||||
canEdit: boolean; canDelete: boolean;
|
||||
}) {
|
||||
const DetailIcon = config.icon;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl ${config.bg} flex items-center justify-center`}>
|
||||
<DetailIcon className={`w-5 h-5 ${config.color}`} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
{instance.name}
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${instance.status === 'online' ? 'bg-green-500/15 text-green-400' : instance.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{instance.status === 'online' ? '在线' : instance.status === 'warning' ? '告警' : '离线'}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{instance.host}:{instance.port} · 版本 {instance.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canEdit && (
|
||||
<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 text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Edit2 className="w-4 h-4" /> 编辑
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<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 text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all">
|
||||
<Trash2 className="w-4 h-4" /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 性能指标 */}
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[
|
||||
{ label: '连接数', value: instance.connections, icon: Activity },
|
||||
{ label: 'QPS', value: instance.qps.toLocaleString(), icon: Zap },
|
||||
{ label: '延迟', value: `${instance.latency}ms`, icon: Clock },
|
||||
{ label: 'CPU', value: `${instance.cpu}%`, icon: Cpu },
|
||||
{ label: '内存', value: `${instance.memory}%`, icon: MemoryStick },
|
||||
{ label: '磁盘', value: `${instance.disk}%`, icon: HardDrive },
|
||||
].map((m, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 text-center">
|
||||
<m.icon className="w-5 h-5 text-[var(--dg-text-muted)] mx-auto mb-2" />
|
||||
<div className="text-xl font-bold">{m.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{m.label}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* 连接信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '主机地址', value: instance.host },
|
||||
{ label: '端口', value: instance.port.toString() },
|
||||
{ label: '数据库版本', value: instance.version },
|
||||
{ label: '运行环境', value: instance.env === 'prod' ? '生产环境' : instance.env === 'test' ? '测试环境' : '开发环境' },
|
||||
{ label: '运行时间', value: instance.uptime },
|
||||
{ label: '标签', value: instance.tags.join(', ') },
|
||||
...(instance.dbType === 'mysql' ? [
|
||||
{ label: 'Binlog 格式', value: <span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded">ROW</span> },
|
||||
{ label: 'Binlog 状态', value: <span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded">ON</span> },
|
||||
{ label: '回滚支持', value: <span className="flex items-center gap-1"><Undo2 className="w-3 h-3 text-green-400" /> <span className="text-green-400 text-xs">支持</span></span> },
|
||||
] : []),
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 资源使用趋势 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">资源使用</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-5">
|
||||
{[
|
||||
{ label: 'CPU 使用率', value: instance.cpu, color: 'bg-blue-400' },
|
||||
{ label: '内存使用率', value: instance.memory, color: 'bg-purple-400' },
|
||||
{ label: '磁盘使用率', value: instance.disk, color: 'bg-orange-400' },
|
||||
].map(item => (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="font-medium">{item.value}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div className={`h-full ${item.color} rounded-full transition-all`} style={{ width: `${item.value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Shield, Database, Code, GitBranch, Bell, History, UserCog, Sparkles, Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const ok = await login(username, password);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError('用户名或密码错误');
|
||||
}
|
||||
};
|
||||
|
||||
const features = [
|
||||
{ icon: Database, title: '多数据库支持', desc: 'MySQL、PostgreSQL、Redis、MongoDB' },
|
||||
{ icon: Code, title: '智能 SQL 审核', desc: '语法检查、规则校验、影响分析、自动评分' },
|
||||
{ icon: GitBranch, title: '多级审批流程', desc: '会签、或签、条件审批灵活配置' },
|
||||
{ icon: Bell, title: '多渠道通知', desc: '飞书、钉钉、企微、TG 即时推送' },
|
||||
{ icon: History, title: '完整审计追踪', desc: '所有操作可追溯,支持 SQL 回滚' },
|
||||
{ icon: UserCog, title: '细粒度权限', desc: 'RBAC 角色权限,字段级/行级访问控制' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-[var(--dg-bg-primary)] relative overflow-hidden">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_20%_20%,rgba(88,166,255,0.08)_0%,transparent_50%),radial-gradient(ellipse_at_80%_80%,rgba(137,87,229,0.08)_0%,transparent_50%)]" />
|
||||
</div>
|
||||
{/* 主题切换 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="absolute top-5 right-5 z-20 w-10 h-10 rounded-xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{/* 左侧 */}
|
||||
<div className="flex-1 flex flex-col justify-center px-16 relative z-10">
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl dg-gradient-br flex items-center justify-center">
|
||||
<Shield className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold dg-gradient-text">
|
||||
Web-Lite Pro
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-lg text-[var(--dg-text-secondary)] leading-relaxed">
|
||||
企业级数据审核与变更管理平台<br />
|
||||
支持多数据库类型,提供完善的 SQL 审核、审批流程和审计追踪
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{features.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-4 rounded-xl bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] hover:border-blue-500/50 hover:bg-[var(--dg-bg-secondary)] transition-all group">
|
||||
<f.icon className="w-6 h-6 text-blue-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-1">{f.title}</h4>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">{f.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧 */}
|
||||
<div className="w-[480px] flex flex-col justify-center px-16 bg-[var(--dg-bg-secondary)]/80 backdrop-blur-xl border-l border-[var(--dg-border)] relative z-10">
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-5 h-5 text-blue-400" />
|
||||
<h2 className="text-2xl font-bold">欢迎回来</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-8">请登录您的账户以继续</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">用户名 / 邮箱</label>
|
||||
<div className="relative">
|
||||
<UserCog className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
||||
placeholder="admin"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">密码</label>
|
||||
<div className="relative">
|
||||
<Shield className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
type="password"
|
||||
className="w-full pl-10 pr-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
||||
placeholder="123456"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="w-full py-3 dg-gradient-bg rounded-lg text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : '登录'}
|
||||
</button>
|
||||
{error && <p className="text-xs text-red-400 text-center mt-2">{error}</p>}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center">演示账号</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button onClick={() => { setUsername('admin'); setPassword('123456'); }} className="flex-1 py-2 text-xs rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 hover:bg-blue-500/20 transition-colors">
|
||||
管理员
|
||||
</button>
|
||||
<button onClick={() => { setUsername('developer'); setPassword('123456'); }} className="flex-1 py-2 text-xs rounded-md bg-green-500/10 text-green-400 border border-green-500/20 hover:bg-green-500/20 transition-colors">
|
||||
开发者
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { Send, Bell, MessageSquare, Navigation, Mail, Check, ToggleLeft, ToggleRight, Webhook, Settings, TestTube } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { notificationChannels } from '@/lib/mock';
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Send, Bell, MessageSquare, Navigation, Mail,
|
||||
};
|
||||
|
||||
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 toggleChannel = (id: number) => {
|
||||
setChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
||||
};
|
||||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Search, Download, Plus, Clock, Eye, Copy, X, Database, FileText,
|
||||
AlertTriangle, CheckCircle, XCircle, ChevronRight, Play
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { queryAudits, dbTypeColorMap } from '@/lib/mock';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import type { QueryAudit } from '@/types';
|
||||
|
||||
export default function QueriesPage() {
|
||||
const canCreate = useActionPermission('query.create');
|
||||
const canExport = useActionPermission('query.export');
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selectedQuery, setSelectedQuery] = useState<QueryAudit | null>(null);
|
||||
const [showNewQuery, setShowNewQuery] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return queryAudits.filter(q => {
|
||||
if (dbTypeFilter && q.dbType !== dbTypeFilter) return false;
|
||||
if (statusFilter && q.status !== statusFilter) return false;
|
||||
if (search && !q.sql.includes(search) && !q.user.includes(search) && !q.instance.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, dbTypeFilter, statusFilter]);
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
total: queryAudits.length,
|
||||
success: queryAudits.filter(q => q.status === 'success').length,
|
||||
slow: queryAudits.filter(q => q.status === 'slow').length,
|
||||
failed: queryAudits.filter(q => q.status === 'failed').length,
|
||||
}), []);
|
||||
|
||||
if (showNewQuery) {
|
||||
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} />;
|
||||
}
|
||||
|
||||
if (selectedQuery) {
|
||||
return <QueryDetail query={selectedQuery} onBack={() => setSelectedQuery(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="查询审计"
|
||||
description="记录所有 SQL 查询操作,支持敏感数据脱敏和性能分析"
|
||||
secondaryAction={canExport ? { label: '导出日志', icon: Download, onClick: () => {} } : undefined}
|
||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 统计 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '总查询数', value: statusCounts.total, icon: FileText, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '成功', value: statusCounts.success, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '慢查询', value: statusCounts.slow, icon: Clock, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||
{ label: '失败', value: statusCounts.failed, icon: XCircle, color: 'text-red-400', bg: 'bg-red-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="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索 SQL 关键词/用户/实例" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
|
||||
<option value="">全部数据库</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="redis">Redis</option>
|
||||
<option value="mongo">MongoDB</option>
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="slow">慢查询</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 查询表格 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">查询ID</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">SQL 语句</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">执行用户</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">执行时长</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">返回行数</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">敏感数据</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(q => (
|
||||
<tr key={q.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer" onClick={() => setSelectedQuery(q)}>
|
||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{q.id}</td>
|
||||
<td className="px-5 py-3.5 max-w-xs">
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sqlPreview}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[q.dbType] || ''}`}>{q.dbType.toUpperCase()}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">{q.user}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={q.executeTime > 1000 ? 'text-red-400 font-medium' : ''}>{q.executeTime}ms</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">{q.rowCount}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${q.status === 'success' ? 'bg-green-500/15 text-green-400' : q.status === 'slow' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${q.status === 'slow' ? 'animate-pulse' : ''}`} />
|
||||
{q.status === 'success' ? '成功' : q.status === 'slow' ? '慢查询' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{q.hasSensitive ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400">
|
||||
<AlertTriangle className="w-3 h-3" /> 已脱敏
|
||||
</span>
|
||||
) : <span className="text-[var(--dg-text-muted)] text-xs">-</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryDetail({ query, onBack }: { query: QueryAudit; onBack: () => void }) {
|
||||
const maskData = (value: string) => {
|
||||
if (!value) return '***';
|
||||
if (value.includes('@')) {
|
||||
const [name, domain] = value.split('@');
|
||||
return name.substring(0, 3) + '***@' + domain;
|
||||
}
|
||||
if (value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
||||
return '***';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
查询详情
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-medium ${query.status === 'success' ? 'bg-green-500/15 text-green-400' : query.status === 'slow' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{query.status === 'success' ? '成功' : query.status === 'slow' ? '慢查询' : '失败'}
|
||||
</span>
|
||||
{query.hasSensitive && <span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400"><AlertTriangle className="w-3 h-3" /> 包含敏感数据</span>}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{query.id} · {query.user} · {query.timestamp}</p>
|
||||
</div>
|
||||
</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 hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Copy className="w-4 h-4" /> 复制 SQL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 执行指标 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '执行时长', value: `${query.executeTime}ms`, isSlow: query.executeTime > 1000, icon: Clock },
|
||||
{ label: '返回行数', value: query.rowCount.toString(), icon: FileText },
|
||||
{ label: '扫描行数', value: query.scanRows.toLocaleString(), icon: Database },
|
||||
{ label: '影响行数', value: query.affectedRows.toString(), icon: CheckCircle },
|
||||
].map((m, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 text-center">
|
||||
<m.icon className="w-5 h-5 text-[var(--dg-text-muted)] mx-auto mb-2" />
|
||||
<div className={`text-xl font-bold ${m.isSlow ? 'text-red-400' : ''}`}>{m.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{m.label}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* SQL 详情 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">SQL 语句</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{query.sql}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 连接信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '数据库类型', value: query.dbType.toUpperCase() },
|
||||
{ label: '目标实例', value: query.instance },
|
||||
{ label: '执行用户', value: query.user },
|
||||
{ label: '客户端 IP', value: query.clientIp },
|
||||
{ label: '来源', value: query.source },
|
||||
{ label: '执行时间', value: query.timestamp },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 查询结果 */}
|
||||
{query.resultSet && query.resultColumns && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold">查询结果</CardTitle>
|
||||
{query.hasSensitive && <span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400"><AlertTriangle className="w-3 h-3" /> 敏感字段已脱敏</span>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
{query.resultColumns.map(col => (
|
||||
<th key={col.prop} className="text-left px-4 py-3 font-semibold text-xs">
|
||||
{col.label}
|
||||
{col.sensitive && <span className="ml-1.5 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-400 text-[10px] rounded">敏感</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{query.resultSet.map((row, i) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
{query.resultColumns!.map(col => (
|
||||
<td key={col.prop} className="px-4 py-3 text-xs">
|
||||
{col.sensitive ? (
|
||||
<span className="text-yellow-400 italic">{maskData(String(row[col.prop]))}</span>
|
||||
) : (
|
||||
String(row[col.prop])
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewQueryPanel({ onCancel }: { onCancel: () => void }) {
|
||||
const [sql, setSql] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-6 h-6" /></button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<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>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
|
||||
</select>
|
||||
</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>生产 MySQL 主库</option><option>测试库</option><option>开发库</option>
|
||||
</select>
|
||||
</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>user_db</option><option>order_db</option><option>log_db</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句</label>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<textarea
|
||||
className="w-full min-h-[250px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder="-- 请输入 SQL 查询语句\n-- 查询结果将自动脱敏敏感字段\n\nSELECT id, name, email, phone, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span>{sql.length} 字符</span>
|
||||
<span>Ctrl + Enter 执行查询</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">查询结果中的敏感字段(邮箱、手机号、身份证号等)将自动脱敏显示。完整数据需要额外权限。</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-5 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)] transition-colors">取消</button>
|
||||
<button className="flex items-center gap-2 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">
|
||||
<Play className="w-4 h-4" /> 执行查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { roles, permissionTreeData } from '@/lib/mock';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
|
||||
|
||||
export default function RolesPage() {
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(roles[0]);
|
||||
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(roles[0]?.permissions || []));
|
||||
|
||||
const handleRoleSelect = (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setCheckedKeys(new Set(role.permissions));
|
||||
};
|
||||
|
||||
const togglePermission = (id: string) => {
|
||||
const next = new Set(checkedKeys);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setCheckedKeys(next);
|
||||
};
|
||||
|
||||
const toggleChildren = (node: typeof permissionTreeData[0], checked: boolean) => {
|
||||
const next = new Set(checkedKeys);
|
||||
const toggleNode = (n: typeof node) => {
|
||||
if (checked) next.add(n.id);
|
||||
else next.delete(n.id);
|
||||
n.children?.forEach(toggleNode);
|
||||
};
|
||||
toggleNode(node);
|
||||
setCheckedKeys(next);
|
||||
};
|
||||
|
||||
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">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 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">
|
||||
<Shield className="w-4 h-4" /> 新建角色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 角色卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{roles.map(role => {
|
||||
const Icon = iconMap[role.icon] || Eye;
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => handleRoleSelect(role)}
|
||||
className={`dg-card p-5 cursor-pointer transition-all ${selectedRole?.id === role.id ? 'border-blue-500/50 bg-blue-500/5' : 'dg-card-hover'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${role.iconClass}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold truncate">{role.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] truncate">{role.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 权限配置 */}
|
||||
{selectedRole && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all">
|
||||
<Save className="w-4 h-4" /> 保存权限
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="space-y-2">
|
||||
{permissionTreeData.map(node => (
|
||||
<PermissionNodeComponent
|
||||
key={node.id}
|
||||
node={node}
|
||||
checkedKeys={checkedKeys}
|
||||
onToggle={togglePermission}
|
||||
onToggleChildren={toggleChildren}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionNodeComponent({
|
||||
node, checkedKeys, onToggle, onToggleChildren, depth
|
||||
}: {
|
||||
node: typeof permissionTreeData[0];
|
||||
checkedKeys: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onToggleChildren: (node: typeof permissionTreeData[0], checked: boolean) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isChecked = checkedKeys.has(node.id);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const allChildrenChecked = hasChildren && node.children!.every(c => checkedKeys.has(c.id));
|
||||
const someChildrenChecked = hasChildren && node.children!.some(c => checkedKeys.has(c.id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
|
||||
style={{ paddingLeft: `${depth * 20 + 12}px` }}
|
||||
onClick={() => { if (hasChildren) setExpanded(!expanded); }}
|
||||
>
|
||||
{hasChildren && (
|
||||
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
)}
|
||||
{!hasChildren && <div className="w-4" />}
|
||||
<div
|
||||
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${isChecked || allChildrenChecked ? 'bg-blue-500 border-blue-500' : someChildrenChecked ? 'bg-blue-500/50 border-blue-500' : 'border-[var(--dg-text-muted)]'}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
|
||||
else onToggle(node.id);
|
||||
}}
|
||||
>
|
||||
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-sm">{node.label}</span>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{node.children!.map(child => (
|
||||
<PermissionNodeComponent
|
||||
key={child.id}
|
||||
node={child}
|
||||
checkedKeys={checkedKeys}
|
||||
onToggle={onToggle}
|
||||
onToggleChildren={onToggleChildren}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw, Copy,
|
||||
ChevronRight, Shield, FileCode, History, CircleDot, AlertTriangle,
|
||||
Database, Key, Lock, Undo2, Info,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { tickets, statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
|
||||
import { useAuth, useActionPermission } from '@/hooks/useAuth';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import type { Ticket } from '@/types';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const { isDeveloper } = useAuth();
|
||||
const canCreate = useActionPermission('ticket.create');
|
||||
const canApprove = useActionPermission('ticket.approve');
|
||||
const canExecute = useActionPermission('ticket.execute');
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [showNewTicket, setShowNewTicket] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return tickets.filter(t => {
|
||||
if (statusFilter && t.status !== statusFilter) return false;
|
||||
if (dbTypeFilter && t.dbType !== dbTypeFilter) return false;
|
||||
if (priorityFilter && t.priority !== priorityFilter) return false;
|
||||
if (search && !t.title.includes(search) && !t.id.includes(search) && !t.applicant.includes(search)) return false;
|
||||
// developer 只能看到自己的工单
|
||||
if (isDeveloper && t.applicant !== '王五' && t.applicant !== 'developer') return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, statusFilter, dbTypeFilter, priorityFilter, isDeveloper]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
|
||||
return counts;
|
||||
}, []);
|
||||
|
||||
if (showNewTicket) {
|
||||
return <NewTicketForm onCancel={() => setShowNewTicket(false)} />;
|
||||
}
|
||||
|
||||
if (selectedTicket) {
|
||||
return <TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} canApprove={canApprove} canExecute={canExecute} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="数据变更工单"
|
||||
description="管理 SQL 变更工单,提交审核、查看审批进度"
|
||||
action={canCreate ? {
|
||||
label: '新建工单', icon: Plus, onClick: () => setShowNewTicket(true), permission: 'ticket.create'
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
{/* 状态快捷筛选 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[
|
||||
{ key: 'pending', label: '待审批', count: statusCounts.pending || 0, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
|
||||
{ key: 'reviewing', label: '审批中', count: statusCounts.reviewing || 0, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20' },
|
||||
{ key: 'approved', label: '已通过', count: statusCounts.approved || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
||||
{ key: 'executing', label: '执行中', count: statusCounts.executing || 0, color: 'text-purple-400 bg-purple-400/10 border-purple-400/20' },
|
||||
{ key: 'success', label: '成功', count: statusCounts.success || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
||||
{ key: 'failed', label: '失败', count: statusCounts.failed || 0, color: 'text-red-400 bg-red-400/10 border-red-400/20' },
|
||||
].map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setStatusFilter(statusFilter === s.key ? '' : s.key)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${statusFilter === s.key ? s.color : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'}`}
|
||||
>
|
||||
{s.label}
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${statusFilter === s.key ? 'bg-white/20' : 'bg-[var(--dg-bg-tertiary)]'}`}>{s.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索工单标题/编号/申请人" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
|
||||
<option value="">全部数据库</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="redis">Redis</option>
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)}>
|
||||
<option value="">全部优先级</option>
|
||||
<option value="high">高</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 工单表格 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">工单号</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">标题</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">实例</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">申请人</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">优先级</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">创建时间</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(ticket => (
|
||||
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
|
||||
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
|
||||
<td className="px-5 py-3.5"><span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span></td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.instance}</td>
|
||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>
|
||||
{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
|
||||
{statusMap[ticket.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setSelectedTicket(ticket)} className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
||||
{canApprove && ticket.status === 'pending' && <button className="text-green-400 hover:text-green-300 transition-colors"><CheckCircle className="w-4 h-4" /></button>}
|
||||
{canApprove && ticket.status === 'pending' && <button className="text-red-400 hover:text-red-300 transition-colors"><XCircle className="w-4 h-4" /></button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
|
||||
<div className="flex gap-1">
|
||||
{['上一页', '1', '2', '3', '下一页'].map((p, i) => (
|
||||
<button key={i} className={`px-3 py-1.5 rounded-md text-sm ${i === 1 ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTicketForm({ onCancel }: { onCancel: () => void }) {
|
||||
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
|
||||
const [sql, setSql] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-6 h-6" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></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 focus:ring-1 focus:ring-blue-400/20" placeholder="请输入工单标题,如:更新用户状态字段" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></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>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></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>生产 MySQL 主库</option><option>测试 MySQL 从库</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<button onClick={() => setSqlTab('change')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
|
||||
<FileCode className="w-3.5 h-3.5" /> 变更 SQL
|
||||
</button>
|
||||
<button onClick={() => setSqlTab('rollback')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> 回滚 SQL
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder={sqlTab === 'change' ? "-- 请输入 SQL 语句,支持多条 SQL,使用分号分隔\n\nUPDATE users SET status = 'active' WHERE id = 1;\nINSERT INTO logs (action, user_id) VALUES ('update', 1);" : "-- 请输入回滚 SQL\n\nUPDATE users SET status = (SELECT status FROM users_backup WHERE id = users.id) WHERE id = 1;"}
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5" />已输入 {sql.length} 个字符</span>
|
||||
<span>Ctrl + Enter 快速提交</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
|
||||
<textarea className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 placeholder:text-[var(--dg-text-muted)]" placeholder="请描述本次数据变更的原因和背景,便于审批人理解" />
|
||||
</div>
|
||||
|
||||
{/* SQL 审核预览 */}
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2"><Shield className="w-4 h-4 text-blue-400" /><span className="text-sm font-semibold">SQL 审核预览</span></div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/15 rounded-full">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" /><span className="text-sm font-semibold text-green-400">92 分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||
{[
|
||||
{ icon: XCircle, color: 'text-red-400 bg-red-400/10', title: '禁止无 WHERE 条件的 UPDATE/DELETE', desc: '检测到 UPDATE 语句缺少 WHERE 条件', suggestion: '建议: 添加 WHERE 条件限制影响范围' },
|
||||
{ icon: FileCode, color: 'text-yellow-400 bg-yellow-400/10', title: '大表变更警告', desc: '目标表 users 数据量超过 100 万行', suggestion: '建议: 考虑分批执行或使用 pt-online-schema-change' },
|
||||
{ icon: CheckCircle, color: 'text-green-400 bg-green-400/10', title: 'SQL 语法检查通过', desc: 'SQL 语法正确,可以正常解析' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-4">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${item.color}`}>
|
||||
<item.icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">{item.desc}</div>
|
||||
{item.suggestion && <div className="text-xs text-blue-400 mt-1">{item.suggestion}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={onCancel} className="px-5 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)] transition-colors">取消</button>
|
||||
<button 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
|
||||
ticket: Ticket; onBack: () => void; canApprove: boolean; canExecute: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
{ticket.title}
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>{statusMap[ticket.status]}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{ticket.id} · 由 {ticket.applicant} 创建于 {ticket.createTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canApprove && ticket.status === 'pending' && (
|
||||
<>
|
||||
<button className="flex items-center gap-2 px-4 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">
|
||||
<CheckCircle className="w-4 h-4" /> 通过
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all">
|
||||
<XCircle className="w-4 h-4" /> 拒绝
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canExecute && ticket.status === 'approved' && (
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all">
|
||||
<Play className="w-4 h-4" /> 执行
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审批流程 */}
|
||||
{ticket.workflowSteps && (
|
||||
<div className="dg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{ticket.workflowSteps.map((step, i) => (
|
||||
<div key={i} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium ${step.status === 'completed' ? 'bg-green-500' : step.status === 'active' ? 'bg-blue-500 ring-4 ring-blue-500/20' : 'bg-[var(--dg-bg-tertiary)] border-2 border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}>
|
||||
{step.status === 'completed' ? <CheckCircle className="w-5 h-5" /> : step.status === 'active' ? <CircleDot className="w-5 h-5" /> : <span className="text-xs">{i + 1}</span>}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)]">{step.time || '等待中'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{i < ticket.workflowSteps!.length - 1 && (
|
||||
<div className={`flex-1 h-[2px] mx-3 mb-6 ${step.status === 'completed' ? 'bg-green-500' : 'bg-[var(--dg-border)]'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* 工单信息 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">工单信息</CardTitle></CardHeader>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{[
|
||||
{ label: '工单号', value: ticket.id },
|
||||
{ label: '数据库类型', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span> },
|
||||
{ label: '目标实例', value: ticket.instance },
|
||||
{ label: '申请人', value: ticket.applicant },
|
||||
{ label: '优先级', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}</span> },
|
||||
{ label: '创建时间', value: ticket.createTime },
|
||||
{ label: '变更原因', value: ticket.reason },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SQL 内容 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><FileCode className="w-4 h-4 text-blue-400" /> SQL 内容</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.sql}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 回滚 SQL */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2"><Undo2 className="w-4 h-4 text-purple-400" /> 回滚 SQL</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Database className="w-3 h-3" /> Binlog ROW</span>
|
||||
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Key className="w-3 h-3" /> 有主键</span>
|
||||
<span className="px-2 py-0.5 bg-blue-500/15 text-blue-400 text-xs rounded">支持回滚</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{ticket.rollbackSql ? (
|
||||
<div>
|
||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.rollbackSql}</pre>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><Info className="w-3 h-3" /> 基于 Binlog ROW 格式解析生成</span>
|
||||
<span className="flex items-center gap-1"><Lock className="w-3 h-3" /> 备份库: backup_mysql_01</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-500/15 text-purple-400 text-xs rounded hover:bg-purple-500/25 transition-colors">
|
||||
<Copy className="w-3 h-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] mb-1">暂无回滚 SQL</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">工单执行后将自动生成基于 Binlog 的回滚语句</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 回滚限制说明 */}
|
||||
<div className="dg-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
<h3 className="text-sm font-semibold">回滚功能限制说明</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ icon: Database, title: 'Binlog 配置', status: '已满足', desc: 'MySQL 必须开启 Binlog 且格式为 ROW', color: 'text-green-400 bg-green-400/10' },
|
||||
{ icon: Key, title: '主键要求', status: '已满足', desc: '操作的表必须包含主键,用于精确定位行', color: 'text-green-400 bg-green-400/10' },
|
||||
{ icon: Lock, title: '权限要求', status: '已满足', desc: '账号需有 REPLICATION SLAVE/CLIENT 权限', color: 'text-green-400 bg-green-400/10' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className={`w-8 h-8 rounded-lg ${item.color} flex items-center justify-center flex-shrink-0`}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium flex items-center gap-2">{item.title}<span className={`text-[11px] ${item.color.split(' ')[0]}`}>{item.status}</span></div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-medium text-yellow-400">重要提示:</span>
|
||||
DDL操作(DROP/TRUNCATE)的回滚支持有限,仅能重建表结构无法恢复数据;
|
||||
DML和DDL不可混合执行;WHERE条件可能导致非唯一标识冲突,恢复时务必仔细核对。
|
||||
参考 Yearning 2.0 实现:AST语法解析 + Binlog逆向操作。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审批记录 */}
|
||||
{ticket.approvalRecords && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><History className="w-4 h-4 text-blue-400" /> 审批记录</CardTitle></CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="relative pl-6 space-y-6">
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
||||
{ticket.approvalRecords.map((record, i) => (
|
||||
<div key={i} className="relative">
|
||||
<div className={`absolute -left-6 top-0.5 w-5 h-5 rounded-full flex items-center justify-center ${record.action === 'approve' ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{record.action === 'approve' ? <CheckCircle className="w-3 h-3 text-white" /> : <XCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<div className="text-sm font-medium">{record.approver} {record.action === 'approve' ? '通过审批' : '拒绝审批'}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{record.time}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">{record.comment}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, Search, Edit2, Lock, Trash2, } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { users, roles } from '@/lib/mock';
|
||||
// type User is used implicitly
|
||||
|
||||
const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-purple-500/15 text-purple-400',
|
||||
dba: 'bg-blue-500/15 text-blue-400',
|
||||
developer: 'bg-green-500/15 text-green-400',
|
||||
guest: 'bg-gray-500/15 text-gray-400',
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (roleFilter && u.role !== roleFilter) return false;
|
||||
if (statusFilter && u.status !== statusFilter) return false;
|
||||
if (search && !u.username.includes(search) && !u.email.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, roleFilter, statusFilter]);
|
||||
|
||||
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">管理系统用户,分配角色和数据库访问权限</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 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">
|
||||
<Plus className="w-4 h-4" /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索用户名/邮箱" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
|
||||
<option value="">全部角色</option>
|
||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 用户表格 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">角色</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">部门</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">最后登录</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(user => (
|
||||
<tr key={user.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
||||
{user.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${(roleTagStyle as any)[user.role] || ''}`}>
|
||||
{user.roleName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.department}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.lastLogin}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.status === 'active' ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${user.status === 'active' ? 'translate-x-5.5' : 'translate-x-0.5'}`} style={{ transform: user.status === 'active' ? 'translateX(20px)' : 'translateX(2px)' }} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button className="text-purple-400 hover:text-purple-300 transition-colors"><Lock className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle,
|
||||
ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp,
|
||||
Save, Play
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { workflows as initialWorkflows, dbTypeColorMap } from '@/lib/mock';
|
||||
|
||||
type StepType = 'submit' | 'audit' | 'execute';
|
||||
|
||||
type FlowStep = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: StepType;
|
||||
assignees: string[];
|
||||
mode?: 'any' | 'all';
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type WorkflowDetail = {
|
||||
id: number;
|
||||
name: string;
|
||||
dbTypes: string[];
|
||||
steps: FlowStep[];
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
conditions?: string[];
|
||||
};
|
||||
|
||||
const stepTypeLabels: Record<StepType, string> = {
|
||||
submit: '提交阶段',
|
||||
audit: '审核阶段',
|
||||
execute: '执行阶段',
|
||||
};
|
||||
|
||||
const stepTypeColors: Record<StepType, string> = {
|
||||
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
audit: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
|
||||
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
};
|
||||
|
||||
const stepTypeIcons: Record<StepType, React.ElementType> = {
|
||||
submit: Users,
|
||||
audit: Shield,
|
||||
execute: Play,
|
||||
};
|
||||
|
||||
const defaultSteps: FlowStep[] = [
|
||||
{ id: 'step-1', name: '提交阶段', type: 'submit', assignees: ['工单提交人'], description: '申请人提交 SQL 变更工单' },
|
||||
{ id: 'step-2', name: '一级审批', type: 'audit', assignees: ['部门负责人'], mode: 'any', description: '部门负责人进行一级审批' },
|
||||
{ id: 'step-3', name: '二级审批', type: 'audit', assignees: ['DBA 团队'], mode: 'any', description: 'DBA 进行二级审批' },
|
||||
{ id: 'step-4', name: 'SQL 执行', type: 'execute', assignees: ['DBA 执行人'], description: '审批通过后执行 SQL 变更' },
|
||||
];
|
||||
|
||||
export default function WorkflowPage() {
|
||||
const [items, setItems] = useState<WorkflowDetail[]>(
|
||||
initialWorkflows.map(w => ({
|
||||
...w,
|
||||
steps: defaultSteps.slice(0, Math.min(w.steps + 2, defaultSteps.length)),
|
||||
}))
|
||||
);
|
||||
const [previewWorkflow, setPreviewWorkflow] = useState<WorkflowDetail | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDetail | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
// 新建/编辑表单状态
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formDbTypes, setFormDbTypes] = useState<string[]>(['mysql']);
|
||||
const [formSteps, setFormSteps] = useState<FlowStep[]>([defaultSteps[0]]);
|
||||
const [formConditions, setFormConditions] = useState('');
|
||||
|
||||
const dbTypes = ['mysql', 'postgres', 'redis', 'mongo'];
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName('');
|
||||
setFormDesc('');
|
||||
setFormDbTypes(['mysql']);
|
||||
setFormSteps([{ ...defaultSteps[0] }]);
|
||||
setFormConditions('');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm();
|
||||
setEditingWorkflow(null);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (wf: WorkflowDetail) => {
|
||||
setFormName(wf.name);
|
||||
setFormDesc(wf.description);
|
||||
setFormDbTypes(wf.dbTypes);
|
||||
setFormSteps([...wf.steps]);
|
||||
setFormConditions(wf.conditions?.join(', ') || '');
|
||||
setEditingWorkflow(wf);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const toggleWorkflow = (id: number) => {
|
||||
setItems(prev => prev.map(w => w.id === id ? { ...w, enabled: !w.enabled } : w));
|
||||
};
|
||||
|
||||
const deleteWorkflow = (id: number) => {
|
||||
setItems(prev => prev.filter(w => w.id !== id));
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
const types: StepType[] = ['audit', 'execute'];
|
||||
const nextType = types[Math.min(formSteps.length - 1, types.length - 1)];
|
||||
setFormSteps(prev => [...prev, {
|
||||
id: `step-${Date.now()}`,
|
||||
name: nextType === 'audit' ? `第 ${prev.filter(s => s.type === 'audit').length + 1} 级审批` : '执行阶段',
|
||||
type: nextType,
|
||||
assignees: nextType === 'audit' ? ['审批人'] : ['DBA 执行人'],
|
||||
mode: 'any',
|
||||
description: '',
|
||||
}]);
|
||||
};
|
||||
|
||||
const removeStep = (stepId: string) => {
|
||||
if (formSteps.length <= 1) return;
|
||||
setFormSteps(prev => prev.filter(s => s.id !== stepId));
|
||||
};
|
||||
|
||||
const updateStep = (stepId: string, updates: Partial<FlowStep>) => {
|
||||
setFormSteps(prev => prev.map(s => s.id === stepId ? { ...s, ...updates } : s));
|
||||
};
|
||||
|
||||
const moveStep = (index: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && index === 0) return;
|
||||
if (direction === 'down' && index === formSteps.length - 1) return;
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
setFormSteps(prev => {
|
||||
const arr = [...prev];
|
||||
[arr[index], arr[newIndex]] = [arr[newIndex], arr[index]];
|
||||
return arr;
|
||||
});
|
||||
};
|
||||
|
||||
const saveWorkflow = () => {
|
||||
if (!formName.trim()) return;
|
||||
const conditions = formConditions ? formConditions.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
if (editingWorkflow) {
|
||||
setItems(prev => prev.map(w => w.id === editingWorkflow.id ? {
|
||||
...w,
|
||||
name: formName,
|
||||
description: formDesc,
|
||||
dbTypes: formDbTypes,
|
||||
steps: [...formSteps],
|
||||
conditions: conditions.length > 0 ? conditions : undefined,
|
||||
} : w));
|
||||
} else {
|
||||
const newWf: WorkflowDetail = {
|
||||
id: Date.now(),
|
||||
name: formName,
|
||||
description: formDesc,
|
||||
dbTypes: formDbTypes,
|
||||
steps: [...formSteps],
|
||||
enabled: true,
|
||||
conditions: conditions.length > 0 ? conditions : undefined,
|
||||
};
|
||||
setItems(prev => [...prev, newWf]);
|
||||
}
|
||||
setShowCreateModal(false);
|
||||
};
|
||||
|
||||
const toggleDbType = (dt: string) => {
|
||||
setFormDbTypes(prev =>
|
||||
prev.includes(dt) ? prev.filter(d => d !== dt) : [...prev, dt]
|
||||
);
|
||||
};
|
||||
|
||||
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">配置多级审批流程模板,支持提交阶段、审核阶段、执行阶段的自定义配置</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 px-4 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"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> 新建流程
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已启用', value: items.filter(w => w.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '多级审批', value: items.filter(w => w.steps.filter(s => s.type === 'audit').length > 1).length, icon: Users, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ label: '单级审批', value: items.filter(w => w.steps.filter(s => s.type === 'audit').length === 1).length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-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="space-y-4">
|
||||
{items.map(wf => {
|
||||
const isExpanded = expandedId === wf.id;
|
||||
return (
|
||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.enabled ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${wf.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{wf.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-xs rounded">
|
||||
{wf.steps.length} 个阶段
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{wf.description}</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">适用数据库:</span>
|
||||
{wf.dbTypes.map(dt => (
|
||||
<span key={dt} className={`inline-flex px-2 py-0.5 rounded text-[11px] font-medium ${dbTypeColorMap[dt] || ''}`}>{dt.toUpperCase()}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 流程可视化 */}
|
||||
<div className="flex items-center gap-1 mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg overflow-x-auto">
|
||||
{wf.steps.map((step, i) => {
|
||||
const StepIcon = stepTypeIcons[step.type];
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className={`px-3 py-2 rounded-lg border ${stepTypeColors[step.type]} flex items-center gap-2`}>
|
||||
<StepIcon className="w-4 h-4" />
|
||||
<div>
|
||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
||||
<div className="text-[10px] opacity-80 whitespace-nowrap">{step.assignees.join(' / ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{i < wf.steps.length - 1 && <ArrowRight className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3 pt-4 border-t border-[var(--dg-border)]">
|
||||
<h4 className="text-sm font-semibold">流程步骤详情</h4>
|
||||
{wf.steps.map((step) => {
|
||||
const StepIcon = stepTypeIcons[step.type];
|
||||
return (
|
||||
<div key={step.id} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${stepTypeColors[step.type].split(' ')[0]}`}>
|
||||
<StepIcon className={`w-4 h-4 ${stepTypeColors[step.type].split(' ')[1]}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">{step.name}</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded ${stepTypeColors[step.type]}`}>{stepTypeLabels[step.type]}</span>
|
||||
{step.mode && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
||||
{step.mode === 'any' ? '或签(任一人)' : '会签(全部人)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">{step.description}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">执行人:</span>
|
||||
{step.assignees.map((a, j) => (
|
||||
<span key={j} className="text-xs text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded">{a}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{wf.conditions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">触发条件:</span>
|
||||
{wf.conditions.map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<button onClick={() => setPreviewWorkflow(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" title="预览">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" title="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => toggleWorkflow(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={wf.enabled ? '禁用' : '启用'}>
|
||||
{wf.enabled ? <ToggleRight className="w-5 h-5 text-green-400" /> : <ToggleLeft className="w-5 h-5 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
<button onClick={() => deleteWorkflow(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
{previewWorkflow && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewWorkflow(null)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold">{previewWorkflow.name} - 流程预览</h3>
|
||||
<button onClick={() => setPreviewWorkflow(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{previewWorkflow.steps.map((step, i) => {
|
||||
const StepIcon = stepTypeIcons[step.type];
|
||||
return (
|
||||
<div key={step.id} className="flex items-start gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${stepTypeColors[step.type].split(' ')[0]}`}>
|
||||
<StepIcon className={`w-5 h-5 ${stepTypeColors[step.type].split(' ')[1]}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{step.name}</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded ${stepTypeColors[step.type]}`}>{stepTypeLabels[step.type]}</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">{step.description || '该阶段暂无详细描述'}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">负责人:</span>
|
||||
{step.assignees.map((a, j) => (
|
||||
<span key={j} className="text-xs text-blue-400">{a}{j < step.assignees.length - 1 ? ' / ' : ''}</span>
|
||||
))}
|
||||
{step.mode && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] ml-2">
|
||||
{step.mode === 'any' ? '或签' : '会签'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<button onClick={() => setPreviewWorkflow(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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建/编辑流程弹窗 */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateModal(false)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold">{editingWorkflow ? '编辑流程' : '新建流程'}</h3>
|
||||
<button onClick={() => setShowCreateModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-[var(--dg-text-secondary)] uppercase tracking-wider">基本信息</h4>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">流程名称 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="例如:钱包平台 SQL 变更流程"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">流程描述</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={e => setFormDesc(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none"
|
||||
rows={2}
|
||||
placeholder="描述该流程的用途和适用范围"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">适用数据库</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dbTypes.map(dt => (
|
||||
<button
|
||||
key={dt}
|
||||
onClick={() => toggleDbType(dt)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
formDbTypes.includes(dt)
|
||||
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)] hover:border-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{dt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">触发条件(用逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formConditions}
|
||||
onChange={e => setFormConditions(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="例如:影响行数>1000, 执行时间>1s"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步骤配置 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-[var(--dg-text-secondary)] uppercase tracking-wider">流程步骤</h4>
|
||||
<button
|
||||
onClick={addStep}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-500/15 text-blue-400 rounded-lg text-xs hover:bg-blue-500/25 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> 添加步骤
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formSteps.map((step, index) => (
|
||||
<Card key={step.id} className="dg-card border-l-2 border-l-blue-500">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<button onClick={() => moveStep(index, 'up')} disabled={index === 0} className="w-6 h-6 rounded flex items-center justify-center hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-colors">
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => moveStep(index, 'down')} disabled={index === formSteps.length - 1} className="w-6 h-6 rounded flex items-center justify-center hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-colors">
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">步骤名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.name}
|
||||
onChange={e => updateStep(step.id, { name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">步骤类型</label>
|
||||
<select
|
||||
value={step.type}
|
||||
onChange={e => updateStep(step.id, { type: e.target.value as StepType })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="submit">提交阶段</option>
|
||||
<option value="audit">审核阶段</option>
|
||||
<option value="execute">执行阶段</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">负责人(用/分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.assignees.join(' / ')}
|
||||
onChange={e => updateStep(step.id, { assignees: e.target.value.split('/').map(s => s.trim()).filter(Boolean) })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="例如:张三 / 李四"
|
||||
/>
|
||||
</div>
|
||||
{step.type === 'audit' && (
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">审批模式</label>
|
||||
<select
|
||||
value={step.mode || 'any'}
|
||||
onChange={e => updateStep(step.id, { mode: e.target.value as 'any' | 'all' })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="any">或签(任一人通过)</option>
|
||||
<option value="all">会签(全部人通过)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">步骤描述</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.description || ''}
|
||||
onChange={e => updateStep(step.id, { description: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="描述该步骤的具体操作..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formSteps.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/15 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={() => setShowCreateModal(false)} 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
|
||||
onClick={saveWorkflow}
|
||||
disabled={!formName.trim() || formDbTypes.length === 0}
|
||||
className="flex items-center gap-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {editingWorkflow ? '保存修改' : '创建流程'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user