diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 03dee49..42392b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { LoginPage } from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import { RepositoryManager } from './components/RepositoryManager'; import { ConfigManager } from './components/ConfigManager'; +import { NotificationConfigPage } from './components/NotificationConfigPage'; import { ReviewConfigPage } from './components/ReviewConfigPage'; import { Toaster } from "@/components/ui/sonner" import { useTheme } from 'next-themes' @@ -51,6 +52,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/ConfigGroupCard.tsx b/frontend/src/components/ConfigGroupCard.tsx index 0305f3e..3cb417e 100644 --- a/frontend/src/components/ConfigGroupCard.tsx +++ b/frontend/src/components/ConfigGroupCard.tsx @@ -25,6 +25,7 @@ interface ConfigGroupCardProps { onFieldChange: (envKey: string, value: any) => void; onReset: (keys: string[]) => void; isResetting: boolean; + headerActions?: React.ReactNode; /** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */ renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined; } @@ -35,6 +36,7 @@ export function ConfigGroupCard({ onFieldChange, onReset, isResetting, + headerActions, renderField, }: ConfigGroupCardProps) { const hasOverride = group.fields.some((f) => f.source === 'db'); @@ -69,17 +71,22 @@ export function ConfigGroupCard({ - {hasOverride && ( - + {(headerActions || hasOverride) && ( +
+ {headerActions} + {hasOverride && ( + + )} +
)} diff --git a/frontend/src/components/ConfigManager.tsx b/frontend/src/components/ConfigManager.tsx index 69520d0..8ebafaa 100644 --- a/frontend/src/components/ConfigManager.tsx +++ b/frontend/src/components/ConfigManager.tsx @@ -9,7 +9,7 @@ import { Save, AlertCircle, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; /** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */ -const SYSTEM_GROUPS = new Set(['gitea', 'feishu', 'security']); +const SYSTEM_GROUPS = new Set(['gitea', 'security']); export function ConfigManager() { const queryClient = useQueryClient(); diff --git a/frontend/src/components/NotificationConfigPage.tsx b/frontend/src/components/NotificationConfigPage.tsx new file mode 100644 index 0000000..79cd49c --- /dev/null +++ b/frontend/src/components/NotificationConfigPage.tsx @@ -0,0 +1,379 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + fetchConfig, + fetchNotificationTestHistory, + updateConfig, + resetConfig, + testNotification, + type NotificationTestProvider, +} from '@/services/configService'; +import type { + ConfigResponse, + ConfigGroupDto, + ConfigFieldDto, + NotificationTestRecordDto, +} from '@/services/configService'; +import { ConfigGroupCard } from './ConfigGroupCard'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Save, AlertCircle, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +const NOTIFICATION_GROUPS = new Set(['notification']); + +type ProviderCardMeta = { + key: NotificationTestProvider; + fieldPrefix: 'FEISHU_' | 'WECOM_'; + label: string; + description: string; + enableKey: 'FEISHU_ENABLED' | 'WECOM_ENABLED'; + webhookKey: 'FEISHU_WEBHOOK_URL' | 'WECOM_WEBHOOK_URL'; +}; + +const PROVIDER_CARDS: ProviderCardMeta[] = [ + { + key: 'feishu', + fieldPrefix: 'FEISHU_', + label: '飞书通知', + description: '配置飞书机器人 Webhook 与签名密钥。', + enableKey: 'FEISHU_ENABLED', + webhookKey: 'FEISHU_WEBHOOK_URL', + }, + { + key: 'wecom', + fieldPrefix: 'WECOM_', + label: '企业微信通知', + description: '配置企业微信群机器人 Webhook。', + enableKey: 'WECOM_ENABLED', + webhookKey: 'WECOM_WEBHOOK_URL', + }, +]; + +export function NotificationConfigPage() { + const queryClient = useQueryClient(); + const [localConfig, setLocalConfig] = useState>({}); + const [hasChanges, setHasChanges] = useState(false); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['config'], + queryFn: fetchConfig, + }); + + const { + data: testHistory, + isLoading: isHistoryLoading, + } = useQuery({ + queryKey: ['notification-test-history'], + queryFn: fetchNotificationTestHistory, + refetchInterval: 10000, + }); + + useEffect(() => { + if (data) { + const initialState: Record = {}; + data.groups + .filter((g) => NOTIFICATION_GROUPS.has(g.key)) + .forEach((group) => { + group.fields.forEach((field) => { + if (field.sensitive && field.hasValue) { + initialState[field.envKey] = '••••••••'; + } else if (field.type === 'boolean') { + initialState[field.envKey] = field.value === 'true' || field.value === true; + } else { + initialState[field.envKey] = field.value ?? ''; + } + }); + }); + setLocalConfig(initialState); + setHasChanges(false); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: (configData: Record) => updateConfig(configData), + onSuccess: () => { + toast.success('通知配置已成功保存'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + setHasChanges(false); + }, + onError: (err: Error) => { + toast.error(`保存失败: ${err.message}`); + }, + }); + + const feishuTestMutation = useMutation({ + mutationFn: () => testNotification('feishu'), + onSuccess: () => { + toast.success('飞书测试通知已发送'); + queryClient.invalidateQueries({ queryKey: ['notification-test-history'] }); + }, + onError: (err: Error) => { + toast.error(`飞书测试发送失败: ${err.message}`); + queryClient.invalidateQueries({ queryKey: ['notification-test-history'] }); + }, + }); + + const wecomTestMutation = useMutation({ + mutationFn: () => testNotification('wecom'), + onSuccess: () => { + toast.success('企业微信测试通知已发送'); + queryClient.invalidateQueries({ queryKey: ['notification-test-history'] }); + }, + onError: (err: Error) => { + toast.error(`企业微信测试发送失败: ${err.message}`); + queryClient.invalidateQueries({ queryKey: ['notification-test-history'] }); + }, + }); + + const resetMutation = useMutation({ + mutationFn: (keys: string[]) => resetConfig(keys), + onSuccess: () => { + toast.success('通知配置已重置'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + onError: (err: Error) => { + toast.error(`重置失败: ${err.message}`); + }, + }); + + const handleFieldChange = (envKey: string, value: any) => { + setLocalConfig((prev) => ({ + ...prev, + [envKey]: value, + })); + setHasChanges(true); + }; + + const handleSave = () => { + const payload: Record = {}; + + for (const [key, val] of Object.entries(localConfig)) { + if (typeof val === 'boolean') { + payload[key] = val ? 'true' : 'false'; + } else { + payload[key] = val === undefined || val === null ? '' : String(val); + } + } + + saveMutation.mutate(payload); + }; + + const handleResetGroup = (keys: string[]) => { + if (confirm('确定要重置这些通知配置到默认值吗?这将立即生效。')) { + resetMutation.mutate(keys); + } + }; + + const notificationGroup = useMemo( + () => data?.groups.find((g) => NOTIFICATION_GROUPS.has(g.key)), + [data] + ); + + const providerGroups = useMemo(() => { + if (!notificationGroup) { + return []; + } + + return PROVIDER_CARDS.map((provider) => { + const fields = notificationGroup.fields.filter((field: ConfigFieldDto) => + field.envKey.startsWith(provider.fieldPrefix) + ); + + return { + ...notificationGroup, + key: `notification-${provider.key}`, + label: provider.label, + description: provider.description, + fields, + }; + }).filter((group) => group.fields.length > 0); + }, [notificationGroup]); + + const hasOverrides = useMemo( + () => + providerGroups.some((g) => + g.fields.some((f) => f.source === 'db') + ), + [providerGroups] + ); + + const handleResetAll = () => { + if (providerGroups.length === 0) return; + const allOverrideKeys = providerGroups + .flatMap((g) => g.fields) + .filter((f) => f.source === 'db') + .map((f) => f.envKey); + + if (allOverrideKeys.length === 0) return; + if (confirm('确定要重置所有通知配置到默认值吗?这将立即生效。')) { + resetMutation.mutate(allOverrideKeys); + } + }; + + const canSendProviderTest = (provider: ProviderCardMeta): boolean => { + const enabled = localConfig[provider.enableKey] === true; + const webhook = localConfig[provider.webhookKey]; + return enabled && typeof webhook === 'string' && webhook.trim().length > 0; + }; + + const getProviderMutation = (providerKey: NotificationTestProvider) => { + return providerKey === 'feishu' ? feishuTestMutation : wecomTestMutation; + }; + + const getProviderLabel = (provider: string): string => { + if (provider === 'feishu') return '飞书'; + if (provider === 'wecom') return '企业微信'; + return provider; + }; + + if (isLoading) { + return ( +
+
+ + +
+ +
+ ); + } + + if (isError) { + return ( +
+ +
加载通知配置失败: {error.message}
+
+ ); + } + + return ( +
+
+
+ + +
+
+ +
+ {providerGroups.map((group) => { + const provider = PROVIDER_CARDS.find((item) => group.key === `notification-${item.key}`); + if (!provider) { + return null; + } + + const mutation = getProviderMutation(provider.key); + const canTest = canSendProviderTest(provider); + const canTestNow = canTest && !hasChanges && !saveMutation.isPending; + const testTitle = hasChanges + ? '请先保存配置后再测试' + : canTest + ? '发送测试通知' + : '请先启用并配置Webhook地址'; + + return ( + mutation.mutate()} + disabled={mutation.isPending || !canTestNow} + className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors" + title={testTitle} + > + {mutation.isPending ? '测试中...' : '测试发送'} + + } + /> + ); + })} + +
+
+

最近测试记录

+ +
+ + {isHistoryLoading ? ( +
+ + +
+ ) : (testHistory?.length ?? 0) === 0 ? ( +
+ 暂无测试记录,点击上方“测试发送”按钮可生成记录。 +
+ ) : ( +
+ {testHistory?.slice(0, 10).map((record) => ( +
+
+ + {getProviderLabel(record.provider)} + + + {record.status === 'success' ? '成功' : '失败'} + + {record.message} +
+ + {new Date(record.timestamp).toLocaleString('zh-CN')} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index bc469c9..c42bc6a 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react'; +import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react'; import { useTheme } from 'next-themes'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette'; @@ -9,6 +9,7 @@ import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette'; const navItems = [ { path: '/repos', label: '仓库管理', icon: FolderGit2 }, { path: '/config', label: '系统配置', icon: Sliders }, + { path: '/notifications', label: '通知管理', icon: Bell }, { path: '/review-config', label: '审查配置', icon: FileSearch }, ] as const; @@ -31,6 +32,7 @@ export default function DashboardPage() { const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard'; const isConfigPage = location.pathname.startsWith('/config'); + const isNotificationPage = location.pathname.startsWith('/notifications'); const isReviewConfigPage = location.pathname.startsWith('/review-config'); return ( @@ -205,7 +207,7 @@ export default function DashboardPage() {
-
+
diff --git a/frontend/src/services/configService.ts b/frontend/src/services/configService.ts index 5690bd7..491d51a 100644 --- a/frontend/src/services/configService.ts +++ b/frontend/src/services/configService.ts @@ -30,6 +30,21 @@ export interface ConfigResponse { groups: ConfigGroupDto[]; } +export type NotificationTestProvider = 'feishu' | 'wecom'; +export type NotificationTestStatus = 'success' | 'error'; + +export interface NotificationTestRecordDto { + id: string; + provider: string; + status: NotificationTestStatus; + message: string; + timestamp: string; +} + +export interface NotificationTestHistoryResponse { + data: NotificationTestRecordDto[]; +} + export const fetchConfig = async (): Promise => { const response = await api.get('/config'); return response.data; @@ -42,3 +57,12 @@ export const updateConfig = async (configData: Record): Promise< export const resetConfig = async (keys: string[]): Promise => { await api.post('/config/reset', { keys }); }; + +export const testNotification = async (provider: NotificationTestProvider): Promise => { + await api.post('/config/notification/test', { provider }); +}; + +export const fetchNotificationTestHistory = async (): Promise => { + const response = await api.get('/config/notification/test/history'); + return response.data.data; +}; diff --git a/frontend/tests/visual/fixtures/mockApi.ts b/frontend/tests/visual/fixtures/mockApi.ts index e0c090e..8385c7e 100644 --- a/frontend/tests/visual/fixtures/mockApi.ts +++ b/frontend/tests/visual/fixtures/mockApi.ts @@ -31,21 +31,41 @@ const configResponse = { ], }, { - key: 'feishu', - label: '飞书通知', - description: '配置飞书 webhook 通知。', + key: 'notification', + label: '通知服务', + description: '配置飞书与企业微信通知。', icon: 'bell', fields: [ + { + envKey: 'FEISHU_ENABLED', + label: '启用飞书通知', + description: '是否启用飞书通知', + type: 'boolean', + sensitive: false, + value: true, + hasValue: true, + source: 'db', + }, { envKey: 'FEISHU_WEBHOOK_URL', label: '飞书 Webhook URL', - description: '用于发送审查通知', + description: '用于发送飞书通知', type: 'url', sensitive: false, value: 'https://open.feishu.cn/mock/webhook', hasValue: true, source: 'db', }, + { + envKey: 'WECOM_ENABLED', + label: '启用企业微信通知', + description: '是否启用企业微信通知', + type: 'boolean', + sensitive: false, + value: false, + hasValue: true, + source: 'db', + }, ], }, { @@ -237,6 +257,23 @@ const modelSuggestions = { gemini: ['gemini-2.5-pro'], }; +const notificationTestHistory = [ + { + id: 'test-1', + provider: 'feishu', + status: 'success', + message: 'feishu 测试通知已发送', + timestamp: '2026-03-24T09:00:00.000Z', + }, + { + id: 'test-2', + provider: 'wecom', + status: 'error', + message: 'wecom 未启用或未配置', + timestamp: '2026-03-24T08:50:00.000Z', + }, +]; + const json = async (route: Route, body: unknown, status = 200) => { await route.fulfill({ status, @@ -279,6 +316,14 @@ export async function installVisualApiMocks(page: Page) { return route.fulfill({ status: 204, body: '' }); } + if (method === 'POST' && path.endsWith('/admin/api/config/notification/test')) { + return json(route, { success: true, message: 'test sent' }); + } + + if (method === 'GET' && path.endsWith('/admin/api/config/notification/test/history')) { + return json(route, { data: notificationTestHistory }); + } + if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) { return json(route, modelSuggestions); }