feat: add message tmplate #76
This commit is contained in:
@@ -68,6 +68,21 @@ const (
|
||||
KeyNotifyChannels = "channels"
|
||||
KeyNotifyEvents = "events"
|
||||
KeyNotifyToken = "notify_token"
|
||||
KeyNotifyPrefix = "notify_prefix"
|
||||
|
||||
// Notify Templates Keys
|
||||
KeyNotifyTemplateUserLoginTitle = "notify_template_user_login_title"
|
||||
KeyNotifyTemplateUserLoginText = "notify_template_user_login_text"
|
||||
KeyNotifyTemplateBruteForceLoginTitle = "notify_template_brute_force_login_title"
|
||||
KeyNotifyTemplateBruteForceLoginText = "notify_template_brute_force_login_text"
|
||||
KeyNotifyTemplatePasswordChangedTitle = "notify_template_password_changed_title"
|
||||
KeyNotifyTemplatePasswordChangedText = "notify_template_password_changed_text"
|
||||
KeyNotifyTemplateTaskSuccessTitle = "notify_template_task_success_title"
|
||||
KeyNotifyTemplateTaskSuccessText = "notify_template_task_success_text"
|
||||
KeyNotifyTemplateTaskFailedTitle = "notify_template_task_failed_title"
|
||||
KeyNotifyTemplateTaskFailedText = "notify_template_task_failed_text"
|
||||
KeyNotifyTemplateTaskTimeoutTitle = "notify_template_task_timeout_title"
|
||||
KeyNotifyTemplateTaskTimeoutText = "notify_template_task_timeout_text"
|
||||
|
||||
// 事件绑定类型
|
||||
BindingTypeSystem = "system"
|
||||
@@ -186,4 +201,21 @@ var DefaultSettings = map[string]map[string]string{
|
||||
KeyQueueSize: "100",
|
||||
KeyRateInterval: "200",
|
||||
},
|
||||
SectionNotify: {
|
||||
KeyNotifyPrefix: "[白虎面板]",
|
||||
// Login
|
||||
KeyNotifyTemplateUserLoginTitle: "用户登录(成功/失败)",
|
||||
KeyNotifyTemplateUserLoginText: "用户 {{username}} 在 IP {{ip}} 登录{{status_label}}\n{{message}}",
|
||||
KeyNotifyTemplateBruteForceLoginTitle: "系统安全警告",
|
||||
KeyNotifyTemplateBruteForceLoginText: "检测到 IP {{ip}} 正在尝试暴力破解用户 {{username}}",
|
||||
KeyNotifyTemplatePasswordChangedTitle: "账户安全通知",
|
||||
KeyNotifyTemplatePasswordChangedText: "用户 {{username}} 刚刚修改了密码",
|
||||
// Task
|
||||
KeyNotifyTemplateTaskSuccessTitle: "任务[{{task_name}}] 成功",
|
||||
KeyNotifyTemplateTaskSuccessText: "任务 #{{task_id}} {{task_name}}\n状态: 成功\n耗时: {{duration}}ms\n执行结果: {{output}}",
|
||||
KeyNotifyTemplateTaskFailedTitle: "任务[{{task_name}}] 失败",
|
||||
KeyNotifyTemplateTaskFailedText: "任务 #{{task_id}} {{task_name}}\n状态: 失败\n执行时间: {{start_time}}\n原因: {{error}}\n最后输出: {{output}}",
|
||||
KeyNotifyTemplateTaskTimeoutTitle: "任务[{{task_name}}] 超时",
|
||||
KeyNotifyTemplateTaskTimeoutText: "任务 #{{task_id}} {{task_name}}\n状态: 超时\n耗时: {{duration}}ms\n最后输出: {{output}}",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -494,6 +494,39 @@ func (sc *SettingsController) RestoreBackup(c *gin.Context) {
|
||||
utils.SuccessMsg(c, "恢复成功")
|
||||
}
|
||||
|
||||
// GetSectionSettings 获取指定 section 的所有设置
|
||||
func (sc *SettingsController) GetSectionSettings(c *gin.Context) {
|
||||
section := c.Param("section")
|
||||
if section == "" {
|
||||
utils.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
settings := sc.settingsService.GetSection(section)
|
||||
utils.Success(c, settings)
|
||||
}
|
||||
|
||||
// UpdateSectionSettings 批量更新指定 section 的设置
|
||||
func (sc *SettingsController) UpdateSectionSettings(c *gin.Context) {
|
||||
section := c.Param("section")
|
||||
if section == "" {
|
||||
utils.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var values map[string]string
|
||||
if err := c.ShouldBindJSON(&values); err != nil {
|
||||
utils.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sc.settingsService.SetSection(section, values); err != nil {
|
||||
utils.ServerError(c, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessMsg(c, "保存成功")
|
||||
}
|
||||
|
||||
// GetSetting 获取单个设置值
|
||||
func (sc *SettingsController) GetSetting(c *gin.Context) {
|
||||
section := c.Param("section")
|
||||
|
||||
@@ -163,6 +163,8 @@ func registerSettingsRoutes(g *gin.RouterGroup, c *Controllers) {
|
||||
settings.GET("/backup/download", c.Settings.DownloadBackup)
|
||||
settings.POST("/restore", c.Settings.RestoreBackup)
|
||||
// 通用设置接口
|
||||
settings.GET("/:section", c.Settings.GetSectionSettings)
|
||||
settings.PUT("/:section", c.Settings.UpdateSectionSettings)
|
||||
settings.GET("/:section/:key", c.Settings.GetSetting)
|
||||
settings.POST("/:section/:key/generate", c.Settings.GenerateSettingToken)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/engigu/baihu-panel/internal/sdk/messenger"
|
||||
"gorm.io/gorm"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NotifyChannel 通知渠道配置
|
||||
@@ -324,6 +325,54 @@ func stripAnsi(str string) string {
|
||||
return ansiRegexp.ReplaceAllString(str, "")
|
||||
}
|
||||
|
||||
// parseTemplate 简单的 {{key}} 模板替换
|
||||
func (s *NotificationService) parseTemplate(tmpl string, payload map[string]interface{}) string {
|
||||
result := tmpl
|
||||
for k, v := range payload {
|
||||
placeholder := fmt.Sprintf("{{%s}}", k)
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
result = strings.ReplaceAll(result, placeholder, valStr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getDefaultMessage 兜底默认消息内容
|
||||
func (s *NotificationService) getDefaultMessage(eventType string, payload map[string]interface{}) (string, string) {
|
||||
var title, text string
|
||||
switch eventType {
|
||||
case constant.EventUserLogin:
|
||||
status, _ := payload["status"].(string)
|
||||
if status == "success" {
|
||||
title = "用户登录成功"
|
||||
text = fmt.Sprintf("用户 %v 在 IP %v 登录成功", payload["username"], payload["ip"])
|
||||
} else {
|
||||
title = "用户登录失败"
|
||||
reason, _ := payload["message"].(string)
|
||||
text = fmt.Sprintf("用户 %v 在 IP %v 登录失败\n原因: %v", payload["username"], payload["ip"], reason)
|
||||
}
|
||||
case constant.EventBruteForceLogin:
|
||||
title = "系统安全警告"
|
||||
text = fmt.Sprintf("检测到 IP %v 正在尝试暴力破解用户 %v", payload["ip"], payload["username"])
|
||||
case constant.EventPasswordChanged:
|
||||
title = "账户安全通知"
|
||||
text = fmt.Sprintf("用户 %v 刚刚修改了密码", payload["username"])
|
||||
case constant.EventTaskSuccess:
|
||||
title = fmt.Sprintf("任务[%v] 成功", payload["task_name"])
|
||||
text = fmt.Sprintf("任务 #%v %v\n状态: 成功\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"])
|
||||
case constant.EventTaskFailed:
|
||||
title = fmt.Sprintf("任务[%v] 失败", payload["task_name"])
|
||||
if errStr, ok := payload["error"]; ok {
|
||||
text = fmt.Sprintf("任务 #%v %v\n执行失败\n执行时间: %v\n错误: %v", payload["task_id"], payload["task_name"], payload["start_time"], errStr)
|
||||
} else {
|
||||
text = fmt.Sprintf("任务 #%v %v\n执行失败\n状态: %v\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["status"], payload["start_time"], payload["duration"])
|
||||
}
|
||||
case constant.EventTaskTimeout:
|
||||
title = fmt.Sprintf("任务[%v] 超时", payload["task_name"])
|
||||
text = fmt.Sprintf("任务 #%v %v\n执行超时\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"])
|
||||
}
|
||||
return title, text
|
||||
}
|
||||
|
||||
// handleEvent 处理事件订阅并发送通知
|
||||
func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
||||
return func(e eventbus.Event) {
|
||||
@@ -338,36 +387,52 @@ func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
||||
}
|
||||
|
||||
var title, text string
|
||||
|
||||
// 获取全局前缀和模板配置
|
||||
prefix := s.settingsService.Get(constant.SectionNotify, constant.KeyNotifyPrefix)
|
||||
var tmplTitleKey, tmplTextKey string
|
||||
|
||||
switch e.Type {
|
||||
case constant.EventUserLogin:
|
||||
tmplTitleKey = constant.KeyNotifyTemplateUserLoginTitle
|
||||
tmplTextKey = constant.KeyNotifyTemplateUserLoginText
|
||||
// 特殊处理登录状态
|
||||
status, _ := payload["status"].(string)
|
||||
if status == "success" {
|
||||
title = "用户登录成功"
|
||||
text = fmt.Sprintf("用户 %v 在 IP %v 登录成功", payload["username"], payload["ip"])
|
||||
payload["status_label"] = "成功"
|
||||
} else {
|
||||
title = "用户登录失败"
|
||||
reason, _ := payload["message"].(string)
|
||||
text = fmt.Sprintf("用户 %v 在 IP %v 登录失败\n原因: %v", payload["username"], payload["ip"], reason)
|
||||
payload["status_label"] = "失败"
|
||||
}
|
||||
|
||||
case constant.EventBruteForceLogin:
|
||||
title = "系统安全警告"
|
||||
text = fmt.Sprintf("检测到 IP %v 正在尝试暴力破解用户 %v", payload["ip"], payload["username"])
|
||||
tmplTitleKey = constant.KeyNotifyTemplateBruteForceLoginTitle
|
||||
tmplTextKey = constant.KeyNotifyTemplateBruteForceLoginText
|
||||
|
||||
case constant.EventPasswordChanged:
|
||||
title = "账户安全通知"
|
||||
text = fmt.Sprintf("用户 %v 刚刚修改了密码", payload["username"])
|
||||
case constant.EventTaskSuccess:
|
||||
title = fmt.Sprintf("任务[%v] 成功", payload["task_name"])
|
||||
text = fmt.Sprintf("任务 #%v %v\n状态: 成功\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"])
|
||||
case constant.EventTaskFailed:
|
||||
title = fmt.Sprintf("任务[%v] 失败", payload["task_name"])
|
||||
if errStr, ok := payload["error"]; ok {
|
||||
text = fmt.Sprintf("任务 #%v %v\n执行失败\n执行时间: %v\n错误: %v", payload["task_id"], payload["task_name"], payload["start_time"], errStr)
|
||||
tmplTitleKey = constant.KeyNotifyTemplatePasswordChangedTitle
|
||||
tmplTextKey = constant.KeyNotifyTemplatePasswordChangedText
|
||||
|
||||
case constant.EventTaskSuccess, constant.EventTaskFailed, constant.EventTaskTimeout:
|
||||
if e.Type == constant.EventTaskSuccess {
|
||||
tmplTitleKey = constant.KeyNotifyTemplateTaskSuccessTitle
|
||||
tmplTextKey = constant.KeyNotifyTemplateTaskSuccessText
|
||||
} else if e.Type == constant.EventTaskFailed {
|
||||
tmplTitleKey = constant.KeyNotifyTemplateTaskFailedTitle
|
||||
tmplTextKey = constant.KeyNotifyTemplateTaskFailedText
|
||||
} else {
|
||||
text = fmt.Sprintf("任务 #%v %v\n执行失败\n状态: %v\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["status"], payload["start_time"], payload["duration"])
|
||||
tmplTitleKey = constant.KeyNotifyTemplateTaskTimeoutTitle
|
||||
tmplTextKey = constant.KeyNotifyTemplateTaskTimeoutText
|
||||
}
|
||||
case constant.EventTaskTimeout:
|
||||
title = fmt.Sprintf("任务[%v] 超时", payload["task_name"])
|
||||
text = fmt.Sprintf("任务 #%v %v\n执行超时\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"])
|
||||
|
||||
// 处理输出内容,避免过长
|
||||
if output, ok := payload["output"].(string); ok {
|
||||
// 如果输出包含了压缩后的 Base64 (以 "base64:" 开头),由于是推送到通知,我们尽量不发大段 Base64
|
||||
// 这里简单处理:如果过长则截断,或者如果是压缩的则记录一下
|
||||
if len(output) > 1000 {
|
||||
payload["output"] = output[len(output)-1000:] + "\n...(截断)"
|
||||
}
|
||||
}
|
||||
|
||||
case constant.EventSystemNotice:
|
||||
title, _ = payload["title"].(string)
|
||||
text, _ = payload["content"].(string)
|
||||
@@ -375,6 +440,28 @@ func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if tmplTitleKey != "" {
|
||||
tmplTitle := s.settingsService.Get(constant.SectionNotify, tmplTitleKey)
|
||||
tmplText := s.settingsService.Get(constant.SectionNotify, tmplTextKey)
|
||||
|
||||
if tmplTitle != "" {
|
||||
title = s.parseTemplate(tmplTitle, payload)
|
||||
}
|
||||
if tmplText != "" {
|
||||
text = s.parseTemplate(tmplText, payload)
|
||||
}
|
||||
|
||||
// 如果模板为空,使用兜底默认逻辑(保持向上兼容)
|
||||
if title == "" || text == "" {
|
||||
title, text = s.getDefaultMessage(e.Type, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加全局前缀
|
||||
if prefix != "" {
|
||||
title = fmt.Sprintf("%s %s", prefix, title)
|
||||
}
|
||||
|
||||
bindings := s.GetBindingsByEvent(bindingType, e.Type, dataID)
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
|
||||
@@ -87,6 +87,17 @@ func (s *SettingsService) InitSettings() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 从 constant.DefaultSettings 初始化所有缺少的通知模板
|
||||
if notifyDefaults, ok := constant.DefaultSettings[constant.SectionNotify]; ok {
|
||||
for k, v := range notifyDefaults {
|
||||
var count int64
|
||||
database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionNotify, Key: k}).Count(&count)
|
||||
if count == 0 {
|
||||
s.Set(constant.SectionNotify, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化或获取 JWT Secret 密码
|
||||
var secCount int64
|
||||
database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionSecurity, Key: constant.KeySecret}).Count(&secCount)
|
||||
|
||||
@@ -151,6 +151,9 @@ export const api = {
|
||||
getAbout: () => request<AboutInfo>('/settings/about'),
|
||||
getChangelog: () => request<string>('/settings/changelog'),
|
||||
get: (section: string, key: string) => request<string>(`/settings/${section}/${key}`),
|
||||
getSection: (section: string) => request<Record<string, string>>(`/settings/${section}`),
|
||||
setSection: (section: string, values: Record<string, string>) =>
|
||||
request(`/settings/${section}`, { method: 'PUT', body: JSON.stringify(values) }),
|
||||
generateToken: (section: string, key: string) =>
|
||||
request<string>(`/settings/${section}/${key}/generate`, { method: 'POST' }),
|
||||
getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => {
|
||||
|
||||
@@ -222,6 +222,10 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
input, textarea, button, select {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
.font-code,
|
||||
.font-mono,
|
||||
code,
|
||||
|
||||
@@ -17,6 +17,7 @@ import ChannelList from './components/ChannelList.vue'
|
||||
import EventBinding from './components/EventBinding.vue'
|
||||
import ApiUsage from './components/ApiUsage.vue'
|
||||
import ChannelDialog from './components/ChannelDialog.vue'
|
||||
import TemplateSettings from './components/TemplateSettings.vue'
|
||||
|
||||
const activeTab = ref('channels')
|
||||
|
||||
@@ -322,10 +323,11 @@ onMounted(() => {
|
||||
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">消息推送</h2>
|
||||
<p class="text-muted-foreground text-sm">配置通知渠道,绑定系统事件实现自动推送</p>
|
||||
</div>
|
||||
<TabsList class="grid grid-cols-3 w-full sm:w-auto min-w-[300px]">
|
||||
<TabsTrigger value="channels">渠道管理</TabsTrigger>
|
||||
<TabsTrigger value="events">事件绑定</TabsTrigger>
|
||||
<TabsTrigger value="api">脚本调用</TabsTrigger>
|
||||
<TabsList class="flex w-full sm:w-fit overflow-x-auto overflow-y-hidden justify-start sm:justify-center bg-muted/50 p-1 rounded-xl scrollbar-hide border border-border/50">
|
||||
<TabsTrigger value="channels" class="flex-1 sm:flex-none whitespace-nowrap px-3 sm:px-6">渠道管理</TabsTrigger>
|
||||
<TabsTrigger value="templates" class="flex-1 sm:flex-none whitespace-nowrap px-3 sm:px-6">推送模板</TabsTrigger>
|
||||
<TabsTrigger value="events" class="flex-1 sm:flex-none whitespace-nowrap px-3 sm:px-6">事件绑定</TabsTrigger>
|
||||
<TabsTrigger value="api" class="flex-1 sm:flex-none whitespace-nowrap px-3 sm:px-6">脚本调用</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -335,6 +337,11 @@ onMounted(() => {
|
||||
@delete="confirmDelete" @test="testChannel" />
|
||||
</TabsContent>
|
||||
|
||||
<!-- 推送模板 -->
|
||||
<TabsContent value="templates">
|
||||
<TemplateSettings v-model:activeTab="activeTab" />
|
||||
</TabsContent>
|
||||
|
||||
<!-- 事件绑定 -->
|
||||
<TabsContent value="events">
|
||||
<EventBinding :channels="channels" :channel-types="channelTypes" :event-types="eventTypes" :bindings="bindings"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Save, RefreshCcw, Info, ChevronDown, ChevronUp } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
activeTab?: string,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:activeTab'])
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const prefix = ref('')
|
||||
const templates = ref<Record<string, string>>({})
|
||||
const expandedEvents = ref<Record<string, boolean>>({})
|
||||
|
||||
const eventGroups = [
|
||||
{
|
||||
title: '系统与安全事件',
|
||||
events: [
|
||||
{
|
||||
id: 'user_login',
|
||||
name: '用户登录 (成功/失败)',
|
||||
keys: { title: 'notify_template_user_login_title', text: 'notify_template_user_login_text' },
|
||||
variables: ['username', 'ip', 'status_label', 'message']
|
||||
},
|
||||
{
|
||||
id: 'brute_force_login',
|
||||
name: '密码尝试破解',
|
||||
keys: { title: 'notify_template_brute_force_login_title', text: 'notify_template_brute_force_login_text' },
|
||||
variables: ['ip', 'username']
|
||||
},
|
||||
{
|
||||
id: 'password_changed',
|
||||
name: '密码修改',
|
||||
keys: { title: 'notify_template_password_changed_title', text: 'notify_template_password_changed_text' },
|
||||
variables: ['username']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '任务执行事件',
|
||||
events: [
|
||||
{
|
||||
id: 'task_success',
|
||||
name: '任务成功',
|
||||
keys: { title: 'notify_template_task_success_title', text: 'notify_template_task_success_text' },
|
||||
variables: ['task_id', 'task_name', 'start_time', 'duration', 'output']
|
||||
},
|
||||
{
|
||||
id: 'task_failed',
|
||||
name: '任务失败',
|
||||
keys: { title: 'notify_template_task_failed_title', text: 'notify_template_task_failed_text' },
|
||||
variables: ['task_id', 'task_name', 'start_time', 'duration', 'error', 'output']
|
||||
},
|
||||
{
|
||||
id: 'task_timeout',
|
||||
name: '任务超时',
|
||||
keys: { title: 'notify_template_task_timeout_title', text: 'notify_template_task_timeout_text' },
|
||||
variables: ['task_id', 'task_name', 'start_time', 'duration', 'output']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.settings.getSection('notify')
|
||||
prefix.value = res.notify_prefix || '[白虎面板]'
|
||||
templates.value = res
|
||||
} catch (e: any) {
|
||||
toast.error('加载配置失败: ' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const data: Record<string, string> = {
|
||||
notify_prefix: prefix.value,
|
||||
...templates.value
|
||||
}
|
||||
await api.settings.setSection('notify', data)
|
||||
toast.success('模板配置已保存')
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败: ' + e.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function insertVariable(eventKey: string, variable: string) {
|
||||
const current = templates.value[eventKey] || ''
|
||||
templates.value[eventKey] = current + ` {{${variable}}}`
|
||||
}
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
expandedEvents.value[id] = !expandedEvents.value[id]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
// 默认展开第一个
|
||||
expandedEvents.value['user_login'] = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<Card class="border-none shadow-none bg-transparent">
|
||||
<CardHeader class="px-0 pt-0 pb-4">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<CardTitle class="text-xl sm:text-2xl font-bold tracking-tight">通知模板管理</CardTitle>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" @click="loadSettings" :disabled="loading" class="h-9 w-9 sm:w-auto p-0 sm:px-3">
|
||||
<RefreshCcw class="w-4 h-4 sm:mr-2" :class="{ 'animate-spin': loading }" />
|
||||
<span class="hidden sm:inline">刷新</span>
|
||||
</Button>
|
||||
<Button size="sm" @click="saveSettings" :disabled="saving" class="h-9 px-4">
|
||||
<Save class="w-4 h-4 sm:mr-2" />
|
||||
<span class="hidden sm:inline">{{ saving ? '保存中...' : '提交修改' }}</span>
|
||||
<span class="sm:hidden">保存</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription class="text-xs sm:text-sm leading-relaxed max-w-2xl">
|
||||
定制通知的消息格式,支持局前缀与动态变量内置变量
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="px-0 space-y-6">
|
||||
<!-- 全局前缀 -->
|
||||
<div class="p-5 rounded-2xl bg-accent/20 border border-accent/30 space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm font-bold text-foreground">
|
||||
<div class="w-1.5 h-4 bg-primary rounded-full" />
|
||||
全局消息前缀
|
||||
</div>
|
||||
<div class="flex flex-col lg:flex-row gap-4 items-start lg:items-end">
|
||||
<div class="flex-1 w-full space-y-2">
|
||||
<Label class="text-[11px] text-muted-foreground ml-1 font-medium tracking-wide uppercase">该前缀会添加在所有通知标题的最前面</Label>
|
||||
<Input v-model="prefix" placeholder="例如: [生产环境]" class="bg-background/60 h-10 border-accent/20 focus:border-primary/50" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 px-4 h-10 rounded-lg bg-background/40 border border-dashed border-muted-foreground/20 text-[11px] text-muted-foreground shrink-0 w-full lg:w-auto">
|
||||
预览效果: <span class="font-mono text-primary font-bold tracking-tight">{{ prefix }} 用户登录成功</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板详情 -->
|
||||
<div class="w-full space-y-8 mt-4">
|
||||
<div v-for="group in eventGroups" :key="group.title" class="space-y-4">
|
||||
<div class="flex items-center gap-3 ml-1">
|
||||
<h3 class="text-[11px] font-black text-muted-foreground uppercase tracking-[0.2em]">{{ group.title }}</h3>
|
||||
<div class="h-[1px] flex-1 bg-gradient-to-r from-border/60 to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- 任务执行事件专属提示 -->
|
||||
<div v-if="group.title === '任务执行事件'" class="p-3 rounded-lg bg-yellow-500/5 border border-yellow-500/10 flex gap-3 text-xs text-yellow-600/80 leading-relaxed mb-6">
|
||||
<Info class="w-4 h-4 shrink-0 mt-0.5 text-yellow-500/50" />
|
||||
<div>
|
||||
<span class="font-bold">配置提示:</span>
|
||||
部分变量(如 <code v-pre class="bg-yellow-500/10 px-1 rounded text-yellow-600">{{ output }}</code>)依赖于的具体绑定配置。请确保在
|
||||
<span class="font-bold underline decoration-dotted cursor-pointer hover:text-yellow-600" @click="emit('update:activeTab', 'events')">事件绑定</span>
|
||||
的高级设置中开启了 <span class="text-foreground/80">“发送任务日志”</span>,否则通知消息中该变量将为空。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="event in group.events" :key="event.id"
|
||||
class="border rounded-xl bg-background/50 overflow-hidden transition-all duration-200"
|
||||
:class="{ 'ring-1 ring-primary/20 bg-accent/5': expandedEvents[event.id] }">
|
||||
|
||||
<div class="flex items-center justify-between p-4 cursor-pointer select-none" @click="toggleExpand(event.id)">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary">
|
||||
<Info class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-semibold">{{ event.name }}</div>
|
||||
<div class="text-[10px] text-muted-foreground font-normal uppercase tracking-tight">ID: {{ event.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<component :is="expandedEvents[event.id] ? ChevronUp : ChevronDown" class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-if="expandedEvents[event.id]" class="px-4 pb-6 space-y-4 pt-2 border-t border-dashed">
|
||||
<!-- 变量提示 -->
|
||||
<div class="flex flex-wrap items-center gap-2 py-2">
|
||||
<span class="text-[10px] font-bold text-muted-foreground mr-1 uppercase">可用参数:</span>
|
||||
<Badge v-for="v in event.variables" :key="v" variant="secondary"
|
||||
class="cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors py-0.5 px-2 font-medium text-[11px] border-none"
|
||||
@click="insertVariable(event.keys.text, v)"
|
||||
v-text="'{{' + v + '}}'">
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- 标题模板 -->
|
||||
<div class="space-y-2.5">
|
||||
<Label class="text-[11px] font-bold flex items-center gap-1.5 text-muted-foreground uppercase tracking-wide">
|
||||
推送标题模板
|
||||
</Label>
|
||||
<Input v-model="templates[event.keys.title]" placeholder="通知标题" class="bg-background/80 h-10 border-accent/20 focus:border-primary/50" />
|
||||
</div>
|
||||
<!-- 内容模板 -->
|
||||
<div class="space-y-2.5">
|
||||
<Label class="text-[11px] font-bold flex items-center gap-1.5 text-muted-foreground uppercase tracking-wide">
|
||||
推送正文模板
|
||||
</Label>
|
||||
<Textarea v-model="templates[event.keys.text]"
|
||||
:rows="4"
|
||||
placeholder="通知详细内容..."
|
||||
class="resize-none font-sans leading-relaxed bg-background/80 border-accent/20 focus:border-primary/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 移除不必要的移动端过重样式,保持清爽 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user