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 字段
This commit is contained in:
Logan.Li committed 2026-10-27 09:07:42 +08:00
1 parent 88d8fc282e
commit 581d0a8351
12 files changed
+1415 -2

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ import WebhookConfig from '@/pages/WebhookConfig';
import GitOpsDeploy from '@/pages/GitOpsDeploy';
import PluginManager from '@/pages/PluginManager';
import SystemSettings from '@/pages/SystemSettings';
import AiAssistant from '@/components/ai/AiAssistant';
import { apiClient } from '@/lib/api';
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
@@ -166,6 +167,7 @@ export default function App() {
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{renderContent()}
</div>
<AiAssistant onNavigate={(m) => setActiveModule(m as ModuleType)} />
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useState, useRef, useEffect } from 'react';
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot } from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface Props {
onNavigate?: (module: string) => void;
}
interface ChatItem {
role: 'user' | 'assistant';
content: string;
action?: any; // {type:'release', jobs, branch, env}
actionState?: 'pending' | 'done' | 'cancelled';
result?: { triggered: any[]; failed: any[] };
}
const suggestions = [
'发布 aiops-backend、aiops-web 到 test 分支',
'把后端两个服务发到 pre',
];
export default function AiAssistant({ onNavigate }: Props) {
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [messages, setMessages] = useState<ChatItem[]>([
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
]);
const listRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: apiClient.getSettings,
enabled: open,
});
const aiConnected = settings?.llmEnabled && settings?.llmApiKeySet;
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, open]);
const send = async (text?: string) => {
const content = (text ?? input).trim();
if (!content || sending) return;
setInput('');
const userMsg: ChatItem = { role: 'user', content };
const next = [...messages, userMsg];
setMessages(next);
setSending(true);
try {
const history = next.slice(-10).map((m) => ({ role: m.role, content: m.content }));
const res = await apiClient.aiChat(history);
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply, action: res.action, actionState: res.action ? 'pending' : undefined }]);
} catch (err) {
setMessages((prev) => [...prev, { role: 'assistant', content: '请求失败:' + (err as Error).message }]);
} finally {
setSending(false);
}
};
const confirmRelease = async (index: number) => {
const msg = messages[index];
if (!msg?.action) return;
const jobs = msg.action.jobs.filter((j: any) => j.matched || j.source === 'jenkins' || true).map((j: any) => j.name);
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'done' } : m)));
try {
const res = await apiClient.aiRelease({
jobs,
branch: msg.action.branch,
env: msg.action.env,
autoCreate: true,
});
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, result: res } : m)));
const okN = res.triggered?.length || 0;
const failN = res.failed?.length || 0;
if (okN > 0) toast.success(`已触发 ${okN} 个服务${failN ? `,${failN} 个失败` : ''}`);
queryClient.invalidateQueries({ queryKey: ['runs'] });
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
} catch (err) {
toast.error('发布失败:' + (err as Error).message);
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'pending' } : m)));
}
};
const cancelAction = (index: number) => {
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'cancelled' } : m)));
};
return (
<>
{/* 悬浮按钮 */}
<button
onClick={() => setOpen(!open)}
className="fixed bottom-6 right-6 z-50 w-12 h-12 rounded-full shadow-lg flex items-center justify-center transition-transform hover:scale-105"
style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}
title="FlowStack 智能助手"
>
{open ? <X className="w-5 h-5 text-white" /> : <Sparkles className="w-5 h-5 text-white" />}
</button>
{/* 对话面板 */}
{open && (
<div
className="fixed bottom-22 right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{/* 头部 */}
<div className="px-4 py-3 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'linear-gradient(135deg, rgba(9,105,218,0.06), rgba(130,80,223,0.06))' }}>
<div className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
<Bot className="w-4 h-4 text-white" />
</div>
<div className="flex-1">
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>FlowStack 智能助手</div>
<div className="text-[10px] flex items-center gap-1" style={{ color: aiConnected ? '#1a7f37' : 'var(--text-tertiary)' }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: aiConnected ? '#1a7f37' : '#9a6700' }} />
{aiConnected ? 'AI 已连接' : '本地规则模式(系统设置可接入 AI)'}
</div>
</div>
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-black/5">
<X className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* 消息列表 */}
<div ref={listRef} className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3 space-y-3">
{messages.map((m, i) => (
<div key={i} className={m.role === 'user' ? 'flex justify-end' : 'flex justify-start'}>
<div className="max-w-[85%]">
<div
className="px-3 py-2 rounded-xl text-[12px] leading-5 whitespace-pre-wrap"
style={
m.role === 'user'
? { background: 'var(--accent-blue)', color: '#fff', borderBottomRightRadius: 4 }
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
}
>
{m.content}
</div>
{/* 发布计划卡片 */}
{m.action && m.action.type === 'release' && (
<div className="mt-2 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
<div className="text-[11px] font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--text-primary)' }}>
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />
发布计划 · {m.action.jobs.length} 个服务
{m.action.branch && <span className="font-normal" style={{ color: 'var(--text-tertiary)' }}>@{m.action.branch}</span>}
{m.action.env && <span className="font-normal" style={{ color: 'var(--text-tertiary)' }}>env:{m.action.env}</span>}
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
{m.action.jobs.map((j: any, ji: number) => (
<span
key={ji}
className="text-[10px] px-2 py-[2px] rounded-md font-mono"
style={{
background: j.matched ? '#dafbe1' : j.source === 'jenkins' ? '#ddf4ff' : '#ffebe9',
color: j.matched ? '#1a7f37' : j.source === 'jenkins' ? '#0969da' : '#cf222e',
}}
title={j.matched ? '已匹配流水线' : j.source === 'jenkins' ? 'Jenkins Job(发布时自动创建流水线)' : '未匹配'}
>
{j.name}
</span>
))}
</div>
{m.actionState === 'pending' && (
<div className="flex gap-2">
<button
onClick={() => confirmRelease(i)}
className="px-3 py-1 text-[11px] rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
确认发布
</button>
<button onClick={() => cancelAction(i)} className="px-3 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
取消
</button>
</div>
)}
{m.result && (
<div className="space-y-1">
{m.result.triggered?.map((t: any, ti: number) => (
<div key={ti} className="flex items-center gap-1.5 text-[10px]">
<CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} />
<span className="font-mono" style={{ color: 'var(--text-secondary)' }}>{t.job}</span>
{t.created && <span className="px-1 rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>自动创建</span>}
<span className="font-mono" style={{ color: 'var(--text-tertiary)' }}>{t.runId}</span>
</div>
))}
{m.result.failed?.map((f: any, fi: number) => (
<div key={fi} className="flex items-center gap-1.5 text-[10px]">
<XCircle className="w-3 h-3" style={{ color: '#cf222e' }} />
<span className="font-mono" style={{ color: 'var(--text-secondary)' }}>{f.job}</span>
<span style={{ color: '#cf222e' }}>{f.reason}</span>
</div>
))}
{(m.result.triggered?.length > 0) && (
<button
onClick={() => { onNavigate?.('release-records'); setOpen(false); }}
className="mt-1 flex items-center gap-1 text-[10px] px-2 py-1 rounded border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}
>
<History className="w-3 h-3" />查看发布记录
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
))}
{sending && (
<div className="flex justify-start">
<div className="px-3 py-2 rounded-xl flex items-center gap-1.5" style={{ background: 'var(--panel-bg-secondary)' }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>思考中…</span>
</div>
</div>
)}
</div>
{/* 快捷示例 */}
{messages.length <= 1 && (
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
{suggestions.map((s) => (
<button
key={s}
onClick={() => send(s)}
className="text-[10px] px-2 py-1 rounded-full border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
{s}
</button>
))}
</div>
)}
{/* 输入区 */}
<div className="px-3 py-2.5 border-t flex items-center gap-2" style={{ borderColor: 'var(--border-color)' }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) send(); }}
placeholder="描述要发布的服务,如:发布 agent 三件套到 pre"
className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
<button
onClick={() => send()}
disabled={sending || !input.trim()}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
style={{ background: 'var(--accent-blue)' }}
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from 'react';
import { ClipboardPaste, Play, Loader2, CheckCircle2, XCircle, History, Wand2 } from 'lucide-react';
import { toast } from 'sonner';
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface Props {
onViewRecords?: () => void;
}
const branchHints = ['pre', 'origin/pre', 'test', 'origin/test', 'main'];
const exampleText = 'cex-agent-server\ncex-agent-api\ncex-agent-admin\ncex-cex-web-nextjs\ncex-agent-admin-web';
export default function PasteRelease({ onViewRecords }: Props) {
const [text, setText] = useState('');
const [refType, setRefType] = useState<'branch' | 'tag'>('branch');
const [fixedRef, setFixedRef] = useState('test');
const [parallel, setParallel] = useState(false);
const [autoCreate, setAutoCreate] = useState(true);
const [preview, setPreview] = useState<any>(null);
const [parseError, setParseError] = useState('');
const [result, setResult] = useState<any>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
// 700ms 防抖自动解析
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!text.trim()) {
setPreview(null);
setParseError('');
return;
}
timerRef.current = setTimeout(async () => {
try {
const res = await apiClient.batchParse({ text, refType, fixedRef });
setPreview(res);
setParseError('');
} catch (err) {
setPreview(null);
setParseError((err as Error).message);
}
}, 700);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [text, refType, fixedRef]);
const releaseMutation = useMutation({
mutationFn: () =>
apiClient.aiRelease({
jobs: (preview?.jobs || []).map((j: any) => j.name),
branch: refType === 'branch' ? fixedRef : undefined,
parallel,
autoCreate,
}),
onSuccess: (res) => {
setResult(res);
const okN = res.triggered?.length || 0;
const failN = res.failed?.length || 0;
if (okN > 0) toast.success(`已触发 ${okN} 个服务${failN ? `,${failN} 个失败` : ''}`);
else toast.error('全部触发失败');
},
onError: (err: Error) => toast.error('发布失败', { description: err.message }),
});
const jobs = preview?.jobs || [];
const matchedCount = jobs.filter((j: any) => j.matched || j.autoCreatable).length;
return (
<div className="max-w-3xl mx-auto space-y-4">
{/* 粘贴区 */}
<div className="rounded-lg border p-5" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ClipboardPaste className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
<h3 className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布清单</h3>
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>每行一个服务;# 开头为注释</span>
</div>
<button onClick={() => setText(exampleText)} className="text-[11px] px-2 py-1 rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
填入示例
</button>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={refType === 'branch' ? 'cex-agent-server\ncex-agent-api\ncex-agent-admin' : 'cex-openapi-api\nV2.08\ncex-openapi-svc\nV2.09'}
rows={7}
className="w-full px-3 py-2.5 text-[12px] font-mono rounded-md border outline-none resize-y"
style={inputStyle}
/>
{/* 配置行 */}
<div className="flex items-center gap-3 mt-3 flex-wrap">
<div className="flex items-center rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
{(['branch', 'tag'] as const).map((t) => (
<button
key={t}
onClick={() => setRefType(t)}
className="px-3 py-1 text-[11px] font-medium"
style={{ background: refType === t ? 'var(--accent-blue)' : 'transparent', color: refType === t ? '#fff' : 'var(--text-secondary)' }}
>
{t === 'branch' ? '分支' : 'TAG'}
</button>
))}
</div>
{refType === 'branch' && (
<>
<input value={fixedRef} onChange={(e) => setFixedRef(e.target.value)} placeholder="分支名" className="px-2.5 py-1 text-[11px] rounded-md border outline-none font-mono w-28" style={inputStyle} />
{branchHints.map((b) => (
<button key={b} onClick={() => setFixedRef(b)} className="text-[10px] px-2 py-[3px] rounded-full border font-mono"
style={{ borderColor: fixedRef === b ? 'var(--accent-blue)' : 'var(--border-color-light)', color: fixedRef === b ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>
{b}
</button>
))}
</>
)}
<label className="flex items-center gap-1.5 text-[11px] ml-auto cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
<input type="checkbox" checked={autoCreate} onChange={(e) => setAutoCreate(e.target.checked)} />
未导入的 Jenkins Job 自动创建流水线
</label>
</div>
<div className="flex items-center gap-3 mt-2">
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
<input type="checkbox" checked={parallel} onChange={(e) => setParallel(e.target.checked)} />
并发发布(默认串行,逐个触发)
</label>
</div>
</div>
{/* 解析预览 */}
{(parseError || preview) && (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: parseError ? '#cf222e' : 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<Wand2 className="w-3.5 h-3.5" style={{ color: parseError ? '#cf222e' : 'var(--accent-blue)' }} />
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>{parseError ? '解析失败' : '解析结果'}</span>
{!parseError && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{preview.count} 个 · 可发布 {matchedCount} 个{preview.param ? ` · 参数 ${preview.param}` : ''}</span>}
</div>
{!parseError && (
<button
onClick={() => releaseMutation.mutate()}
disabled={releaseMutation.isPending || matchedCount === 0}
className="flex items-center gap-1.5 px-3.5 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
style={{ background: 'var(--accent-blue)' }}
>
{releaseMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
开始{parallel ? '并发' : '串行'}发布
</button>
)}
</div>
{parseError ? (
<div className="px-4 py-3 text-[12px]" style={{ color: '#cf222e' }}>{parseError}</div>
) : (
<div>
{jobs.map((j: any, i: number) => (
<div key={i} className="grid grid-cols-[36px_1fr_130px_110px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{i + 1}</span>
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{j.name}</span>
<span className="font-mono" style={{ color: 'var(--text-tertiary)' }}>{j.version}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded w-fit" style={{
background: j.matched ? '#dafbe1' : j.autoCreatable ? '#ddf4ff' : '#ffebe9',
color: j.matched ? '#1a7f37' : j.autoCreatable ? '#0969da' : '#cf222e',
}}>
{j.matched ? (j.source === 'jenkins' ? 'Jenkins 已导入' : '流水线') : j.autoCreatable ? 'Jenkins · 将自动创建' : '未找到'}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* 执行结果 */}
{result && (
<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 justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>
触发结果 · 成功 {result.triggered?.length || 0} / 失败 {result.failed?.length || 0}
</span>
<button onClick={onViewRecords} className="flex items-center gap-1 text-[11px] px-2 py-1 rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}>
<History className="w-3 h-3" />查看发布记录
</button>
</div>
{result.triggered?.map((t: any, i: number) => (
<div key={i} className="flex items-center gap-2 px-4 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{t.job}</span>
{t.created && <span className="text-[9px] px-1 rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>自动创建</span>}
<span className="font-mono ml-auto" style={{ color: 'var(--text-tertiary)' }}>{t.runId}</span>
</div>
))}
{result.failed?.map((f: any, i: number) => (
<div key={i} className="flex items-center gap-2 px-4 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
<XCircle className="w-3.5 h-3.5" style={{ color: '#cf222e' }} />
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{f.job}</span>
<span className="ml-auto" style={{ color: '#cf222e' }}>{f.reason}</span>
</div>
))}
</div>
)}
{text && !preview && !parseError && (
<div className="flex items-center justify-center gap-2 py-4 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" />解析中…
</div>
)}
</div>
);
}
+8
View File
@@ -145,4 +145,12 @@ export const apiClient = {
listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`),
importJenkinsJobs: (id: any, jobs: string[]) =>
api<any>(`/api/jenkins-servers/${id}/import`, { method: 'POST', body: JSON.stringify({ jobs }) }),
// AI 助手与批量发布
aiChat: (messages: { role: string; content: string }[]) =>
api<any>('/api/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
aiRelease: (b: { jobs: string[]; branch?: string; env?: string; parallel?: boolean; jenkinsServerId?: number; autoCreate?: boolean }) =>
api<any>('/api/ai/release', { method: 'POST', body: JSON.stringify(b) }),
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
}
+20 -1
View File
@@ -6,6 +6,7 @@ import {
import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import PasteRelease from '@/components/batch/PasteRelease';
// ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
interface PipelineDTO {
@@ -64,6 +65,7 @@ const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
const queryClient = useQueryClient();
const [inputMode, setInputMode] = useState<'paste' | 'wizard'>('paste');
const [currentStep, setCurrentStep] = useState<StepType>('select');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 发布配置(本地状态)
@@ -220,10 +222,25 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
<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>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>
{/* 输入方式切换:粘贴清单 / 手动向导 */}
<div className="flex items-center rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
{[{ id: 'paste', label: '粘贴清单' }, { id: 'wizard', label: '手动勾选' }].map((m) => (
<button key={m.id} onClick={() => setInputMode(m.id as 'paste' | 'wizard')} className="px-3 py-[3px] text-[11px] font-medium"
style={{ background: inputMode === m.id ? 'var(--accent-blue)' : 'transparent', color: inputMode === m.id ? '#fff' : 'var(--text-secondary)' }}>
{m.label}
</button>
))}
</div>
{inputMode === 'wizard' && <span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>}
</div>
</div>
{inputMode === 'paste' ? (
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<PasteRelease onViewRecords={handleViewRecords} />
</div>
) : (
<>
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{steps.map((step, idx) => {
const isActive = idx === stepIndex;
@@ -446,6 +463,8 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
</div>
)}
</div>
</>
)}
</div>
);
}
+72
View File
@@ -66,6 +66,7 @@ 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 });
@@ -74,8 +75,38 @@ export default function SystemSettings() {
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),
@@ -282,6 +313,47 @@ export default function SystemSettings() {
</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>
);