Files
FlowStack/frontend/src/pages/SystemSettings.tsx
T
Logan.Li 581d0a8351 feat: Jenkins Job 批量发布 + 全局 AI 智能助手 (v0.3.0)
参考 opsx-jenkins 发布清单:
- 批量发布页新增「粘贴清单」模式:多行 Job 名粘贴(#注释/内联对/
  名称版本交替)、TAG/分支切换、分支快捷 chips、700ms 防抖解析预览、
  串行/并发发布;未导入的 Jenkins Job 自动创建流水线后触发
- 解析器移植 opsx-jenkins parser(参数头识别/成对校验/重名检测)

全局 AI 智能助手(右下角悬浮):
- 自然语言「发布一下 cex-agent-server 等 5 个」→ 解析服务+分支+环境
  → 发布计划卡片(匹配状态着色)→ 确认 → 真实触发 → runId 回显 +
  跳转发布记录
- LLM 接入:OpenAI 兼容接口(DeepSeek/GLM/Qwen),系统设置页配置
  API Key(加密存储)+ 测试连接;未配置时自动降级本地规则模式,
  服务名/分支关键词匹配,功能不中断
- 候选池 = 流水线 + Jenkins Job(60s 缓存);精确/忽略大小写/包含
  三级匹配

新端点:POST /api/ai/chat、/api/ai/release、/api/batch/parse、
POST /api/settings/test-llm;SystemSetting 扩展 LLM 字段
2026-10-27 09:07:42 +08:00

