chore: update meessage
This commit is contained in:
@@ -84,7 +84,6 @@ func (nc *NotificationController) TestChannel(c *gin.Context) {
|
|||||||
utils.Success(c, result)
|
utils.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// GetBindings 获取事件绑定列表
|
// GetBindings 获取事件绑定列表
|
||||||
func (nc *NotificationController) GetBindings(c *gin.Context) {
|
func (nc *NotificationController) GetBindings(c *gin.Context) {
|
||||||
bindings := nc.notifyService.GetBindings()
|
bindings := nc.notifyService.GetBindings()
|
||||||
@@ -94,11 +93,12 @@ func (nc *NotificationController) GetBindings(c *gin.Context) {
|
|||||||
// SaveBinding 保存事件绑定
|
// SaveBinding 保存事件绑定
|
||||||
func (nc *NotificationController) SaveBinding(c *gin.Context) {
|
func (nc *NotificationController) SaveBinding(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
WayID string `json:"way_id"`
|
WayID string `json:"way_id"`
|
||||||
DataID string `json:"data_id"`
|
DataID string `json:"data_id"`
|
||||||
|
Extra models.BigText `json:"extra"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
utils.BadRequest(c, "参数错误")
|
utils.BadRequest(c, "参数错误")
|
||||||
@@ -116,6 +116,7 @@ func (nc *NotificationController) SaveBinding(c *gin.Context) {
|
|||||||
Event: req.Event,
|
Event: req.Event,
|
||||||
WayID: req.WayID,
|
WayID: req.WayID,
|
||||||
DataID: req.DataID,
|
DataID: req.DataID,
|
||||||
|
Extra: req.Extra,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := nc.notifyService.SaveBinding(binding); err != nil {
|
if err := nc.notifyService.SaveBinding(binding); err != nil {
|
||||||
@@ -142,6 +143,31 @@ func (nc *NotificationController) DeleteBinding(c *gin.Context) {
|
|||||||
utils.SuccessMsg(c, "删除成功")
|
utils.SuccessMsg(c, "删除成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchSaveBindings 批量保存事件绑定
|
||||||
|
func (nc *NotificationController) BatchSaveBindings(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
DataID string `json:"data_id"`
|
||||||
|
Bindings []models.NotifyBinding `json:"bindings"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type == "" {
|
||||||
|
utils.BadRequest(c, "类型不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nc.notifyService.BatchSaveBindings(req.Type, req.DataID, req.Bindings); err != nil {
|
||||||
|
utils.ServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessMsg(c, "保存成功")
|
||||||
|
}
|
||||||
|
|
||||||
// SendNotification API 发送通知(供脚本调用)
|
// SendNotification API 发送通知(供脚本调用)
|
||||||
func (nc *NotificationController) SendNotification(c *gin.Context) {
|
func (nc *NotificationController) SendNotification(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
@@ -13,11 +13,18 @@ type NotifyBinding struct {
|
|||||||
Event string `json:"event" gorm:"size:50;not null;index"` // 事件类型
|
Event string `json:"event" gorm:"size:50;not null;index"` // 事件类型
|
||||||
WayID string `json:"way_id" gorm:"size:20;not null;index"` // 通知渠道ID
|
WayID string `json:"way_id" gorm:"size:20;not null;index"` // 通知渠道ID
|
||||||
DataID string `json:"data_id" gorm:"size:20;index"` // 关联ID,系统事件为空,任务事件为任务ID
|
DataID string `json:"data_id" gorm:"size:20;index"` // 关联ID,系统事件为空,任务事件为任务ID
|
||||||
|
Extra BigText `json:"extra"` // 额外配置(如是否开启日志推送等,对应 BindingExtra 结构)
|
||||||
CreatedAt LocalTime `json:"created_at"`
|
CreatedAt LocalTime `json:"created_at"`
|
||||||
UpdatedAt LocalTime `json:"updated_at"`
|
UpdatedAt LocalTime `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindingExtra 存储在 Extra 字段中的 JSON 配置
|
||||||
|
type BindingExtra struct {
|
||||||
|
EnableLog bool `json:"enable_log"`
|
||||||
|
LogLimit int `json:"log_limit"` // 日志字数限制,默认 1000
|
||||||
|
}
|
||||||
|
|
||||||
func (NotifyBinding) TableName() string {
|
func (NotifyBinding) TableName() string {
|
||||||
return constant.TablePrefix + "notify_bindings"
|
return constant.TablePrefix + "notify_bindings"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ func registerNotificationRoutes(g *gin.RouterGroup, c *Controllers) {
|
|||||||
notify.POST("/channels/test", c.Notification.TestChannel)
|
notify.POST("/channels/test", c.Notification.TestChannel)
|
||||||
notify.GET("/bindings", c.Notification.GetBindings)
|
notify.GET("/bindings", c.Notification.GetBindings)
|
||||||
notify.POST("/bindings", c.Notification.SaveBinding)
|
notify.POST("/bindings", c.Notification.SaveBinding)
|
||||||
|
notify.POST("/bindings/batch", c.Notification.BatchSaveBindings)
|
||||||
notify.DELETE("/bindings/:id", c.Notification.DeleteBinding)
|
notify.DELETE("/bindings/:id", c.Notification.DeleteBinding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/sdk/messenger"
|
"github.com/engigu/baihu-panel/internal/sdk/messenger"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NotifyChannel 通知渠道配置
|
// NotifyChannel 通知渠道配置
|
||||||
@@ -155,9 +157,13 @@ func (s *NotificationService) SaveBinding(binding *models.NotifyBinding) error {
|
|||||||
res := database.DB.Where("type = ? AND event = ? AND way_id = ? AND data_id = ?",
|
res := database.DB.Where("type = ? AND event = ? AND way_id = ? AND data_id = ?",
|
||||||
binding.Type, binding.Event, binding.WayID, binding.DataID).Limit(1).Find(&existing)
|
binding.Type, binding.Event, binding.WayID, binding.DataID).Limit(1).Find(&existing)
|
||||||
if res.Error == nil && res.RowsAffected > 0 {
|
if res.Error == nil && res.RowsAffected > 0 {
|
||||||
// 如果已存在且未删除,直接返回(或者更新它)
|
// 如果已存在且未删除,更新现有记录(特别是 Extra 字段)
|
||||||
*binding = existing
|
existing.Extra = binding.Extra
|
||||||
return nil
|
err := database.DB.Save(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
*binding = existing
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.ID = utils.GenerateID()
|
binding.ID = utils.GenerateID()
|
||||||
@@ -166,6 +172,29 @@ func (s *NotificationService) SaveBinding(binding *models.NotifyBinding) error {
|
|||||||
return database.DB.Save(binding).Error
|
return database.DB.Save(binding).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchSaveBindings 批量保存事件绑定
|
||||||
|
func (s *NotificationService) BatchSaveBindings(bindingType, dataID string, bindings []models.NotifyBinding) error {
|
||||||
|
return database.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 如果指定了 dataID,先清理该对象的所有现有绑定
|
||||||
|
if dataID != "" {
|
||||||
|
if err := tx.Unscoped().Where("type = ? AND data_id = ?", bindingType, dataID).Delete(&models.NotifyBinding{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入新绑定
|
||||||
|
for i := range bindings {
|
||||||
|
bindings[i].ID = utils.GenerateID()
|
||||||
|
bindings[i].Type = bindingType
|
||||||
|
bindings[i].DataID = dataID
|
||||||
|
if err := tx.Create(&bindings[i]).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteBinding 删除事件绑定
|
// DeleteBinding 删除事件绑定
|
||||||
func (s *NotificationService) DeleteBinding(id string) error {
|
func (s *NotificationService) DeleteBinding(id string) error {
|
||||||
return database.DB.Unscoped().Where("id = ?", id).Delete(&models.NotifyBinding{}).Error
|
return database.DB.Unscoped().Where("id = ?", id).Delete(&models.NotifyBinding{}).Error
|
||||||
@@ -288,6 +317,14 @@ func (s *NotificationService) SubscribeEvents(bus *eventbus.EventBus) {
|
|||||||
bus.Subscribe(constant.EventSystemNotice, s.handleEvent(constant.BindingTypeSystem))
|
bus.Subscribe(constant.EventSystemNotice, s.handleEvent(constant.BindingTypeSystem))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ansiRegexp = regexp.MustCompile(`[\x1b\x9b][\[()#;?]*([0-9]{1,4}(;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`)
|
||||||
|
|
||||||
|
// stripAnsi 移除字符串中的 ANSI 转义码(如颜色代码)
|
||||||
|
func stripAnsi(str string) string {
|
||||||
|
return ansiRegexp.ReplaceAllString(str, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEvent 处理事件订阅并发送通知
|
||||||
func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
||||||
return func(e eventbus.Event) {
|
return func(e eventbus.Event) {
|
||||||
payload, ok := e.Payload.(map[string]interface{})
|
payload, ok := e.Payload.(map[string]interface{})
|
||||||
@@ -338,7 +375,6 @@ func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := &NotifyMessage{Title: title, Text: text}
|
|
||||||
bindings := s.GetBindingsByEvent(bindingType, e.Type, dataID)
|
bindings := s.GetBindingsByEvent(bindingType, e.Type, dataID)
|
||||||
if len(bindings) == 0 {
|
if len(bindings) == 0 {
|
||||||
return
|
return
|
||||||
@@ -355,12 +391,38 @@ func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
|||||||
if !ok || !ch.Enabled {
|
if !ok || !ch.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
go func(channel NotifyChannel) {
|
|
||||||
result := s.SendToChannel(channel, msg)
|
// 克隆文本以便修改
|
||||||
|
currentText := text
|
||||||
|
|
||||||
|
// 解析额外配置
|
||||||
|
var extra models.BindingExtra
|
||||||
|
if binding.Extra != "" {
|
||||||
|
_ = json.Unmarshal([]byte(binding.Extra), &extra)
|
||||||
|
}
|
||||||
|
// 默认日志限制为 1000
|
||||||
|
if extra.LogLimit <= 0 {
|
||||||
|
extra.LogLimit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果开启了日志推送
|
||||||
|
if extra.EnableLog {
|
||||||
|
if output, ok := payload["output"].(string); ok && output != "" {
|
||||||
|
// 仅保留指定字数的日志内容并移除 ANSI 颜色代码
|
||||||
|
logSnippet := stripAnsi(output)
|
||||||
|
if len(logSnippet) > extra.LogLimit {
|
||||||
|
logSnippet = "...\n" + logSnippet[len(logSnippet)-extra.LogLimit:]
|
||||||
|
}
|
||||||
|
currentText += "\n\n【执行日志】\n" + logSnippet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(channel NotifyChannel, msgTitle, msgText string) {
|
||||||
|
result := s.SendToChannel(channel, &NotifyMessage{Title: msgTitle, Text: msgText})
|
||||||
if !result.Success {
|
if !result.Success {
|
||||||
logger.Warnf("[Notify] 发送事件 %s 到渠道 %s(%s) 失败: %s", e.Type, channel.Name, channel.Type, result.Error)
|
logger.Warnf("[Notify] 发送事件 %s 到渠道 %s(%s) 失败: %s", e.Type, channel.Name, channel.Type, result.Error)
|
||||||
}
|
}
|
||||||
}(ch)
|
}(ch, title, currentText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,8 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest,
|
|||||||
"task_name": task.Name,
|
"task_name": task.Name,
|
||||||
"status": result.Status,
|
"status": result.Status,
|
||||||
"duration": result.Duration,
|
"duration": result.Duration,
|
||||||
|
"output": result.Output,
|
||||||
|
"error": result.Error,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -346,6 +348,7 @@ func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, er
|
|||||||
"task_id": taskID,
|
"task_id": taskID,
|
||||||
"task_name": taskName,
|
"task_name": taskName,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
"output": output,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ export const api = {
|
|||||||
getBindings: () => request<NotifyBinding[]>('/notify/bindings'),
|
getBindings: () => request<NotifyBinding[]>('/notify/bindings'),
|
||||||
saveBinding: (data: Partial<NotifyBinding>) =>
|
saveBinding: (data: Partial<NotifyBinding>) =>
|
||||||
request<NotifyBinding>('/notify/bindings', { method: 'POST', body: JSON.stringify(data) }),
|
request<NotifyBinding>('/notify/bindings', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
saveBindingsBatch: (data: { type: string; data_id: string; bindings: Partial<NotifyBinding>[] }) =>
|
||||||
|
request('/notify/bindings/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
deleteBinding: (id: string) => request('/notify/bindings/' + id, { method: 'DELETE' }),
|
deleteBinding: (id: string) => request('/notify/bindings/' + id, { method: 'DELETE' }),
|
||||||
send: (data: { channel_id: string; title: string; text: string }) =>
|
send: (data: { channel_id: string; title: string; text: string }) =>
|
||||||
request<NotifyResult>('/notify/send', { method: 'POST', body: JSON.stringify(data) })
|
request<NotifyResult>('/notify/send', { method: 'POST', body: JSON.stringify(data) })
|
||||||
@@ -595,10 +597,16 @@ export interface NotifyBinding {
|
|||||||
event: string
|
event: string
|
||||||
way_id: string
|
way_id: string
|
||||||
data_id: string
|
data_id: string
|
||||||
|
extra?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BindingExtra {
|
||||||
|
enable_log: boolean
|
||||||
|
log_limit: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotifyResult {
|
export interface NotifyResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { X, Plus, Shield, Terminal, Search } from 'lucide-vue-next'
|
import { X, Plus, Shield, Terminal, Search, FileText } from 'lucide-vue-next'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import type { NotifyChannel, ChannelType, EventType, NotifyBinding, Task } from '@/api'
|
import type { NotifyChannel, ChannelType, EventType, NotifyBinding, Task } from '@/api'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -87,7 +88,8 @@ function addChannelToEvent(eventType: string, bindingType: 'system' | 'task') {
|
|||||||
type: bindingType,
|
type: bindingType,
|
||||||
event: eventType,
|
event: eventType,
|
||||||
way_id: channelId,
|
way_id: channelId,
|
||||||
data_id: dataId
|
data_id: dataId,
|
||||||
|
extra: JSON.stringify({ enable_log: false, log_limit: 1000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('save', [newBinding])
|
emit('save', [newBinding])
|
||||||
@@ -105,6 +107,49 @@ function getAvailableChannels(eventType: string, bindingType: 'system' | 'task')
|
|||||||
function removeBinding(binding: NotifyBinding) {
|
function removeBinding(binding: NotifyBinding) {
|
||||||
emit('delete', binding.id)
|
emit('delete', binding.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 日志推送开关逻辑
|
||||||
|
function isLogEnabled(binding: NotifyBinding): boolean {
|
||||||
|
if (!binding.extra) return false
|
||||||
|
try {
|
||||||
|
const extra = JSON.parse(binding.extra)
|
||||||
|
return extra.enable_log === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogLimit(binding: NotifyBinding): number {
|
||||||
|
if (!binding.extra) return 1000
|
||||||
|
try {
|
||||||
|
const extra = JSON.parse(binding.extra)
|
||||||
|
return extra.log_limit || 1000
|
||||||
|
} catch {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLog(binding: NotifyBinding, enabled: boolean) {
|
||||||
|
const extra: any = binding.extra ? JSON.parse(binding.extra) : { log_limit: 1000 }
|
||||||
|
extra.enable_log = enabled
|
||||||
|
|
||||||
|
const updatedBinding: Partial<NotifyBinding> = {
|
||||||
|
...binding,
|
||||||
|
extra: JSON.stringify(extra)
|
||||||
|
}
|
||||||
|
emit('save', [updatedBinding])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLogLimit(binding: NotifyBinding, limit: number) {
|
||||||
|
const extra: any = binding.extra ? JSON.parse(binding.extra) : { enable_log: false }
|
||||||
|
extra.log_limit = limit || 1000
|
||||||
|
|
||||||
|
const updatedBinding: Partial<NotifyBinding> = {
|
||||||
|
...binding,
|
||||||
|
extra: JSON.stringify(extra)
|
||||||
|
}
|
||||||
|
emit('save', [updatedBinding])
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -253,15 +298,40 @@ function removeBinding(binding: NotifyBinding) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已绑定渠道 -->
|
<!-- 已绑定渠道 -->
|
||||||
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] items-center">
|
<div class="flex flex-wrap gap-3 mb-3 min-h-[40px] items-center">
|
||||||
<template v-if="getBindings(event.type, false).length > 0">
|
<template v-if="getBindings(event.type, false).length > 0">
|
||||||
<div v-for="binding in getBindings(event.type, false)" :key="binding.id"
|
<div v-for="binding in getBindings(event.type, false)" :key="binding.id"
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-secondary/50 border text-[11px] font-medium">
|
class="group relative flex flex-col gap-1.5 p-2 rounded-lg bg-secondary/30 border border-border/50 text-[11px] font-medium min-w-[130px] hover:bg-secondary/50 transition-all">
|
||||||
<span class="truncate max-w-[100px]">{{ getChannelName(binding.way_id) }}</span>
|
<div class="flex items-center justify-between gap-1.5">
|
||||||
<button @click="removeBinding(binding)"
|
<span class="truncate max-w-[90px] text-xs">{{ getChannelName(binding.way_id) }}</span>
|
||||||
class="hover:text-destructive p-0.5 rounded-sm transition-colors">
|
<button @click="removeBinding(binding)"
|
||||||
<X class="w-3 h-3" />
|
class="text-muted-foreground hover:text-destructive p-0.5 rounded-md hover:bg-destructive/10 transition-colors">
|
||||||
</button>
|
<X class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志推送开关 -->
|
||||||
|
<div class="flex flex-col mt-1 pt-1.5 border-t border-border/30">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-1 opacity-70 group-hover:opacity-100 transition-opacity">
|
||||||
|
<FileText class="w-2.5 h-2.5" />
|
||||||
|
<span class="text-[9px]">发送日志</span>
|
||||||
|
</div>
|
||||||
|
<Switch :checked="isLogEnabled(binding)"
|
||||||
|
@update:checked="(val: boolean) => toggleLog(binding, val)" class="scale-75 origin-right" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志字数限制配置 -->
|
||||||
|
<div v-if="isLogEnabled(binding)"
|
||||||
|
class="flex items-center gap-1 mt-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<div class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-background/50 border border-border/30 focus-within:border-primary/30 transition-all shadow-sm">
|
||||||
|
<input type="text" inputmode="numeric" :value="getLogLimit(binding)"
|
||||||
|
@change="(e: any) => setLogLimit(binding, parseInt(e.target.value.replace(/\D/g, '')))"
|
||||||
|
class="w-10 h-3.5 text-center text-[9px] font-mono bg-transparent border-none outline-none focus:ring-0 p-0" />
|
||||||
|
<span class="text-[8px] text-muted-foreground opacity-40 select-none">字</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="text-[11px] text-muted-foreground italic">未绑定渠道</span>
|
<span v-else class="text-[11px] text-muted-foreground italic">未绑定渠道</span>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
||||||
import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, Loader2, AlertCircle, Terminal, Clock, Zap } from 'lucide-vue-next'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, Loader2, AlertCircle, Terminal, Clock, Zap, Bell } from 'lucide-vue-next'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api'
|
import { api, type Task, type EnvVar, type Agent, type MiseLanguage, type NotifyChannel, type NotifyBinding } from '@/api'
|
||||||
import { PATHS, TRIGGER_TYPE } from '@/constants'
|
import { PATHS, TRIGGER_TYPE } from '@/constants'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { getCronDescription } from '@/utils/cron'
|
import { getCronDescription } from '@/utils/cron'
|
||||||
@@ -139,6 +140,130 @@ const availablePlugins = ref<string[]>([])
|
|||||||
const pluginSearch = ref('')
|
const pluginSearch = ref('')
|
||||||
const versionSearch = ref('')
|
const versionSearch = ref('')
|
||||||
|
|
||||||
|
// 通知配置相关
|
||||||
|
const notifyChannels = ref<NotifyChannel[]>([])
|
||||||
|
const notifyWayId = ref<string>('none')
|
||||||
|
const notifyOnSuccess = ref(false)
|
||||||
|
const notifyOnFailure = ref(false)
|
||||||
|
const notifyOnTimeout = ref(false)
|
||||||
|
const notifyIncludeLog = ref(false)
|
||||||
|
const notifyLogLimit = ref(1000)
|
||||||
|
|
||||||
|
async function loadNotificationConfig() {
|
||||||
|
try {
|
||||||
|
notifyChannels.value = await api.notify.getChannels()
|
||||||
|
if (props.isEdit && props.task?.id) {
|
||||||
|
const allBindings = await api.notify.getBindings()
|
||||||
|
const taskBindings = allBindings.filter(b => b.data_id === props.task!.id && b.type === 'task')
|
||||||
|
|
||||||
|
console.log('DEBUG [loadNotificationConfig] TaskID:', props.task!.id, 'Total:', allBindings.length, 'Matched:', taskBindings.length)
|
||||||
|
|
||||||
|
if (taskBindings.length > 0) {
|
||||||
|
notifyWayId.value = taskBindings[0].way_id
|
||||||
|
|
||||||
|
// 使用 nextTick 确保 UI 响应
|
||||||
|
setTimeout(() => {
|
||||||
|
notifyOnSuccess.value = taskBindings.some(b => b.event === 'task_success')
|
||||||
|
notifyOnFailure.value = taskBindings.some(b => b.event === 'task_failed')
|
||||||
|
notifyOnTimeout.value = taskBindings.some(b => b.event === 'task_timeout')
|
||||||
|
|
||||||
|
console.log('DEBUG [loadNotificationConfig] States Set:', {
|
||||||
|
success: notifyOnSuccess.value,
|
||||||
|
failure: notifyOnFailure.value,
|
||||||
|
timeout: notifyOnTimeout.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 尝试解析日志配置
|
||||||
|
const extraBinding = taskBindings.find(b => b.extra && b.extra !== '')
|
||||||
|
if (extraBinding) {
|
||||||
|
try {
|
||||||
|
const extra = JSON.parse(extraBinding.extra)
|
||||||
|
notifyIncludeLog.value = !!extra.enable_log
|
||||||
|
notifyLogLimit.value = extra.log_limit || 1000
|
||||||
|
} catch {
|
||||||
|
notifyIncludeLog.value = false
|
||||||
|
notifyLogLimit.value = 1000
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notifyIncludeLog.value = false
|
||||||
|
notifyLogLimit.value = 1000
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
} else {
|
||||||
|
resetNotificationConfig()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetNotificationConfig()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Load notifications failed', e)
|
||||||
|
resetNotificationConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNotificationConfig() {
|
||||||
|
notifyWayId.value = 'none'
|
||||||
|
notifyOnSuccess.value = false
|
||||||
|
notifyOnFailure.value = false
|
||||||
|
notifyOnTimeout.value = false
|
||||||
|
notifyIncludeLog.value = false
|
||||||
|
notifyLogLimit.value = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotifications(taskId: string) {
|
||||||
|
console.log('DEBUG [saveNotifications]:', {
|
||||||
|
taskId,
|
||||||
|
notifyWayId: notifyWayId.value,
|
||||||
|
notifyOnSuccess: notifyOnSuccess.value,
|
||||||
|
notifyOnFailure: notifyOnFailure.value,
|
||||||
|
notifyOnTimeout: notifyOnTimeout.value,
|
||||||
|
notifyIncludeLog: notifyIncludeLog.value,
|
||||||
|
notifyLogLimit: notifyLogLimit.value
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bindings: Partial<NotifyBinding>[] = []
|
||||||
|
|
||||||
|
if (notifyWayId.value !== 'none') {
|
||||||
|
const events = [
|
||||||
|
{ type: 'task_success', enabled: notifyOnSuccess.value },
|
||||||
|
{ type: 'task_failed', enabled: notifyOnFailure.value },
|
||||||
|
{ type: 'task_timeout', enabled: notifyOnTimeout.value }
|
||||||
|
]
|
||||||
|
|
||||||
|
const extra = JSON.stringify({
|
||||||
|
enable_log: notifyIncludeLog.value,
|
||||||
|
log_limit: notifyLogLimit.value
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.enabled) {
|
||||||
|
bindings.push({
|
||||||
|
event: event.type,
|
||||||
|
way_id: notifyWayId.value,
|
||||||
|
extra: extra
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('DEBUG [saveNotifications] payload bindings:', bindings)
|
||||||
|
|
||||||
|
// 调用批量保存,后端会自动清理该任务旧的绑定
|
||||||
|
await api.notify.saveBindingsBatch({
|
||||||
|
type: 'task',
|
||||||
|
data_id: taskId,
|
||||||
|
bindings: bindings
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Save notifications failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(notifyOnSuccess, (val) => console.log('DEBUG [notifyOnSuccess]:', val))
|
||||||
|
watch(notifyOnFailure, (val) => console.log('DEBUG [notifyOnFailure]:', val))
|
||||||
|
watch(notifyOnTimeout, (val) => console.log('DEBUG [notifyOnTimeout]:', val))
|
||||||
|
|
||||||
const filteredPlugins = computed(() => {
|
const filteredPlugins = computed(() => {
|
||||||
if (!pluginSearch.value) return availablePlugins.value
|
if (!pluginSearch.value) return availablePlugins.value
|
||||||
const s = pluginSearch.value.toLowerCase()
|
const s = pluginSearch.value.toLowerCase()
|
||||||
@@ -329,6 +454,8 @@ watch(() => props.open, async (val) => {
|
|||||||
updateAvailableVersions(lang)
|
updateAvailableVersions(lang)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 加载通知配置
|
||||||
|
await loadNotificationConfig()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -426,10 +553,12 @@ async function save() {
|
|||||||
: currentWorkDir.value
|
: currentWorkDir.value
|
||||||
|
|
||||||
if (props.isEdit && form.value.id) {
|
if (props.isEdit && form.value.id) {
|
||||||
await api.tasks.update(form.value.id, form.value)
|
const task = await api.tasks.update(form.value.id, form.value)
|
||||||
|
await saveNotifications(task.id)
|
||||||
toast.success('任务已更新')
|
toast.success('任务已更新')
|
||||||
} else {
|
} else {
|
||||||
await api.tasks.create(form.value)
|
const task = await api.tasks.create(form.value)
|
||||||
|
await saveNotifications(task.id)
|
||||||
toast.success('任务已创建')
|
toast.success('任务已创建')
|
||||||
}
|
}
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
@@ -865,19 +994,90 @@ async function save() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 bg-muted/20 border-t shrink-0 backdrop-blur-sm">
|
<!-- 通知配置 Section -->
|
||||||
<p class="text-[10px] text-muted-foreground/50 italic">最后编辑于: {{ isEdit ? (form.updated_at || '刚才') : '现在' }}</p>
|
<section class="space-y-4">
|
||||||
<div class="flex gap-3">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<Button variant="ghost" size="sm" class="hover:bg-muted font-medium text-xs px-6" @click="emit('update:open', false)">取消</Button>
|
<div class="h-4 w-1 bg-primary rounded-full" />
|
||||||
<Button size="sm" class="px-8 font-semibold text-xs shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98] bg-primary hover:bg-primary/90" @click="save">
|
<h3 class="text-sm font-semibold text-foreground/80">通知配置</h3>
|
||||||
确定保存
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
<div class="grid gap-4 pl-3 border-l border-muted">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-3">
|
||||||
|
<Label class="sm:text-right text-xs text-muted-foreground uppercase tracking-wider">通知渠道</Label>
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<Select v-model="notifyWayId">
|
||||||
|
<SelectTrigger class="h-9 bg-muted/30 border-muted-foreground/20">
|
||||||
|
<SelectValue placeholder="不启用通知" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">不启用通知</SelectItem>
|
||||||
|
<SelectItem v-for="ch in notifyChannels" :key="ch.id" :value="ch.id">
|
||||||
|
{{ ch.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="notifyWayId !== 'none'">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-3">
|
||||||
|
<Label class="sm:text-right text-xs text-muted-foreground uppercase tracking-wider pt-1.5">通知时机</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-3">
|
||||||
|
<div class="flex flex-wrap gap-4 p-3 rounded-lg bg-muted/20 border border-muted-foreground/10 items-center">
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
<Checkbox :id="`ns-${props.task?.id || 'new'}`" v-model:checked="notifyOnSuccess" />
|
||||||
|
<label :for="`ns-${props.task?.id || 'new'}`" class="text-xs shrink-0 cursor-pointer group-hover:text-primary transition-colors">成功时</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
<Checkbox :id="`nf-${props.task?.id || 'new'}`" v-model:checked="notifyOnFailure" />
|
||||||
|
<label :for="`nf-${props.task?.id || 'new'}`" class="text-xs shrink-0 cursor-pointer group-hover:text-primary transition-colors">失败时</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
<Checkbox :id="`nt-${props.task?.id || 'new'}`" v-model:checked="notifyOnTimeout" />
|
||||||
|
<label :for="`nt-${props.task?.id || 'new'}`" class="text-xs shrink-0 cursor-pointer group-hover:text-primary transition-colors">超时时</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 rounded-xl bg-primary/5 border border-primary/10 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||||
|
<Bell :class="cn('h-3.5 w-3.5', notifyIncludeLog ? 'text-primary' : 'text-muted-foreground')" />
|
||||||
|
附带执行日志
|
||||||
|
</div>
|
||||||
|
<Switch :model-value="notifyIncludeLog" @update:model-value="(v: boolean) => notifyIncludeLog = v" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notifyIncludeLog" class="flex items-center gap-2 animate-in fade-in slide-in-from-top-1 duration-200 pl-5">
|
||||||
|
<div class="flex items-center gap-2 px-2.5 py-1 rounded-full bg-background/60 border border-muted-foreground/10 focus-within:border-primary/30 transition-all shadow-sm">
|
||||||
|
<span class="text-[10px] text-muted-foreground opacity-60 whitespace-nowrap">长度限制</span>
|
||||||
|
<div class="h-3 w-[1px] bg-muted-foreground/10" />
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input type="text" inputmode="numeric" :value="notifyLogLimit" @input="(e: any) => notifyLogLimit = Number(e.target.value.replace(/\D/g, ''))"
|
||||||
|
class="w-14 h-4 text-center text-[11px] font-mono bg-transparent border-none outline-none focus:ring-0 p-0" />
|
||||||
|
<span class="text-[10px] text-muted-foreground opacity-40">字</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 bg-muted/20 border-t shrink-0 backdrop-blur-sm">
|
||||||
|
<p class="text-[10px] text-muted-foreground/50 italic">最后编辑于: {{ isEdit ? (form.updated_at || '刚才') : '现在' }}</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<Button variant="ghost" size="sm" class="hover:bg-muted font-medium text-xs px-6" @click="emit('update:open', false)">取消</Button>
|
||||||
|
<Button size="sm" class="px-8 font-semibold text-xs shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98] bg-primary hover:bg-primary/90" @click="save">
|
||||||
|
确定保存
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user