e.stopPropagation()}
+ >
-
{editingAnn ? '编辑公告' : '发布公告'}
-
+
-
- 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="输入公告标题" />
+
+ 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 text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
+ placeholder="输入公告标题"
+ />
+
-
-
-
-
-
-
-
-
-
- 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" />
-
-
-
-
- 置顶公告
-
-
-
-
- {roleOptions.map(role => (
-
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}
-
- ))}
+
+
+ setShowPreview(!showPreview)}
+ className={`text-xs px-2 py-1 rounded transition-colors ${
+ showPreview
+ ? 'bg-[var(--dg-blue)] text-white'
+ : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
+ }`}
+ >
+ {showPreview ? '编辑' : '预览'}
+
+
+ {showPreview ? (
+
+ ) : (
+
+
- 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">取消
-
- {editingAnn ? '保存修改' : '发布公告'}
+ setShowModal(false)}
+ className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
+ >
+ 取消
+
+
+ {saving ? '保存中…' : editingId ? '保存修改' : '创建'}
diff --git a/frontend/src/pages/AuditRulesPage.tsx b/frontend/src/pages/AuditRulesPage.tsx
index b024bf3..bafb832 100644
--- a/frontend/src/pages/AuditRulesPage.tsx
+++ b/frontend/src/pages/AuditRulesPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import request from '../api/request';
interface AuditRule {
@@ -11,278 +11,237 @@ interface AuditRule {
pattern: string | null;
is_enabled: boolean;
message: string;
+ value: string | null;
created_at: string;
updated_at: string;
}
-const RULE_TYPES = [
- { value: 'syntax', label: '语法检查', color: 'bg-blue-100 text-blue-700' },
- { value: 'performance', label: '性能优化', color: 'bg-yellow-100 text-yellow-700' },
- { value: 'safety', label: '安全检查', color: 'bg-red-100 text-red-700' },
- { value: 'naming', label: '命名规范', color: 'bg-green-100 text-green-700' },
-];
+const DB_TABS = [
+ { key: 'mysql', label: 'MySQL', icon: '🐬' },
+ { key: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
+ { key: 'redis', label: 'Redis', icon: '🔴' },
+] as const;
-const SEVERITY_MAP: Record
= {
- error: { label: '错误', color: 'bg-red-500 text-white' },
- warning: { label: '警告', color: 'bg-yellow-500 text-white' },
- info: { label: '提示', color: 'bg-blue-500 text-white' },
-};
+type DbType = (typeof DB_TABS)[number]['key'];
+
+function isDDL(name: string): boolean {
+ const ddlKeywords = [
+ 'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
+ 'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION',
+ ];
+ const upper = name.toUpperCase();
+ return ddlKeywords.some((kw) => upper.includes(kw));
+}
export default function AuditRulesPage() {
+ const [activeTab, setActiveTab] = useState('mysql');
const [rules, setRules] = useState([]);
- const [loading, setLoading] = useState(true);
- const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' });
- const [showCreate, setShowCreate] = useState(false);
- const [stats, setStats] = useState(null);
- const [form, setForm] = useState({
- name: '', description: '', rule_type: 'safety', severity: 'warning',
- db_type: 'mysql', pattern: '', is_enabled: true, message: '',
- });
+ const [loading, setLoading] = useState(false);
+ const [seeding, setSeeding] = useState(false);
+ const [editingValue, setEditingValue] = useState>({});
+ const [saving, setSaving] = useState>({});
- useEffect(() => { fetchRules(); fetchStats(); }, []);
-
- async function fetchRules() {
+ const fetchRules = useCallback(async (dbType: DbType) => {
try {
setLoading(true);
- const params = new URLSearchParams();
- if (filter.rule_type) params.set('rule_type', filter.rule_type);
- if (filter.severity) params.set('severity', filter.severity);
- if (filter.is_enabled !== '') params.set('is_enabled', filter.is_enabled);
- const res = await request.get('/audit-rules', { params });
- setRules(Array.isArray(res.data) ? res.data : []);
- } catch { setRules([]); } finally { setLoading(false); }
- }
-
- async function fetchStats() {
- try {
- const res = await request.get('/audit-rules/stats/summary');
- setStats(res.data || null);
- } catch { setStats(null); }
- }
-
- async function handleCreate() {
- try {
- await request.post('/audit-rules', form);
- setShowCreate(false);
- setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' });
- fetchRules();
- fetchStats();
- } catch (err: any) {
- alert(err.response?.data?.detail || '创建失败');
+ const res = await request.get('/audit-rules', {
+ params: { db_type: dbType },
+ });
+ setRules(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
+ } catch {
+ setRules([]);
+ } finally {
+ setLoading(false);
}
- }
+ }, []);
- async function handleToggle(rule: AuditRule) {
+ useEffect(() => {
+ fetchRules(activeTab);
+ }, [activeTab, fetchRules]);
+
+ const handleSeed = async () => {
try {
- await request.post('/audit-rules/' + rule.id + '/toggle');
- fetchRules();
- fetchStats();
- } catch { /* */ }
- }
+ setSeeding(true);
+ await request.post(`/audit-rules/seed/${activeTab}`);
+ fetchRules(activeTab);
+ } catch (err: any) {
+ alert(err.response?.data?.detail || '初始化失败');
+ } finally {
+ setSeeding(false);
+ }
+ };
- async function handleDelete(id: number) {
- if (!confirm('确认删除此规则?')) return;
+ const handleToggle = async (rule: AuditRule) => {
try {
- await request.delete('/audit-rules/' + id);
- fetchRules();
- fetchStats();
- } catch { /* */ }
- }
+ await request.post(`/audit-rules/${rule.id}/toggle`);
+ setRules((prev) =>
+ prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
+ );
+ } catch {
+ /* ignore */
+ }
+ };
- const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0];
- const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info;
+ const handleSaveValue = async (rule: AuditRule) => {
+ const newVal = editingValue[rule.id];
+ if (newVal === undefined || newVal === rule.value) return;
+ try {
+ setSaving((prev) => ({ ...prev, [rule.id]: true }));
+ await request.put(`/audit-rules/${rule.id}`, { value: newVal });
+ setRules((prev) =>
+ prev.map((r) => (r.id === rule.id ? { ...r, value: newVal } : r)),
+ );
+ setEditingValue((prev) => {
+ const next = { ...prev };
+ delete next[rule.id];
+ return next;
+ });
+ } catch {
+ /* ignore */
+ } finally {
+ setSaving((prev) => ({ ...prev, [rule.id]: false }));
+ }
+ };
+
+ // Group rules into DDL / DML
+ const ddlRules = rules.filter((r) => isDDL(r.name));
+ const dmlRules = rules.filter((r) => !isDDL(r.name));
+
+ const renderRuleGroup = (title: string, icon: string, groupRules: AuditRule[]) => {
+ if (groupRules.length === 0) return null;
+ return (
+
+
+ {icon}
+ {title}
+ ({groupRules.length})
+
+
+ {groupRules.map((rule) => {
+ const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
+ const currentEditVal = editingValue[rule.id];
+ const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
+
+ return (
+
+ {/* Toggle */}
+
handleToggle(rule)}
+ className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
+ rule.is_enabled
+ ? 'bg-[var(--dg-green)]'
+ : 'bg-[var(--dg-bg-hover)]'
+ }`}
+ >
+
+
+
+ {/* Name + Message */}
+
+
+ {rule.name}
+
+
+ {rule.message}
+
+
+
+ {/* Value input (if applicable) */}
+ {hasValue && (
+
+ =
+
+ setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
+ }
+ onBlur={() => handleSaveValue(rule)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSaveValue(rule);
+ }}
+ disabled={saving[rule.id]}
+ className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50"
+ />
+ {saving[rule.id] && (
+ 保存中…
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+ };
return (
-
+
{/* Header */}
-
审核规则
-
管理 SQL 审核引擎的检查规则
+
审核规则
+
按数据库类型管理 SQL 审核规则模版
setShowCreate(true)}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
+ onClick={handleSeed}
+ disabled={seeding}
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
- 新建规则
+ {seeding ? '初始化中…' : '初始化规则'}
- {/* Stats */}
- {stats && (
-
- {[
- { label: '总规则数', value: stats.total, color: 'text-gray-900' },
- { label: '已启用', value: stats.enabled, color: 'text-green-600' },
- { label: '已禁用', value: stats.disabled, color: 'text-gray-400' },
- { label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' },
- ].map(s => (
-
-
{s.label}
-
{s.value}
-
- ))}
-
- )}
-
- {/* Filters */}
-
-
-
-
-
- 刷新
-
+ {/* DB Type Tabs */}
+
+ {DB_TABS.map((tab) => (
+ setActiveTab(tab.key)}
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ activeTab === tab.key
+ ? 'bg-[var(--dg-blue)] text-white'
+ : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
+ }`}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
- {/* Create Modal */}
- {showCreate && (
-
-
-
新建审核规则
-
-
- setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
- 取消
-
-
- 创建
-
-
-
+ {/* Rule List */}
+ {loading ? (
+
加载中…
+ ) : rules.length === 0 ? (
+
+
+ 当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
+
+
+ 点击初始化默认规则
+
+
+ ) : (
+
+ {renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
+ {renderRuleGroup('DML 规则', '📝', dmlRules)}
)}
-
- {/* Rules Table */}
-
- {loading ? (
-
加载中...
- ) : rules.length === 0 ? (
-
暂无规则,点击"新建规则"添加
- ) : (
-
-
-
- | 规则名称 |
- 类型 |
- 级别 |
- 提示消息 |
- 状态 |
- 操作 |
-
-
-
- {rules.map(rule => {
- const typeInfo = getTypeInfo(rule.rule_type);
- const sevInfo = getSeverityInfo(rule.severity);
- return (
-
-
-
- {rule.name}
- {rule.description && {rule.description} }
-
- |
-
- {typeInfo.label}
- |
-
- {sevInfo.label}
- |
- {rule.message} |
-
- handleToggle(rule)}
- className={'px-2 py-1 rounded text-xs ' + (
- rule.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
- )}
- >
- {rule.is_enabled ? '启用' : '禁用'}
-
- |
-
- handleDelete(rule.id)}
- className="text-xs text-red-500 hover:text-red-700"
- >
- 删除
-
- |
-
- );
- })}
-
-
- )}
-
);
}
diff --git a/frontend/src/pages/LdapConfigPage.tsx b/frontend/src/pages/LdapConfigPage.tsx
new file mode 100644
index 0000000..71014b6
--- /dev/null
+++ b/frontend/src/pages/LdapConfigPage.tsx
@@ -0,0 +1,335 @@
+import { useState, useEffect, useCallback } from 'react';
+import request from '@/api/request';
+import { Save, Shield, CheckCircle, XCircle } from 'lucide-react';
+
+/* ── types ── */
+interface LdapConfig {
+ ldap_host: string;
+ ldap_port: number;
+ use_ldaps: boolean;
+ admin_dn: string;
+ admin_password: string;
+ search_filter: string;
+ base_dn: string;
+ user_mapping: Record
;
+}
+
+const defaultConfig: LdapConfig = {
+ ldap_host: '',
+ ldap_port: 389,
+ use_ldaps: false,
+ admin_dn: '',
+ admin_password: '',
+ search_filter: '(objectClass=person)',
+ base_dn: '',
+ user_mapping: { username: 'uid', email: 'mail', display_name: 'cn' },
+};
+
+export default function LdapConfigPage() {
+ const [config, setConfig] = useState(defaultConfig);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
+
+ /* test */
+ const [testUser, setTestUser] = useState('');
+ const [testPass, setTestPass] = useState('');
+ const [testing, setTesting] = useState(false);
+ const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+
+ const fetchConfig = useCallback(async () => {
+ try {
+ setLoading(true);
+ const res = await request.get('/ldap-config');
+ const data = (res as any).data ?? res;
+ if (data && typeof data === 'object') {
+ setConfig({
+ ldap_host: data.ldap_host || '',
+ ldap_port: data.ldap_port || 389,
+ use_ldaps: data.use_ldaps ?? false,
+ admin_dn: data.admin_dn || '',
+ admin_password: data.admin_password || '',
+ search_filter: data.search_filter || '',
+ base_dn: data.base_dn || '',
+ user_mapping: typeof data.user_mapping === 'string'
+ ? JSON.parse(data.user_mapping)
+ : data.user_mapping || defaultConfig.user_mapping,
+ });
+ }
+ } catch {
+ /* 404 = no config yet, use defaults */
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchConfig();
+ }, [fetchConfig]);
+
+ /* update helpers */
+ const updateField = (key: K, value: LdapConfig[K]) => {
+ setConfig(prev => ({ ...prev, [key]: value }));
+ };
+
+ const updateMapping = (key: string, value: string) => {
+ setConfig(prev => ({
+ ...prev,
+ user_mapping: { ...prev.user_mapping, [key]: value },
+ }));
+ };
+
+ /* save */
+ const handleSave = async () => {
+ try {
+ setSaving(true);
+ setSaveMsg(null);
+ await request.put('/ldap-config', config);
+ setSaveMsg({ ok: true, text: '保存成功' });
+ } catch (err: any) {
+ setSaveMsg({ ok: false, text: err.response?.data?.detail || '保存失败' });
+ } finally {
+ setSaving(false);
+ setTimeout(() => setSaveMsg(null), 3000);
+ }
+ };
+
+ /* test connection */
+ const handleTest = async () => {
+ if (!testUser.trim() || !testPass.trim()) return;
+ try {
+ setTesting(true);
+ setTestResult(null);
+ const res = await request.post('/ldap-config/test', { test_user: testUser, test_password: testPass });
+ const data = (res as any).data ?? res;
+ setTestResult({ success: data.success ?? true, message: data.message || '连接成功' });
+ } catch (err: any) {
+ setTestResult({ success: false, message: err.response?.data?.detail || '连接失败' });
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ if (loading) {
+ return 加载中…
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
LDAP 设置
+
配置 LDAP/AD 域账号认证集成
+
+
+ {saving ? '保存中…' : '保存配置'}
+
+
+
+ {saveMsg && (
+
+ {saveMsg.ok ? : }
+ {saveMsg.text}
+
+ )}
+
+ {/* Config Form */}
+
+ {/* Server */}
+
+
+ LDAP 服务器
+
+
+
+ updateField('use_ldaps', !config.use_ldaps)}
+ className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
+ config.use_ldaps ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
+ }`}
+ >
+
+
+ 启用 LDAPS (SSL/TLS)
+
+
+
+ {/* Admin */}
+
+
+ {/* Search */}
+
+
+ {/* User Mapping */}
+
+
+
+ {/* Test Connection */}
+
+
+ 测试 LDAP 连接
+
+
使用 LDAP 账号验证配置是否正确
+
+
+
+ setTestUser(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-[var(--dg-blue)]"
+ placeholder="LDAP 用户名"
+ />
+
+
+
+ setTestPass(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-[var(--dg-blue)]"
+ placeholder="密码"
+ />
+
+
+
+ {testing ? '测试中…' : '测试连接'}
+
+
+
+ {testResult && (
+
+ {testResult.success ? : }
+ {testResult.message}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/NotificationPage.tsx b/frontend/src/pages/NotificationPage.tsx
new file mode 100644
index 0000000..d67fb94
--- /dev/null
+++ b/frontend/src/pages/NotificationPage.tsx
@@ -0,0 +1,456 @@
+import { useState, useEffect, useCallback } from 'react';
+import request from '@/api/request';
+import { Plus, Trash2, Send, Bell, Mail, Settings, X } from 'lucide-react';
+
+/* ── types ── */
+type ChannelType = 'dingtalk' | 'webhook_feishu' | 'email';
+
+interface NotifChannel {
+ id: number;
+ name: string;
+ channel_type: ChannelType;
+ config: Record;
+ is_enabled: boolean;
+}
+
+const CHANNEL_LABELS: Record = {
+ dingtalk: '钉钉',
+ webhook_feishu: '飞书',
+ email: '邮件',
+};
+
+const CHANNEL_ICONS: Record = {
+ dingtalk: Bell,
+ webhook_feishu: Send,
+ email: Mail,
+};
+
+interface FormState {
+ name: string;
+ channel_type: ChannelType;
+ config: Record;
+ is_enabled: boolean;
+}
+
+const defaultForm = (): FormState => ({
+ name: '',
+ channel_type: 'dingtalk',
+ config: { webhook_url: '' },
+ is_enabled: true,
+});
+
+const dingtalkDefault = () => ({ webhook_url: '', secret: '' });
+const feishuDefault = () => ({ webhook_url: '' });
+const emailDefault = () => ({ smtp_host: '', smtp_port: 465, smtp_user: '', smtp_password: '', from_addr: '', use_tls: true });
+
+const configDefaults: Record Record> = {
+ dingtalk: dingtalkDefault,
+ webhook_feishu: feishuDefault,
+ email: emailDefault,
+};
+
+export default function NotificationPage() {
+ const [channels, setChannels] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ /* modal */
+ const [showModal, setShowModal] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(defaultForm());
+ const [saving, setSaving] = useState(false);
+
+ /* test */
+ const [testingId, setTestingId] = useState(null);
+ const [testResult, setTestResult] = useState>({});
+
+ const fetchList = useCallback(async () => {
+ try {
+ setLoading(true);
+ const res = await request.get('/notifications');
+ setChannels(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
+ } catch {
+ setChannels([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchList();
+ }, [fetchList]);
+
+ /* ── helpers ── */
+ const openCreate = () => {
+ setEditingId(null);
+ setForm(defaultForm());
+ setShowModal(true);
+ };
+
+ const openEdit = (ch: NotifChannel) => {
+ setEditingId(ch.id);
+ setForm({ name: ch.name, channel_type: ch.channel_type, config: { ...ch.config }, is_enabled: ch.is_enabled });
+ setShowModal(true);
+ };
+
+ const handleTypeChange = (t: ChannelType) => {
+ setForm(prev => ({ ...prev, channel_type: t, config: configDefaults[t]() }));
+ };
+
+ const updateConfig = (key: string, value: any) => {
+ setForm(prev => ({ ...prev, config: { ...prev.config, [key]: value } }));
+ };
+
+ const handleSave = async () => {
+ if (!form.name.trim()) return;
+ try {
+ setSaving(true);
+ if (editingId) {
+ await request.put(`/notifications/${editingId}`, form);
+ } else {
+ await request.post('/notifications', form);
+ }
+ setShowModal(false);
+ fetchList();
+ } catch (err: any) {
+ alert(err.response?.data?.detail || '保存失败');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggle = async (ch: NotifChannel) => {
+ try {
+ await request.put(`/notifications/${ch.id}`, { is_enabled: !ch.is_enabled });
+ fetchList();
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ if (!confirm('确定删除此通知渠道?')) return;
+ try {
+ await request.delete(`/notifications/${id}`);
+ fetchList();
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleTest = async (id: number) => {
+ try {
+ setTestingId(id);
+ setTestResult(prev => ({ ...prev, [id]: null }));
+ const res = await request.post(`/notifications/${id}/test`);
+ const data = (res as any).data ?? res;
+ setTestResult(prev => ({ ...prev, [id]: { success: true, message: data.message || '测试成功' } }));
+ } catch (err: any) {
+ setTestResult(prev => ({
+ ...prev,
+ [id]: { success: false, message: err.response?.data?.detail || '测试失败' },
+ }));
+ } finally {
+ setTestingId(null);
+ setTimeout(() => setTestResult(prev => ({ ...prev, [id]: null })), 5000);
+ }
+ };
+
+ /* ── config form renderer ── */
+ const renderConfigFields = () => {
+ const t = form.channel_type;
+ if (t === 'dingtalk') {
+ return (
+ <>
+
+
+ updateConfig('webhook_url', 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-[var(--dg-blue)]"
+ placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
+ />
+
+
+
+ updateConfig('secret', 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-[var(--dg-blue)]"
+ placeholder="可选,加签密钥"
+ />
+
+ >
+ );
+ }
+ if (t === 'webhook_feishu') {
+ return (
+
+
+ updateConfig('webhook_url', 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-[var(--dg-blue)]"
+ placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
+ />
+
+ );
+ }
+ /* email */
+ return (
+
+ );
+ };
+
+ /* ── render ── */
+ return (
+
+ {/* Header */}
+
+
+
消息推送
+
管理钉钉、飞书、邮件等通知渠道
+
+
+ 添加渠道
+
+
+
+ {/* List */}
+ {loading ? (
+
加载中…
+ ) : channels.length === 0 ? (
+
暂无通知渠道,点击右上角添加
+ ) : (
+
+ {channels.map(ch => {
+ const Icon = CHANNEL_ICONS[ch.channel_type] || Settings;
+ const result = testResult[ch.id];
+ return (
+
+
+
+
+
+
+
{ch.name}
+
+ {CHANNEL_LABELS[ch.channel_type] || ch.channel_type}
+
+
+ {ch.is_enabled ? '已启用' : '已禁用'}
+
+
+
+ {ch.channel_type === 'email'
+ ? `${ch.config.smtp_host}:${ch.config.smtp_port} / ${ch.config.from_addr}`
+ : ch.config.webhook_url || '未配置'}
+
+
+
+ handleToggle(ch)}
+ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
+ ch.is_enabled
+ ? 'bg-yellow-500/15 text-yellow-400 hover:bg-yellow-500/25'
+ : 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
+ }`}
+ >
+ {ch.is_enabled ? '禁用' : '启用'}
+
+ handleTest(ch.id)}
+ disabled={testingId === ch.id}
+ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
+ result?.success
+ ? 'bg-green-500/15 text-green-400'
+ : result?.success === false
+ ? 'bg-red-500/15 text-red-400'
+ : 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
+ } disabled:opacity-50`}
+ >
+ {testingId === ch.id ? '发送中…' : result?.success ? '✓ 成功' : result?.success === false ? '✗ 失败' : '测试'}
+
+ openEdit(ch)}
+ 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="编辑"
+ >
+
+
+ handleDelete(ch.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="删除"
+ >
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* ── Create / Edit Modal ── */}
+ {showModal && (
+
setShowModal(false)}>
+
e.stopPropagation()}
+ >
+
+
{editingId ? '编辑渠道' : '添加渠道'}
+ setShowModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
+
+
+
+
+
+
+
+ setForm(prev => ({ ...prev, 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 text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
+ placeholder="例如:运维群钉钉机器人"
+ />
+
+
+ {!editingId && (
+
+
+
+ {(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => (
+ handleTypeChange(key)}
+ className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ form.channel_type === key
+ ? 'bg-[var(--dg-blue)] text-white'
+ : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+ )}
+
+
+
渠道配置
+ {renderConfigFields()}
+
+
+
+ setForm(prev => ({ ...prev, is_enabled: !prev.is_enabled }))}
+ className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
+ form.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
+ }`}
+ >
+
+
+ 启用此渠道
+
+
+
+
+ setShowModal(false)}
+ className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
+ >
+ 取消
+
+
+ {saving ? '保存中…' : editingId ? '保存修改' : '添加'}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..a9cae99
--- /dev/null
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -0,0 +1,53 @@
+import { useState } from 'react';
+import AnnouncementPage from './AnnouncementPage';
+import NotificationPage from './NotificationPage';
+import LdapConfigPage from './LdapConfigPage';
+import AuditRulesPage from './AuditRulesPage';
+
+const tabs = [
+ { id: 'rules', label: '审核规则', icon: '🛡️' },
+ { id: 'announcement', label: '公告管理', icon: '📢' },
+ { id: 'notification', label: '消息推送', icon: '🔔' },
+ { id: 'ldap', label: 'LDAP', icon: '🔐' },
+] as const;
+
+type TabId = typeof tabs[number]['id'];
+
+export default function SettingsPage() {
+ const [activeTab, setActiveTab] = useState('rules');
+
+ return (
+
+
+
系统设置
+
管理平台配置、审核规则、消息推送和 LDAP 认证
+
+
+ {/* Tab 导航 */}
+
+ {tabs.map(tab => (
+ setActiveTab(tab.id)}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
+ activeTab === tab.id
+ ? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
+ : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
+ }`}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ {/* 内容区 */}
+
+ {activeTab === 'rules' &&
}
+ {activeTab === 'announcement' &&
}
+ {activeTab === 'notification' &&
}
+ {activeTab === 'ldap' &&
}
+
+
+ );
+}