361 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import {
Palette, Monitor, Moon, Sun,
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface ThemeConfig {
mode: 'light' | 'dark' | 'system';
primaryColor: string;
fontSize: 'small' | 'medium' | 'large';
density: 'compact' | 'comfortable' | 'spacious';
sidebarStyle: 'collapsed' | 'icons' | 'full';
borderRadius: 'sharp' | 'soft' | 'round';
}
interface NotificationConfig {
email: boolean;
browser: boolean;
pipelineSuccess: boolean;
pipelineFailed: boolean;
securityAlert: boolean;
}
const defaultTheme: ThemeConfig = {
mode: 'light',
primaryColor: '#0969da',
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
};
const defaultNotifications: NotificationConfig = {
email: true,
browser: true,
pipelineSuccess: false,
pipelineFailed: true,
securityAlert: true,
};
// 后端 theme / notification 均以 JSON 字符串存储,安全解析并合并默认值
function safeParse<T extends object>(raw: unknown, fallback: T): T {
if (typeof raw !== 'string' || !raw) return fallback;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return { ...fallback, ...parsed };
return fallback;
} catch {
return fallback;
}
}
const colorOptions = [
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
{ name: '紫色', value: '#8250df', class: 'bg-[#8250df]' },
{ name: '橙色', value: '#bc4c00', class: 'bg-[#bc4c00]' },
{ name: '红色', value: '#cf222e', class: 'bg-[#cf222e]' },
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
];
export default function SystemSettings() {
const queryClient = useQueryClient();
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
// 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态)
useEffect(() => {
if (!settings) return;
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
setLlm((prev) => ({
...prev,
enabled: !!settings.llmEnabled,
endpoint: settings.llmEndpoint || '',
model: settings.llmModel || '',
apiKey: '', // 只写:回显永远留空
}));
}, [settings]);
const saveLlmMutation = useMutation({
mutationFn: () => {
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
if (llm.apiKey) body.llmApiKey = llm.apiKey;
return apiClient.updateSettings(body);
},
onSuccess: () => {
toast.success('AI 配置已保存');
setLlm((p) => ({ ...p, apiKey: '' }));
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const testLlmMutation = useMutation({
mutationFn: apiClient.testLLM,
onSuccess: (res) => {
if (res.success) toast.success('AI 连接成功', { description: res.message });
else toast.error('AI 连接失败', { description: res.message });
},
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
});
const saveMutation = useMutation({
mutationFn: () => apiClient.updateSettings({
theme: JSON.stringify(theme),
notification: JSON.stringify(notifications),
}),
onSuccess: () => {
toast.success('设置已保存', { description: '主题和通知设置已更新' });
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: Error) => toast.error(err.message),
});
const handleReset = () => {
setTheme(defaultTheme);
setNotifications(defaultNotifications);
toast.info('已恢复默认设置', { description: '仅本地重置,点击「保存设置」后生效' });
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
{!settings && (
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-3 h-3 animate-spin" />加载中
</span>
)}
</div>
<div className="flex items-center gap-2">
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className="w-3.5 h-3.5" />恢复默认
</button>
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{saveMutation.isPending ? '保存中...' : '保存设置'}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="max-w-3xl mx-auto space-y-4">
{/* Appearance */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Palette className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>外观主题</span>
</div>
<div className="p-4 space-y-4">
{/* Mode */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式</label>
<div className="flex items-center gap-2">
{[
{ id: 'light', label: '浅色', icon: Sun },
{ id: 'dark', label: '深色', icon: Moon },
{ id: 'system', label: '跟随系统', icon: Monitor },
].map(m => (
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] }))}
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.mode === m.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
<m.icon className="w-3.5 h-3.5" />{m.label}
</button>
))}
</div>
</div>
{/* Primary color */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题色</label>
<div className="flex items-center gap-2">
{colorOptions.map(c => (
<button key={c.value} onClick={() => setTheme(p => ({ ...p, primaryColor: c.value }))}
className={`w-8 h-8 rounded-full ${c.class} flex items-center justify-center transition-transform hover:scale-110`}
title={c.name}>
{theme.primaryColor === c.value && <CheckCircle2 className="w-4 h-4 text-white" />}
</button>
))}
</div>
</div>
{/* Font size */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>字体大小</label>
<div className="flex items-center gap-2">
{[
{ id: 'small', label: '小', size: '12px' },
{ id: 'medium', label: '中', size: '14px' },
{ id: 'large', label: '大', size: '16px' },
].map(f => (
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as ThemeConfig['fontSize'] }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
style={{
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
fontSize: f.size,
}}>
{f.label}
</button>
))}
</div>
</div>
{/* Density */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>界面密度</label>
<div className="flex items-center gap-2">
{[
{ id: 'compact', label: '紧凑' },
{ id: 'comfortable', label: '舒适' },
{ id: 'spacious', label: '宽松' },
].map(d => (
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as ThemeConfig['density'] }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
{d.label}
</button>
))}
</div>
</div>
{/* Border radius */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>圆角风格</label>
<div className="flex items-center gap-2">
{[
{ id: 'sharp', label: '直角', radius: '0px' },
{ id: 'soft', label: '圆角', radius: '6px' },
{ id: 'round', label: '大圆角', radius: '12px' },
].map(r => (
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as ThemeConfig['borderRadius'] }))}
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
style={{
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
borderRadius: r.radius,
}}>
{r.label}
</button>
))}
</div>
</div>
</div>
</div>
{/* Notifications */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Bell className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>通知设置</span>
</div>
<div className="p-4 space-y-3">
{[
{ key: 'email', label: '邮件通知', desc: '通过邮件接收重要通知' },
{ key: 'browser', label: '浏览器通知', desc: '在浏览器中显示推送通知' },
{ key: 'pipelineSuccess', label: '流水线成功通知', desc: '流水线执行成功时发送通知' },
{ key: 'pipelineFailed', label: '流水线失败通知', desc: '流水线执行失败时发送通知(推荐开启)' },
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={notifications[item.key as keyof NotificationConfig]}
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{item.label}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{item.desc}</div>
</div>
</label>
))}
</div>
</div>
{/* Language & Region */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Globe className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>语言与区域</span>
</div>
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>简体中文</option><option>English</option><option>日本語</option>
</select></div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
</select></div>
</div>
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>语言与时区暂未生效,仅作展示</p>
</div>
</div>
</div>
{/* AI 助手配置 */}
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>AI 助手(智能发布)</span>
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.llmEnabled && settings?.llmApiKeySet ? '#dafbe1' : '#fff8c5', color: settings?.llmEnabled && settings?.llmApiKeySet ? '#1a7f37' : '#9a6700' }}>
{settings?.llmEnabled && settings?.llmApiKeySet ? '已启用' : '未启用(规则模式)'}
</span>
</div>
</div>
<div className="p-5 space-y-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={llm.enabled} onChange={(e) => setLlm({ ...llm, enabled: e.target.checked })} />
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
</label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
<input value={llm.model} onChange={(e) => setLlm({ ...llm, model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label>
<input type="password" value={llm.apiKey} onChange={(e) => setLlm({ ...llm, apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div>
</div>
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
<div className="flex items-center gap-2">
<button onClick={() => saveLlmMutation.mutate()} disabled={saveLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
</button>
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
{testLlmMutation.isPending ? '测试中…' : '测试连接(需先保存)'}
</button>
</div>
</div>
</div>
</div>
</div>
);
}