feat(frontend): add dedicated notification management menu and test panel

This commit is contained in:
jeffusion
2026-03-24 12:59:34 +08:00
committed by 路遥知码力
parent e40daddf0d
commit 9964614b5e
7 changed files with 477 additions and 18 deletions

View File

@@ -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() {
<Route index element={<Navigate to="/repos" replace />} />
<Route path="repos" element={<RepositoryManager />} />
<Route path="config" element={<ConfigManager />} />
<Route path="notifications" element={<NotificationConfigPage />} />
<Route path="review-config" element={<ReviewConfigPage />} />
<Route path="*" element={<Navigate to="/repos" replace />} />
</Route>

View File

@@ -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,6 +71,9 @@ export function ConfigGroupCard({
</CardDescription>
</div>
</div>
{(headerActions || hasOverride) && (
<div className="flex items-center gap-2">
{headerActions}
{hasOverride && (
<Button
variant="outline"
@@ -81,6 +86,8 @@ export function ConfigGroupCard({
</Button>
)}
</div>
)}
</CardHeader>
<CardContent className="theme-card-content divide-y divide-border/50">
{group.fields.map((field) => {

View File

@@ -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();

View File

@@ -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<Record<string, any>>({});
const [hasChanges, setHasChanges] = useState(false);
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
});
const {
data: testHistory,
isLoading: isHistoryLoading,
} = useQuery<NotificationTestRecordDto[], Error>({
queryKey: ['notification-test-history'],
queryFn: fetchNotificationTestHistory,
refetchInterval: 10000,
});
useEffect(() => {
if (data) {
const initialState: Record<string, any> = {};
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<string, string>) => 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<string, string> = {};
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<ConfigGroupDto[]>(() => {
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 (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-10 w-48 bg-muted/60" />
<Skeleton className="h-10 w-24 bg-muted/60" />
</div>
<Skeleton className="h-[280px] w-full rounded-xl bg-muted/60 border border-border/60" />
</div>
);
}
if (isError) {
return (
<div className="theme-error-panel flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger" />
<div className="font-medium tracking-wide">: {error.message}</div>
</div>
);
}
return (
<div className="theme-page-frame">
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
<div className="theme-page-actions">
<Button
variant="outline"
onClick={handleResetAll}
disabled={!hasOverrides || resetMutation.isPending}
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
>
{saveMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> ...
</span>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
<div className="theme-page-content">
{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 (
<ConfigGroupCard
key={group.key}
group={group}
localConfig={localConfig}
onFieldChange={handleFieldChange}
onReset={handleResetGroup}
isResetting={resetMutation.isPending}
headerActions={
<Button
variant="outline"
size="sm"
onClick={() => 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 ? '测试中...' : '测试发送'}
</Button>
}
/>
);
})}
<div className="rounded-xl border border-border/60 bg-card p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold tracking-wide text-foreground"></h3>
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['notification-test-history'] })}
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
>
</Button>
</div>
{isHistoryLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full bg-muted/60" />
<Skeleton className="h-10 w-full bg-muted/60" />
</div>
) : (testHistory?.length ?? 0) === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{testHistory?.slice(0, 10).map((record) => (
<div
key={record.id}
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 md:flex-row md:items-center md:justify-between"
>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-border text-foreground">
{getProviderLabel(record.provider)}
</Badge>
<Badge
className={
record.status === 'success'
? 'bg-success/15 text-success border-success/30'
: 'bg-danger/15 text-danger border-danger/30'
}
>
{record.status === 'success' ? '成功' : '失败'}
</Badge>
<span className="text-sm text-muted-foreground">{record.message}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(record.timestamp).toLocaleString('zh-CN')}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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() {
<main className="flex-1 overflow-y-auto relative">
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.025] -z-10"></div>
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isNotificationPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
<Outlet />
</div>
</main>

View File

@@ -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<ConfigResponse> => {
const response = await api.get<ConfigResponse>('/config');
return response.data;
@@ -42,3 +57,12 @@ export const updateConfig = async (configData: Record<string, string>): Promise<
export const resetConfig = async (keys: string[]): Promise<void> => {
await api.post('/config/reset', { keys });
};
export const testNotification = async (provider: NotificationTestProvider): Promise<void> => {
await api.post('/config/notification/test', { provider });
};
export const fetchNotificationTestHistory = async (): Promise<NotificationTestRecordDto[]> => {
const response = await api.get<NotificationTestHistoryResponse>('/config/notification/test/history');
return response.data.data;
};

View File

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