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);
}