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:
1 parent
88d8fc282e
commit
581d0a8351
12 files changed
+1415
-2
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user