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:
1 parent
6e97b55015
commit
d9d1dd5e99
10 files changed
+329
-9
No files matched your search
@@ -209,6 +209,10 @@ func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, j
|
|||||||
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
|
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 发布结束后回查最新构建信息再推飞书卡片(含 buildNumber/externalURL)
|
||||||
|
var fresh model.PipelineRun
|
||||||
|
database.DB.First(&fresh, run.ID)
|
||||||
|
go notifyLarkRelease(fresh, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("$ jenkins build \"" + jobName + "\"")
|
emit("$ jenkins build \"" + jobName + "\"")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/lark"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifyLarkRelease 运行结束后推送飞书发布结果卡片:
|
||||||
|
// Webhook 优先取流水线自带 notifyWebhookURL,否则用全局设置(需启用)。
|
||||||
|
func notifyLarkRelease(run model.PipelineRun, status string) {
|
||||||
|
if status != "success" && status != "failed" && status != "cancelled" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p model.Pipeline
|
||||||
|
database.DB.First(&p, run.PipelineID)
|
||||||
|
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
|
||||||
|
webhook := p.NotifyWebhook
|
||||||
|
if webhook == "" {
|
||||||
|
if !s.LarkEnabled || s.LarkWebhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhook = s.LarkWebhook
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
|
||||||
|
secret = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build := run.RunID
|
||||||
|
if run.BuildNumber > 0 {
|
||||||
|
build = "#" + strconv.Itoa(run.BuildNumber)
|
||||||
|
}
|
||||||
|
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
|
||||||
|
AppName: run.PipelineName,
|
||||||
|
Branch: run.Branch,
|
||||||
|
Build: build,
|
||||||
|
Status: status,
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
BuildURL: run.ExternalURL,
|
||||||
|
})
|
||||||
|
if err := lark.Send(webhook, secret, card); err != nil {
|
||||||
|
log.Printf("[lark] 发布通知发送失败 (%s): %v", run.RunID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[lark] 发布通知已发送: %s %s → %s", run.PipelineName, status, webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLark POST /api/settings/test-lark —— 用已保存配置发测试卡片
|
||||||
|
func TestLark(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhook := c.Query("webhook")
|
||||||
|
if webhook == "" {
|
||||||
|
webhook = s.LarkWebhook
|
||||||
|
}
|
||||||
|
if webhook == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置飞书 Webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
|
||||||
|
secret = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
|
||||||
|
AppName: "flowstack-test",
|
||||||
|
Branch: "test",
|
||||||
|
Build: "#0",
|
||||||
|
Status: "success",
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
BuildURL: "",
|
||||||
|
})
|
||||||
|
if err := lark.Send(webhook, secret, card); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试卡片已发送到群"})
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ func GetSettings(c *gin.Context) {
|
|||||||
database.DB.Create(&s)
|
database.DB.Create(&s)
|
||||||
}
|
}
|
||||||
s.LlmApiKeySet = s.LlmApiKey != ""
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
|
s.LarkSecretSet = s.LarkSecret != ""
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +140,24 @@ func UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updates["llm_api_key"] = enc
|
updates["llm_api_key"] = enc
|
||||||
}
|
}
|
||||||
|
if v, ok := body["larkEnabled"].(bool); ok {
|
||||||
|
updates["lark_enabled"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["larkWebhook"].(string); ok {
|
||||||
|
updates["lark_webhook"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["larkSecret"].(string); ok && v != "" {
|
||||||
|
enc, err := crypto.EncryptField(v)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "签名密钥加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["lark_secret"] = enc
|
||||||
|
}
|
||||||
database.DB.Model(&s).Updates(updates)
|
database.DB.Model(&s).Updates(updates)
|
||||||
database.DB.First(&s, 1)
|
database.DB.First(&s, 1)
|
||||||
s.LlmApiKeySet = s.LlmApiKey != ""
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
|
s.LarkSecretSet = s.LarkSecret != ""
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,13 +127,14 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
|||||||
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
||||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
||||||
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||||
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook
|
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook + 飞书发布卡片
|
||||||
if status == "success" || status == "failed" {
|
if status == "success" || status == "failed" {
|
||||||
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
||||||
"runId": run.RunID, "pipeline": run.PipelineName,
|
"runId": run.RunID, "pipeline": run.PipelineName,
|
||||||
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
go notifyLarkRelease(run, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package lark 飞书/Lark 群自定义机器人卡片通知。
|
||||||
|
package lark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseCardParams 发布结果卡片字段
|
||||||
|
type ReleaseCardParams struct {
|
||||||
|
AppName string // 应用名
|
||||||
|
Branch string // 分支
|
||||||
|
Build string // 构建号(如 #172 或 pr-xxx)
|
||||||
|
Status string // success | failed | 其他
|
||||||
|
Time string // 展示时间
|
||||||
|
BuildURL string // 「查看构建详情」按钮地址(空则不放按钮)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusMeta(status string) (template, text string) {
|
||||||
|
switch status {
|
||||||
|
case "success":
|
||||||
|
return "green", "✅ 成功"
|
||||||
|
case "failed":
|
||||||
|
return "red", "❌ 失败"
|
||||||
|
case "cancelled":
|
||||||
|
return "yellow", "⚠️ 已取消"
|
||||||
|
default:
|
||||||
|
return "yellow", "⚠️ " + status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildReleaseCard 组装发布结果卡片(结构对齐现有 Jenkins 通知脚本)
|
||||||
|
func BuildReleaseCard(p ReleaseCardParams) map[string]any {
|
||||||
|
_, statusText := statusMeta(p.Status)
|
||||||
|
template, _ := statusMeta(p.Status)
|
||||||
|
|
||||||
|
elements := []any{
|
||||||
|
map[string]any{
|
||||||
|
"tag": "div",
|
||||||
|
"text": map[string]any{
|
||||||
|
"tag": "lark_md",
|
||||||
|
"content": fmt.Sprintf(
|
||||||
|
"**应用:** %s\n**分支:** %s\n**构建:** %s\n**状态:** %s\n**时间:** %s",
|
||||||
|
p.AppName, p.Branch, p.Build, statusText, p.Time,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p.BuildURL != "" {
|
||||||
|
elements = append(elements, map[string]any{
|
||||||
|
"tag": "action",
|
||||||
|
"actions": []any{map[string]any{
|
||||||
|
"tag": "button",
|
||||||
|
"text": map[string]any{"tag": "plain_text", "content": "查看构建详情"},
|
||||||
|
"url": p.BuildURL,
|
||||||
|
"type": "default",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statusOnly := statusMeta(p.Status)
|
||||||
|
title := "发布结果通知 - " + statusOnly
|
||||||
|
return map[string]any{
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": map[string]any{
|
||||||
|
"config": map[string]any{"wide_screen_mode": true},
|
||||||
|
"header": map[string]any{
|
||||||
|
"template": template,
|
||||||
|
"title": map[string]any{"tag": "plain_text", "content": title},
|
||||||
|
},
|
||||||
|
"elements": elements,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 发送卡片;secret 非空时按飞书自定义机器人规范加签(timestamp+sign)
|
||||||
|
func Send(webhook, secret string, card map[string]any) error {
|
||||||
|
if webhook == "" {
|
||||||
|
return fmt.Errorf("webhook 未配置")
|
||||||
|
}
|
||||||
|
if secret != "" {
|
||||||
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, []byte(ts+"\n"+secret))
|
||||||
|
mac.Write(nil)
|
||||||
|
card["timestamp"] = ts
|
||||||
|
card["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(card)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Post(webhook, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
// 飞书成功返回 {"code":0,...} / {"StatusCode":0}
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
StatusCode int `json:"StatusCode"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
ErrorMessage string `json:"ErrorMessage"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &out)
|
||||||
|
if out.Code != 0 || out.StatusCode != 0 {
|
||||||
|
msg := out.Msg
|
||||||
|
if msg == "" {
|
||||||
|
msg = out.ErrorMessage
|
||||||
|
}
|
||||||
|
return fmt.Errorf("飞书返回错误: %s", msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -274,6 +274,10 @@ type SystemSetting struct {
|
|||||||
LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读
|
LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读
|
||||||
LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"`
|
LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"`
|
||||||
LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"`
|
LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"`
|
||||||
|
LarkEnabled bool `gorm:"default:false" json:"larkEnabled"`
|
||||||
|
LarkWebhook string `json:"larkWebhook"` // 飞书群自定义机器人 Webhook
|
||||||
|
LarkSecret string `gorm:"type:text" json:"-"` // 加签密钥(可选),enc:v1: 加密
|
||||||
|
LarkSecretSet bool `gorm:"-" json:"larkSecretSet"`
|
||||||
Extra string `gorm:"type:text" json:"extra"`
|
Extra string `gorm:"type:text" json:"extra"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,7 @@ func main() {
|
|||||||
api.GET("/settings", handler.GetSettings)
|
api.GET("/settings", handler.GetSettings)
|
||||||
api.PUT("/settings", handler.UpdateSettings)
|
api.PUT("/settings", handler.UpdateSettings)
|
||||||
api.POST("/settings/test-llm", handler.TestLLM)
|
api.POST("/settings/test-llm", handler.TestLLM)
|
||||||
|
api.POST("/settings/test-lark", handler.TestLark)
|
||||||
|
|
||||||
api.POST("/ai/chat", handler.AiChat)
|
api.POST("/ai/chat", handler.AiChat)
|
||||||
api.POST("/ai/release", handler.AiRelease(hub))
|
api.POST("/ai/release", handler.AiRelease(hub))
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
<head>
|
||||||
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
<meta charset="UTF-8" />
|
||||||
<div style="text-align: center; color: #57606a;">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<h1 style="font-size: 20px;">FlowStack 发布系统</h1>
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
<title>FlowStack · 发布系统</title>
|
||||||
</div>
|
<script type="module" crossorigin src="/assets/index-Ddr58eTO.js"></script>
|
||||||
</body>
|
<link rel="stylesheet" crossorigin href="/assets/index-C7Y81iLv.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -153,4 +153,5 @@ export const apiClient = {
|
|||||||
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
||||||
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
||||||
|
testLark: () => api<any>('/api/settings/test-lark', { method: 'POST' }),
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,7 @@ export default function SystemSettings() {
|
|||||||
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
||||||
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||||
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
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 });
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
|
|
||||||
@@ -82,8 +83,37 @@ export default function SystemSettings() {
|
|||||||
model: settings.llmModel || '',
|
model: settings.llmModel || '',
|
||||||
apiKey: '', // 只写:回显永远留空
|
apiKey: '', // 只写:回显永远留空
|
||||||
}));
|
}));
|
||||||
|
setLark((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.larkEnabled,
|
||||||
|
webhook: settings.larkWebhook || '',
|
||||||
|
secret: '',
|
||||||
|
}));
|
||||||
}, [settings]);
|
}, [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({
|
const saveLlmMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
||||||
@@ -384,6 +414,44 @@ export default function SystemSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user