mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
feat(frontend): add dedicated notification management menu and test panel
This commit is contained in:
@@ -4,6 +4,7 @@ import { LoginPage } from './pages/LoginPage';
|
|||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import { RepositoryManager } from './components/RepositoryManager';
|
import { RepositoryManager } from './components/RepositoryManager';
|
||||||
import { ConfigManager } from './components/ConfigManager';
|
import { ConfigManager } from './components/ConfigManager';
|
||||||
|
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
@@ -51,6 +52,7 @@ function AppContent() {
|
|||||||
<Route index element={<Navigate to="/repos" replace />} />
|
<Route index element={<Navigate to="/repos" replace />} />
|
||||||
<Route path="repos" element={<RepositoryManager />} />
|
<Route path="repos" element={<RepositoryManager />} />
|
||||||
<Route path="config" element={<ConfigManager />} />
|
<Route path="config" element={<ConfigManager />} />
|
||||||
|
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface ConfigGroupCardProps {
|
|||||||
onFieldChange: (envKey: string, value: any) => void;
|
onFieldChange: (envKey: string, value: any) => void;
|
||||||
onReset: (keys: string[]) => void;
|
onReset: (keys: string[]) => void;
|
||||||
isResetting: boolean;
|
isResetting: boolean;
|
||||||
|
headerActions?: React.ReactNode;
|
||||||
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
|
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
|
||||||
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
|
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,7 @@ export function ConfigGroupCard({
|
|||||||
onFieldChange,
|
onFieldChange,
|
||||||
onReset,
|
onReset,
|
||||||
isResetting,
|
isResetting,
|
||||||
|
headerActions,
|
||||||
renderField,
|
renderField,
|
||||||
}: ConfigGroupCardProps) {
|
}: ConfigGroupCardProps) {
|
||||||
const hasOverride = group.fields.some((f) => f.source === 'db');
|
const hasOverride = group.fields.some((f) => f.source === 'db');
|
||||||
@@ -69,17 +71,22 @@ export function ConfigGroupCard({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasOverride && (
|
{(headerActions || hasOverride) && (
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
{headerActions}
|
||||||
size="sm"
|
{hasOverride && (
|
||||||
onClick={handleReset}
|
<Button
|
||||||
disabled={isResetting}
|
variant="outline"
|
||||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
size="sm"
|
||||||
>
|
onClick={handleReset}
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
disabled={isResetting}
|
||||||
重置组配置
|
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||||
</Button>
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
重置组配置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="theme-card-content divide-y divide-border/50">
|
<CardContent className="theme-card-content divide-y divide-border/50">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */
|
/** 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() {
|
export function ConfigManager() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
379
frontend/src/components/NotificationConfigPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useTheme } from 'next-themes';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||||
@@ -9,6 +9,7 @@ import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
||||||
{ path: '/config', label: '系统配置', icon: Sliders },
|
{ path: '/config', label: '系统配置', icon: Sliders },
|
||||||
|
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||||
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
|
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
|
||||||
const isConfigPage = location.pathname.startsWith('/config');
|
const isConfigPage = location.pathname.startsWith('/config');
|
||||||
|
const isNotificationPage = location.pathname.startsWith('/notifications');
|
||||||
const isReviewConfigPage = location.pathname.startsWith('/review-config');
|
const isReviewConfigPage = location.pathname.startsWith('/review-config');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,7 +207,7 @@ export default function DashboardPage() {
|
|||||||
<main className="flex-1 overflow-y-auto relative">
|
<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-background/95 backdrop-blur-[1px] -z-10"></div>
|
||||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.025] -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 />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ export interface ConfigResponse {
|
|||||||
groups: ConfigGroupDto[];
|
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> => {
|
export const fetchConfig = async (): Promise<ConfigResponse> => {
|
||||||
const response = await api.get<ConfigResponse>('/config');
|
const response = await api.get<ConfigResponse>('/config');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -42,3 +57,12 @@ export const updateConfig = async (configData: Record<string, string>): Promise<
|
|||||||
export const resetConfig = async (keys: string[]): Promise<void> => {
|
export const resetConfig = async (keys: string[]): Promise<void> => {
|
||||||
await api.post('/config/reset', { keys });
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -31,21 +31,41 @@ const configResponse = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'feishu',
|
key: 'notification',
|
||||||
label: '飞书通知',
|
label: '通知服务',
|
||||||
description: '配置飞书 webhook 通知。',
|
description: '配置飞书与企业微信通知。',
|
||||||
icon: 'bell',
|
icon: 'bell',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
envKey: 'FEISHU_ENABLED',
|
||||||
|
label: '启用飞书通知',
|
||||||
|
description: '是否启用飞书通知',
|
||||||
|
type: 'boolean',
|
||||||
|
sensitive: false,
|
||||||
|
value: true,
|
||||||
|
hasValue: true,
|
||||||
|
source: 'db',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
envKey: 'FEISHU_WEBHOOK_URL',
|
envKey: 'FEISHU_WEBHOOK_URL',
|
||||||
label: '飞书 Webhook URL',
|
label: '飞书 Webhook URL',
|
||||||
description: '用于发送审查通知',
|
description: '用于发送飞书通知',
|
||||||
type: 'url',
|
type: 'url',
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
value: 'https://open.feishu.cn/mock/webhook',
|
value: 'https://open.feishu.cn/mock/webhook',
|
||||||
hasValue: true,
|
hasValue: true,
|
||||||
source: 'db',
|
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'],
|
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) => {
|
const json = async (route: Route, body: unknown, status = 200) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status,
|
status,
|
||||||
@@ -279,6 +316,14 @@ export async function installVisualApiMocks(page: Page) {
|
|||||||
return route.fulfill({ status: 204, body: '' });
|
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')) {
|
if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) {
|
||||||
return json(route, modelSuggestions);
|
return json(route, modelSuggestions);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user