feat: 发布成功/失败飞书&Lark 群卡片通知

- internal/lark:飞书自定义机器人交互卡片(wide_screen_mode + 彩色头部
  green/red/yellow + lark_md 字段区 + 查看构建详情按钮),支持签名加签
  (timestamp+sign HMAC-SHA256,密钥 enc:v1: 加密存储)
- 双引擎收口:原生模拟引擎与 Jenkins 真实引擎运行结束均推送卡片;
  应用/分支/构建(#N 或 runId)/状态/时间;Jenkins 构建附
  externalURL 按钮直达构建详情页
- Webhook 优先级:流水线「通知 Webhook」> 全局设置(需启用)
- 系统设置新增飞书通知配置区(启用开关/Webhook/签名密钥只写/
  发送测试卡片);POST /api/settings/test-lark
- E2E 已验证:本地模拟机器人收到完整卡片 JSON,测试卡片正常
This commit is contained in:
Logan.Li committed 2026-10-27 09:37:23 +08:00
1 parent 6e97b55015
commit d9d1dd5e99
10 files changed
+329 -9

No files matched your search

+1
View File
@@ -153,4 +153,5 @@ export const apiClient = {
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' }),
testLark: () => api<any>('/api/settings/test-lark', { method: 'POST' }),
}
+68
View File
@@ -67,6 +67,7 @@ export default function SystemSettings() {
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' });
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
@@ -82,8 +83,37 @@ export default function SystemSettings() {
model: settings.llmModel || '',
apiKey: '', // 只写:回显永远留空
}));
setLark((prev) => ({
...prev,
enabled: !!settings.larkEnabled,
webhook: settings.larkWebhook || '',
secret: '',
}));
}, [settings]);
const saveLarkMutation = useMutation({
mutationFn: () => {
const body: any = { larkEnabled: lark.enabled, larkWebhook: lark.webhook };
if (lark.secret) body.larkSecret = lark.secret;
return apiClient.updateSettings(body);
},
onSuccess: () => {
toast.success('飞书通知配置已保存');
setLark((p) => ({ ...p, secret: '' }));
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const testLarkMutation = useMutation({
mutationFn: () => apiClient.testLark(),
onSuccess: (res: any) => {
if (res.success) toast.success('测试卡片已发送', { description: '请到飞书群查看' });
else toast.error('发送失败', { description: res.message });
},
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
});
const saveLlmMutation = useMutation({
mutationFn: () => {
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
@@ -384,6 +414,44 @@ export default function SystemSettings() {
</div>
</div>
</div>
{/* 飞书 / Lark 通知 */}
<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)' }}>飞书 / Lark 群通知</span>
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.larkEnabled && settings?.larkWebhook ? '#dafbe1' : '#f6f8fa', color: settings?.larkEnabled && settings?.larkWebhook ? '#1a7f37' : '#6e7781' }}>
{settings?.larkEnabled && settings?.larkWebhook ? '已启用' : '未启用'}
</span>
</div>
</div>
<div className="p-5 space-y-4">
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
发布成功/失败后向群推送结果卡片(应用 / 分支 / 构建 / 状态 / 时间 + 「查看构建详情」按钮跳转 Jenkins 构建页)。
流水线单独配置的「通知 Webhook」优先生效。
</p>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={lark.enabled} onChange={(e) => setLark({ ...lark, enabled: e.target.checked })} />
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用全局群通知</span>
</label>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>群机器人 Webhook URL</label>
<input value={lark.webhook} onChange={(e) => setLark({ ...lark, webhook: e.target.value })} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" 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 type="password" value={lark.secret} onChange={(e) => setLark({ ...lark, secret: e.target.value })} placeholder={settings?.larkSecretSet ? '已配置,留空不修改' : '无需签名可留空'} 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="flex items-center gap-2">
<button onClick={() => saveLarkMutation.mutate()} disabled={saveLarkMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
{saveLarkMutation.isPending ? '保存中…' : '保存通知配置'}
</button>
<button onClick={() => testLarkMutation.mutate()} disabled={testLarkMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
{testLarkMutation.isPending ? '发送中…' : '发送测试卡片(需先保存)'}
</button>
</div>
</div>
</div>
</div>
</div>
);