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

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

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

P2组件: useToast/useFetchList+ConfirmDialog/FormDialog

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

P4API: NotificationsPage/WorkflowPage重写+RolesPage演示

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

No files matched your search

+4 -38
View File
@@ -76,37 +76,6 @@ const TARGET_ICONS: Record<string, React.ElementType> = {
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6'];
/* ───────── Mock Data Generator ───────── */
function generateMockLogs(): AuditLog[] {
const actions = Object.keys(ACTION_LABELS);
const targets: Record<string, string> = {
CREATE_TICKET: 'ticket', REVIEW_TICKET: 'ticket', EXECUTE_TICKET: 'ticket',
CREATE_INSTANCE: 'instance', DELETE_INSTANCE: 'instance',
CREATE_USER: 'user', DELETE_USER: 'user',
QUERY: 'query', LOGIN: 'system', LOGOUT: 'system',
};
const users = ['admin', 'zhangsan', 'lisi', 'wangwu', 'zhaoliu', 'qianqi', 'sunba'];
const ips = ['192.168.10.50', '192.168.10.51', '192.168.10.52', '10.0.0.15', '10.0.0.22'];
return Array.from({ length: 45 }, (_, i) => {
const action = actions[i % actions.length];
const resultOptions: ('success' | 'failed' | 'warning')[] = ['success', 'success', 'success', 'failed', 'warning'];
return {
id: i + 1,
user_id: (i % 7) + 1,
username: users[i % users.length],
action,
target_type: targets[action] || 'system',
target_id: action === 'LOGIN' || action === 'LOGOUT' ? null : i + 100,
detail: `${ACTION_LABELS[action]}操作,目标ID: ${i + 100}`,
ip_address: ips[i % ips.length],
result_status: resultOptions[i % resultOptions.length],
created_at: new Date(Date.now() - i * 3600000 * 3).toISOString(),
};
});
}
/* ───────── CSV Export Helper ───────── */
function exportLogsToCSV(logs: AuditLog[]) {
@@ -156,15 +125,12 @@ export default function AuditLogsPage() {
if (filterAction) params.set('action', filterAction);
const res = await request.get(`/audit/logs?${params}`);
const data = (res?.data ?? res ?? {}) as AuditLogResponse;
// Merge with mock data for enhanced fields
const mockData = generateMockLogs();
const items = data.items || mockData;
const items = data.items || [];
setLogs(items);
setTotal(data.total || items.length);
setTotal(data.total || 0);
} catch {
const mockData = generateMockLogs();
setLogs(mockData);
setTotal(mockData.length);
setLogs([]);
setTotal(0);
} finally {
setLoading(false);
}
+3 -2
View File
@@ -7,7 +7,8 @@ import {
Filter, Eye, BarChart3,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
import type { DBInstance } from '@/types';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { dashboardApi } from '@/api/dashboard';
@@ -64,7 +65,7 @@ const trendDatasets: Record<TrendRange, TrendDataset> = {
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function calcHealthScore(inst: typeof dbInstances[0]): number {
function calcHealthScore(inst: DBInstance): number {
if (inst.status === 'offline') return 0;
let score = 100;
if (inst.cpu > 80) score -= 25;
+1 -23
View File
@@ -97,28 +97,6 @@ function highlightDDL(ddl: string): string {
return highlighted;
}
function generateMockRelations(tableName: string): TableRelation[] {
const relations: TableRelation[] = [];
if (tableName.includes('order')) {
relations.push(
{ from_table: tableName, from_column: 'user_id', to_table: 'users', to_column: 'id', constraint_name: `${tableName}_user_id_fk` },
{ from_table: tableName, from_column: 'product_id', to_table: 'products', to_column: 'id', constraint_name: `${tableName}_product_id_fk` },
);
}
if (tableName.includes('user')) {
relations.push(
{ from_table: 'orders', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `orders_${tableName}_user_id_fk` },
{ from_table: 'comments', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `comments_${tableName}_user_id_fk` },
);
}
if (tableName.includes('comment')) {
relations.push(
{ from_table: tableName, from_column: 'post_id', to_table: 'posts', to_column: 'id', constraint_name: `${tableName}_post_id_fk` },
);
}
return relations;
}
/* ───────── Tree Component ───────── */
function DbTreeNode({
@@ -265,7 +243,7 @@ export default function DataDictionaryPage() {
const relations = useMemo(() => {
if (!selectedTable) return [];
return generateMockRelations(selectedTable);
return [];
}, [selectedTable]);
const handleExport = () => {
+1 -20
View File
@@ -103,25 +103,6 @@ const DEFAULT_QUOTA: ResourceQuota = {
max_queries_per_day: 10000,
};
/* ───────── Mock Change Logs ───────── */
function generateMockChangeLogs(groupId: number): ChangeLogEntry[] {
const actions = [
{ action: '修改权限配置', target: 'query:select, query:insert' },
{ action: '添加关联实例', target: '生产主库' },
{ action: '移除关联用户', target: 'wangwu' },
{ action: '更新资源配额', target: 'max_qps: 1000 -> 2000' },
{ action: '启用权限组', target: '-' },
];
return actions.map((a, i) => ({
id: `cl-${groupId}-${i}`,
user: ['admin', 'zhangsan', 'lisi'][i % 3],
action: a.action,
target: a.target,
created_at: new Date(Date.now() - i * 86400000).toISOString(),
}));
}
/* ───────── Main Component ───────── */
export default function InstanceGroupPage() {
@@ -196,7 +177,7 @@ export default function InstanceGroupPage() {
const changeLogs = useMemo(() => {
if (!selectedId) return [];
return generateMockChangeLogs(selectedId);
return [];
}, [selectedId]);
const resetForm = () => {
+1 -11
View File
@@ -154,17 +154,7 @@ export default function InstancePage() {
try {
const res = await instanceApi.list({});
const data = Array.isArray(res) ? res : [];
/* Enrich with mock metrics for display */
const enriched = data.map((inst: InstanceItem, idx: number) => ({
...inst,
connections: [245, 189, 192, 45, 23, 0, 156, 89, 1200, 1150, 340][idx] ?? 100,
qps: [3250, 2800, 2750, 120, 80, 0, 2100, 980, 45000, 42000, 1800][idx] ?? 500,
latency: [2.5, 3.1, 2.8, 15.2, 1.2, 0, 1.8, 8.5, 0.5, 0.6, 3.2][idx] ?? 5,
cpu: [45, 38, 40, 82, 15, 0, 35, 78, 25, 28, 42][idx] ?? 30,
memory: [68, 55, 58, 90, 30, 0, 60, 85, 75, 72, 65][idx] ?? 50,
disk: [72, 65, 68, 45, 25, 88, 55, 70, 10, 10, 60][idx] ?? 50,
}));
setInstances(enriched);
setInstances(data);
} catch {
/* keep existing */
} finally {
+160 -146
View File
@@ -1,165 +1,179 @@
import { useState } from 'react';
import { Send, Bell, MessageSquare, Navigation, Mail, Check, ToggleLeft, ToggleRight, Webhook, Settings, TestTube } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { Bell, Webhook, Check, Settings, TestTube, ToggleLeft, ToggleRight, Loader2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { notificationChannels } from '@/lib/mock';
import { notificationApi } from '@/api/notification';
const iconMap: Record<string, React.ElementType> = {
Send, Bell, MessageSquare, Navigation, Mail,
interface NotificationChannel {
id: number;
name: string;
channel_type: string;
config: Record<string, any>;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
const channelStyle: Record<string, { bg: string; color: string; label: string }> = {
webhook_feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]', label: '飞书' },
dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]', label: '钉钉' },
email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]', label: '邮件' },
};
const channelStyle: Record<string, { bg: string; color: string }> = {
feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]' },
dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]' },
wechat: { bg: 'bg-[rgba(7,193,96,0.15)]', color: 'text-[#07c160]' },
telegram: { bg: 'bg-[rgba(0,136,204,0.15)]', color: 'text-[#0088cc]' },
email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]' },
};
const allEvents = [
{ key: 'ticket_created', label: '工单创建' },
{ key: 'ticket_approved', label: '审批通过' },
{ key: 'ticket_rejected', label: '审批拒绝' },
{ key: 'ticket_executed', label: '执行完成' },
{ key: 'ticket_failed', label: '执行失败' },
{ key: 'ticket_rollback', label: '工单回滚' },
];
export default function NotificationsPage() {
const [channels, setChannels] = useState(notificationChannels);
const [editChannel, setEditChannel] = useState<typeof notificationChannels[0] | null>(null);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ id: number; success: boolean; message: string } | null>(null);
const toggleChannel = (id: number) => {
setChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c));
};
const fetchChannels = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await notificationApi.list();
setChannels(data);
} catch (err: any) {
setError(err?.response?.data?.detail || '获取通知渠道失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const toggleChannel = useCallback(async (id: number) => {
const ch = channels.find(c => c.id === id);
if (!ch) return;
try {
await notificationApi.update(id, { is_enabled: !ch.is_enabled });
setChannels(prev => prev.map(c => c.id === id ? { ...c, is_enabled: !c.is_enabled } : c));
} catch {}
}, [channels]);
const handleTest = useCallback(async (id: number) => {
setTestingId(id);
setTestResult(null);
try {
const res = await notificationApi.test(id);
setTestResult({ id, success: res.success, message: res.message });
} catch (err: any) {
setTestResult({ id, success: false, message: err?.response?.data?.detail || '测试失败' });
} finally {
setTestingId(null);
setTimeout(() => setTestResult(null), 5000);
}
}, []);
const handleDelete = useCallback(async (id: number) => {
if (!confirm('确认删除该通知渠道?')) return;
try {
await notificationApi.delete(id);
setChannels(prev => prev.filter(c => c.id !== id));
} catch {}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<AlertCircle className="w-12 h-12 mb-3 text-red-400" />
<p className="text-sm">{error}</p>
<button onClick={fetchChannels} className="mt-3 px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors">
重试
</button>
</div>
);
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">通知渠道配置</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、企业微信、Telegram</p>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、邮件</p>
</div>
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all">
<Settings className="w-4 h-4" /> 全局设置
</button>
</div>
<div className="grid grid-cols-5 gap-4">
{[
{ label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: channels.filter(c => c.enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '未启用', value: channels.filter(c => !c.enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-2 gap-4">
{channels.map(channel => {
const Icon = iconMap[channel.icon] || Bell;
const style = channelStyle[channel.type] || channelStyle.email;
return (
<div
key={channel.id}
className={`dg-card p-5 cursor-pointer transition-all ${channel.enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}
onClick={() => setEditChannel(channel)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
<Icon className={`w-6 h-6 ${style.color}`} />
</div>
<div>
<div className="text-base font-semibold">{channel.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{channel.description}</div>
{channel.enabled && channel.events && (
<div className="flex flex-wrap gap-1 mt-2">
{channel.events.slice(0, 3).map(e => (
<span key={e} className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[10px] rounded">{allEvents.find(a => a.key === e)?.label || e}</span>
))}
{channel.events.length > 3 && <span className="px-1.5 py-0.5 text-[var(--dg-text-muted)] text-[10px]">+{channel.events.length - 3}</span>}
</div>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${channel.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
{channel.enabled ? '已启用' : '未启用'}
</span>
<button
onClick={e => { e.stopPropagation(); toggleChannel(channel.id); }}
className="transition-colors"
>
{channel.enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
</button>
</div>
</div>
</div>
);
})}
</div>
{/* 编辑弹窗 */}
{editChannel && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setEditChannel(null)}>
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="text-xl font-semibold mb-5">配置 {editChannel.name}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Webhook URL</label>
<input
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
defaultValue={editChannel.webhook || ''}
placeholder={`请输入 ${editChannel.name} Webhook 地址`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">密钥 (Secret)</label>
<input
type="password"
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
placeholder="请输入密钥"
/>
</div>
<div>
<label className="block text-sm font-medium mb-3">通知事件</label>
<div className="grid grid-cols-2 gap-2">
{allEvents.map(evt => (
<label key={evt.key} className="flex items-center gap-2 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] cursor-pointer hover:border-[var(--dg-text-muted)] transition-colors">
<input type="checkbox" defaultChecked={editChannel.events?.includes(evt.key)} className="w-4 h-4 rounded border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-blue-500 focus:ring-blue-500/20" />
<span className="text-sm">{evt.label}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">@ 提醒用户</label>
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
<option>审批人</option>
<option>申请人</option>
<option>执行人</option>
<option>全部相关人员</option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
取消
</button>
<button className="flex items-center gap-2 px-5 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
<TestTube className="w-4 h-4" /> 测试
</button>
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
保存
</button>
</div>
{channels.length === 0 ? (
<div className="dg-card p-12 text-center">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
<p className="text-sm text-[var(--dg-text-muted)]">暂无通知渠道,请联系管理员配置</p>
</div>
) : (
<>
<div className="grid grid-cols-5 gap-4">
{[
{ label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: channels.filter(c => c.is_enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '未启用', value: channels.filter(c => !c.is_enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{channels.map(channel => {
const style = channelStyle[channel.channel_type] || { bg: 'bg-gray-400/10', color: 'text-gray-400', label: channel.channel_type };
return (
<div key={channel.id} className={`dg-card p-5 transition-all ${channel.is_enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
<Webhook className={`w-6 h-6 ${style.color}`} />
</div>
<div>
<div className="text-base font-semibold">{channel.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{style.label}
{channel.config?.webhook_url && <span className="ml-2 opacity-60">{channel.config.webhook_url.substring(0, 40)}...</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(channel.id)}
disabled={testingId === channel.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors disabled:opacity-50"
title="测试"
>
{testingId === channel.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <TestTube className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(channel.id)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
<button onClick={() => toggleChannel(channel.id)} className="transition-colors">
{channel.is_enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
</button>
</div>
</div>
{testResult && testResult.id === channel.id && (
<div className={`mt-3 px-3 py-2 rounded-lg text-xs ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.message}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
);
+209
View File
@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react';
import api from '../api/request';
/* ── 类型 ── */
interface SettingItem {
key: string;
value: string;
value_type: string;
group: string;
label: string;
description: string | null;
default_value: string | null;
sort_order: number;
}
type SettingsMap = Record<string, SettingItem[]>;
/* ── 分组元信息 ── */
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
query: { label: '查询控制', icon: '🔍', desc: '查询权限、LIMIT 上限、超时设置' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略、审计日志' },
ticket: { label: '工单流程', icon: '📋', desc: '审批流程、回滚控制' },
};
export default function PlatformConfigPage() {
const [settings, setSettings] = useState<SettingsMap>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [changed, setChanged] = useState<Record<string, string>>({});
const [toast, setToast] = useState('');
const fetchSettings = useCallback(async () => {
try {
const res = await api.get('/settings');
setSettings(res as SettingsMap);
} catch {
setToast('加载设置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
const handleChange = (key: string, value: string) => {
setChanged(prev => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
if (Object.keys(changed).length === 0) return;
setSaving(true);
try {
const items = Object.entries(changed).map(([key, value]) => ({ key, value }));
await api.put('/settings', items);
setChanged({});
setToast('保存成功');
await fetchSettings();
} catch (e: any) {
setToast(e?.response?.data?.detail || '保存失败');
} finally {
setSaving(false);
setTimeout(() => setToast(''), 3000);
}
};
const handleReset = (key: string, defaultValue: string | null) => {
if (defaultValue) handleChange(key, defaultValue);
};
const displayValue = (item: SettingItem) => changed[item.key] ?? item.value;
if (loading) {
return <div className="text-center py-20 text-[var(--dg-text-muted)]">加载中...</div>;
}
const groupKeys = Object.keys(settings);
const hasChanges = Object.keys(changed).length > 0;
return (
<div className="space-y-6">
{/* Toast */}
{toast && (
<div className={`fixed top-6 right-6 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg ${
toast === '保存成功'
? 'bg-emerald-500/90 text-white'
: 'bg-red-500/90 text-white'
}`}>
{toast}
</div>
)}
{/* 各分组 */}
{groupKeys.map(group => {
const meta = GROUP_META[group] || { label: group, icon: '⚙️', desc: '' };
const items = settings[group] || [];
return (
<div key={group} className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
{/* 分组头 */}
<div className="px-5 py-3 flex items-center gap-3">
<span className="text-lg">{meta.icon}</span>
<div>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">{meta.label}</h3>
<p className="text-xs text-[var(--dg-text-muted)]">{meta.desc}</p>
</div>
</div>
{/* 设置项列表 */}
<div className="px-5 pb-4 space-y-3">
{items.map(item => {
const val = displayValue(item);
const isChanged = changed[item.key] !== undefined;
return (
<div key={item.key} className="flex items-center justify-between gap-4 py-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--dg-text-primary)]">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.description}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* 重置按钮 */}
{isChanged && item.default_value && (
<button
onClick={() => handleReset(item.key, item.default_value)}
className="text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]"
title="恢复默认值"
>
↩
</button>
)}
{/* 布尔 → 开关 */}
{item.value_type === 'boolean' && (
<button
onClick={() => handleChange(item.key, val === 'true' ? 'false' : 'true')}
className={`relative w-10 h-[22px] rounded-full transition-colors ${
val === 'true' ? 'bg-emerald-500' : 'bg-[var(--dg-bg-tertiary)]'
}`}
>
<span className={`absolute top-[2px] w-[18px] h-[18px] bg-white rounded-full shadow transition-transform ${
val === 'true' ? 'translate-x-[20px]' : 'translate-x-[2px]'
}`} />
</button>
)}
{/* 整数 → 数字输入 */}
{item.value_type === 'integer' && (
<input
type="number"
value={parseInt(val) || 0}
onChange={e => handleChange(item.key, e.target.value)}
className={`w-24 px-2 py-1 rounded-lg text-sm text-right bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] outline-none ${
isChanged ? 'ring-1 ring-emerald-500/50' : ''
}`}
/>
)}
{/* 字符串 → 文本 */}
{item.value_type === 'string' && (
<input
type="text"
value={val}
onChange={e => handleChange(item.key, e.target.value)}
className={`w-48 px-2 py-1 rounded-lg text-sm bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] outline-none ${
isChanged ? 'ring-1 ring-emerald-500/50' : ''
}`}
/>
)}
{/* 变更指示 */}
{isChanged && (
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
{/* 保存按钮 */}
{hasChanges && (
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-600 text-white disabled:opacity-50 transition-colors"
>
{saving ? '保存中...' : '保存更改'}
</button>
<button
onClick={() => setChanged({})}
className="px-4 py-2 rounded-lg text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]"
>
取消
</button>
<span className="text-xs text-[var(--dg-text-muted)]">
已修改 {Object.keys(changed).length} 项
</span>
</div>
)}
</div>
);
}
+175 -342
View File
@@ -1,15 +1,25 @@
import { useState, useMemo, useCallback } from 'react';
import {
Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save,
Search, X, Copy, GitCompare, Users, Lock, BarChart3, XCircle,
Crown, Database, Code, Eye, ChevronRight, Check, Save,
Search, X, Copy, Users, Lock, BarChart3, XCircle,
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { roles as mockRoles, permissionTreeData, users as mockUsers } from '@/lib/mock';
import type { Role } from '@/types';
import { permissionTreeData } from '@/lib/mock';
/* ───────── Types ───────── */
interface Role {
id: string;
name: string;
description: string;
icon: string;
iconClass: string;
permissions: string[];
userCount: number;
permissionCount: number;
}
interface PermissionChange {
id: string;
label: string;
@@ -86,38 +96,14 @@ function searchTree(nodes: typeof permissionTreeData, query: string): typeof per
}, []);
}
function computeChanges(original: Set<string>, current: Set<string>): PermissionChange[] {
const allIds = collectAllIds(permissionTreeData);
const idToLabel: Record<string, string> = {};
const buildLabelMap = (nodes: typeof permissionTreeData) => {
nodes.forEach(n => {
idToLabel[n.id] = n.label;
if (n.children) buildLabelMap(n.children);
});
};
buildLabelMap(permissionTreeData);
const changes: PermissionChange[] = [];
allIds.forEach(id => {
if (current.has(id) && !original.has(id)) {
changes.push({ id, label: idToLabel[id] || id, action: 'added' });
} else if (!current.has(id) && original.has(id)) {
changes.push({ id, label: idToLabel[id] || id, action: 'removed' });
}
});
return changes;
}
/* ───────── Main Component ───────── */
export default function RolesPage() {
const [selectedRole, setSelectedRole] = useState<Role | null>(mockRoles[0]);
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [showCloneModal, setShowCloneModal] = useState<Role | null>(null);
const [showStats, setShowStats] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const hasChanges = useMemo(() => {
@@ -125,18 +111,29 @@ export default function RolesPage() {
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
}, [checkedKeys, originalKeys]);
const changes = useMemo(() => computeChanges(originalKeys, checkedKeys), [originalKeys, checkedKeys]);
const changes = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
const idToLabel: Record<string, string> = {};
const buildLabelMap = (nodes: typeof permissionTreeData) => {
nodes.forEach(n => {
idToLabel[n.id] = n.label;
if (n.children) buildLabelMap(n.children);
});
};
buildLabelMap(permissionTreeData);
const result: PermissionChange[] = [];
allIds.forEach(id => {
if (checkedKeys.has(id) && !originalKeys.has(id)) {
result.push({ id, label: idToLabel[id] || id, action: 'added' });
} else if (!checkedKeys.has(id) && originalKeys.has(id)) {
result.push({ id, label: idToLabel[id] || id, action: 'removed' });
}
});
return result;
}, [originalKeys, checkedKeys]);
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
const handleRoleSelect = useCallback((role: Role) => {
setSelectedRole(role);
const newSet = new Set(role.permissions);
setCheckedKeys(newSet);
setOriginalKeys(newSet);
setShowDiff(false);
}, []);
const togglePermission = useCallback((id: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
@@ -161,15 +158,15 @@ export default function RolesPage() {
const applyPreset = (preset: PermissionPreset) => {
setCheckedKeys(new Set(preset.permissions));
setOriginalKeys(new Set(preset.permissions));
setShowPresets(false);
setToast({ message: `已应用「${preset.name}」`, type: 'success' });
setToast({ message: `已应用「${preset.name}」预设`, type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleSave = () => {
if (!selectedRole) return;
setOriginalKeys(new Set(checkedKeys));
setToast({ message: '权限已保存', type: 'success' });
setToast({ message: '权限配置已保存(演示模式)', type: 'success' });
setTimeout(() => setToast(null), 2000);
};
@@ -180,19 +177,6 @@ export default function RolesPage() {
setTimeout(() => setToast(null), 2000);
};
// Role statistics
const roleStats = useMemo(() => {
return mockRoles.map(role => {
const roleUsers = mockUsers.filter(u => u.role === role.id);
return {
role,
userCount: roleUsers.length,
permissionCount: role.permissions.length,
onlineUsers: roleUsers.filter(u => u.status === 'active').length,
};
});
}, []);
return (
<div className="space-y-5">
{/* Toast */}
@@ -212,258 +196,158 @@ export default function RolesPage() {
<h1 className="text-2xl font-semibold">角色权限管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowStats(!showStats)}
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 text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<BarChart3 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">
<Shield className="w-4 h-4" /> 新建角色
</button>
</div>
</div>
{/* Stats Panel */}
{showStats && (
<div className="grid grid-cols-4 gap-4">
{roleStats.map(({ role, userCount, permissionCount, onlineUsers }) => {
const Icon = iconMap[role.icon] || Eye;
return (
<Card key={role.id} className="dg-card">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${role.iconClass}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="text-sm font-semibold">{role.name}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{role.userCount} 用户</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-blue-400">{userCount}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">用户</div>
</div>
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-purple-400">{permissionCount}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">权限</div>
</div>
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-green-400">{onlineUsers}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">在线</div>
</div>
</div>
</CardContent>
</Card>
);
})}
{/* Empty state info */}
<div className="dg-card p-5 flex items-start gap-3 bg-blue-500/5 border-blue-500/20">
<Users className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-[var(--dg-text-secondary)]">
角色数据暂未接入后端 API,当前为权限配置演示模式。您可以使用下方的权限树和预设模板来规划角色权限方案。
</p>
</div>
)}
{/* Role Cards */}
<div className="grid grid-cols-4 gap-4">
{mockRoles.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)]">
<Users className="w-3 h-3 inline mr-1" />
<span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户
</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
<Lock className="w-3 h-3 inline mr-1" />
<span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限
</div>
</div>
{/* Clone button */}
<button
onClick={(e) => { e.stopPropagation(); setShowCloneModal(role); }}
className="mt-3 flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
>
<Copy className="w-3 h-3" /> 克隆角色
</button>
</div>
);
})}
</div>
{/* Permission Config */}
{selectedRole && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
{hasChanges && (
<span className="px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-400 text-xs font-medium">
有未保存的更改 ({changes.length})
</span>
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
<CardTitle className="text-[15px] font-semibold">权限配置</CardTitle>
<div className="flex items-center gap-2">
{/* Presets */}
<div className="relative">
<button
onClick={() => setShowPresets(!showPresets)}
className="flex items-center gap-2 px-3 py-2 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-colors"
>
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
</button>
{showPresets && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
{PERMISSION_PRESETS.map(preset => {
const Icon = preset.icon;
return (
<button
key={preset.key}
onClick={() => applyPreset(preset)}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-medium">{preset.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
</div>
</button>
);
})}
</div>
</>
)}
</div>
<div className="flex items-center gap-2">
{/* Presets */}
<div className="relative">
<button
onClick={() => setShowPresets(!showPresets)}
className="flex items-center gap-2 px-3 py-2 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-colors"
>
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
</button>
{showPresets && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
{PERMISSION_PRESETS.map(preset => {
const Icon = preset.icon;
return (
<button
key={preset.key}
onClick={() => applyPreset(preset)}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-medium">{preset.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
</div>
</button>
);
})}
</div>
</>
{/* Diff Toggle */}
{hasChanges && (
<button
onClick={() => setShowDiff(!showDiff)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
变更对比
</button>
)}
{/* Discard */}
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" /> 撤销
</button>
)}
{/* Save */}
<button
onClick={handleSave}
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>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="flex gap-5">
{/* Permission Tree */}
<div className="flex-1 min-w-0">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限名称..."
className="w-full pl-10 pr-4 py-2 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"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Diff Toggle */}
{hasChanges && (
<button
onClick={() => setShowDiff(!showDiff)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<GitCompare className="w-4 h-4" /> 变更对比
</button>
)}
{/* Discard */}
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" /> 撤销
</button>
)}
{/* Save */}
<button
onClick={handleSave}
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>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="flex gap-5">
{/* Permission Tree */}
<div className="flex-1 min-w-0">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限名称..."
className="w-full pl-10 pr-4 py-2 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"
{/* Tree */}
<div className="space-y-1">
{filteredTree.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
未找到匹配的权限
</div>
)}
</div>
</div>
{/* Tree */}
<div className="space-y-1">
{filteredTree.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
未找到匹配的权限
{/* Diff Panel */}
{showDiff && hasChanges && (
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
<h3 className="text-sm font-semibold mb-3">变更详情</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{changes.map(change => (
<div
key={change.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}
>
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 min-w-0 truncate">{change.label}</span>
</div>
)}
))}
</div>
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
</div>
</div>
{/* Diff Panel */}
{showDiff && hasChanges && (
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<GitCompare className="w-4 h-4 text-[var(--dg-text-muted)]" />
变更详情
</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{changes.map(change => (
<div
key={change.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}
>
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 min-w-0 truncate">{change.label}</span>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Clone Modal */}
{showCloneModal && (
<CloneRoleModal
sourceRole={showCloneModal}
onClose={() => setShowCloneModal(null)}
/>
)}
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -531,54 +415,3 @@ function PermissionNodeComponent({
</div>
);
}
/* ───────── Clone Role Modal ───────── */
function CloneRoleModal({ sourceRole, onClose }: { sourceRole: Role; onClose: () => void }) {
const [name, setName] = useState(`${sourceRole.name} (副本)`);
const [description, setDescription] = useState(sourceRole.description);
const [submitting, setSubmitting] = useState(false);
const handleClone = async () => {
setSubmitting(true);
try {
// In real implementation, call API
await new Promise(r => setTimeout(r, 500));
onClose();
} catch {
// handle error
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold flex items-center gap-2"><Copy className="w-5 h-5 text-blue-400" /> 克隆角色</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
</div>
<div className="p-3 bg-blue-500/5 border border-blue-500/15 rounded-lg mb-4">
<p className="text-sm text-[var(--dg-text-secondary)]">基于 <strong className="text-[var(--dg-text-primary)]">{sourceRole.name}</strong> 创建副本,包含 {sourceRole.permissions.length} 个权限</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">角色名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none resize-none" rows={2} />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 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={handleClone} disabled={submitting || !name.trim()} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
{submitting ? '克隆中...' : '确认克隆'}
</button>
</div>
</div>
</div>
</div>
);
}
+5 -2
View File
@@ -1,10 +1,12 @@
import { useState } from 'react';
import PlatformConfigPage from './PlatformConfigPage';
import AuditRulesPage from './AuditRulesPage';
import AnnouncementPage from './AnnouncementPage';
import NotificationPage from './NotificationPage';
import LdapConfigPage from './LdapConfigPage';
import AuditRulesPage from './AuditRulesPage';
const tabs = [
{ id: 'platform', label: '平台配置', icon: '⚙️' },
{ id: 'rules', label: '审核规则', icon: '🛡️' },
{ id: 'announcement', label: '公告管理', icon: '📢' },
{ id: 'notification', label: '消息推送', icon: '🔔' },
@@ -14,7 +16,7 @@ const tabs = [
type TabId = typeof tabs[number]['id'];
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<TabId>('rules');
const [activeTab, setActiveTab] = useState<TabId>('platform');
return (
<div className="space-y-5">
@@ -43,6 +45,7 @@ export default function SettingsPage() {
{/* 内容区 */}
<div>
{activeTab === 'platform' && <PlatformConfigPage />}
{activeTab === 'rules' && <AuditRulesPage />}
{activeTab === 'announcement' && <AnnouncementPage />}
{activeTab === 'notification' && <NotificationPage />}
+3 -41
View File
@@ -5,7 +5,6 @@ import {
UserCheck, UserX, Filter,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { users as mockUsers } from '@/lib/mock';
import { userApi } from '@/api/user';
/* ───────── Types ───────── */
@@ -91,26 +90,6 @@ function validateUserForm(data: { username: string; password: string; email: str
return errors;
}
/* ───────── Mock Activities ───────── */
function generateMockActivities(userId: number): UserActivity[] {
const actions = [
{ action: '登录系统', target: 'Web Console' },
{ action: '执行查询', target: 'users 表' },
{ action: '提交工单', target: 'TK-20240115-001' },
{ action: '审批工单', target: 'TK-20240115-002' },
{ action: '导出数据', target: 'orders 表' },
{ action: '修改密码', target: '账户安全' },
];
return actions.map((a, i) => ({
id: `act-${userId}-${i}`,
action: a.action,
target: a.target,
created_at: new Date(Date.now() - i * 3600000 * 2).toISOString(),
status: Math.random() > 0.2 ? 'success' : 'failed',
}));
}
/* ───────── CSV Export Helper ───────── */
function exportUsersToCSV(users: UserItem[]) {
@@ -155,12 +134,7 @@ export default function UsersPage() {
try {
const res = await userApi.list();
const data = (res as any)?.items || res;
// Merge with mock user data to enrich with last_login
const merged = (Array.isArray(data) ? data : []).map((u: UserItem) => {
const mockU = mockUsers.find(m => m.id === u.id);
return { ...u, last_login: mockU?.lastLogin || u.created_at };
});
setUsers(merged);
setUsers(Array.isArray(data) ? data : []);
} catch {
setToast({ message: '获取用户列表失败', type: 'error' });
} finally {
@@ -792,7 +766,6 @@ function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClo
/* ───────── User Detail Modal ───────── */
function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
const activities = useMemo(() => generateMockActivities(user.id), [user.id]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
@@ -847,19 +820,8 @@ function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => voi
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
最近活动
</h3>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{activities.map(act => (
<div key={act.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors">
<div className={`w-2 h-2 rounded-full ${act.status === 'success' ? 'bg-green-400' : 'bg-red-400'}`} />
<div className="flex-1 min-w-0">
<div className="text-sm">{act.action}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{act.target}</div>
</div>
<div className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{new Date(act.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
))}
<div className="text-sm text-[var(--dg-text-muted)] text-center py-6">
暂无活动记录
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff