feat: add message template
This commit is contained in:
+3
-1
@@ -2,9 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"message-nest/pkg/e"
|
"message-nest/pkg/e"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
var ExcludedRoutes = []string{
|
var ExcludedRoutes = []string{
|
||||||
"/api/v1/message/send",
|
"/api/v1/message/send",
|
||||||
|
"/api/v2/message/send",
|
||||||
"/api/v1/settings/getsetting",
|
"/api/v1/settings/getsetting",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -3,10 +3,11 @@ package migrate
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"message-nest/models"
|
"message-nest/models"
|
||||||
"message-nest/service/settings_service"
|
"message-nest/service/settings_service"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 初始化admin账户
|
// 初始化admin账户
|
||||||
@@ -67,6 +68,7 @@ func Setup() {
|
|||||||
&models.CronMessages{},
|
&models.CronMessages{},
|
||||||
&models.HostedMessage{},
|
&models.HostedMessage{},
|
||||||
&models.LoginLog{},
|
&models.LoginLog{},
|
||||||
|
&models.MessageTemplate{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateTemplateUniqueID 生成模板唯一ID
|
||||||
|
func GenerateTemplateUniqueID() string {
|
||||||
|
newUUID := util.GenerateUniqueID()
|
||||||
|
return fmt.Sprintf("TP%s", newUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageTemplate 消息模板
|
||||||
|
type MessageTemplate struct {
|
||||||
|
UUIDModel
|
||||||
|
|
||||||
|
Name string `json:"name" gorm:"type:varchar(200);not null;index" binding:"required"`
|
||||||
|
Description string `json:"description" gorm:"type:text"`
|
||||||
|
|
||||||
|
// 模板内容(带占位符)
|
||||||
|
TextTemplate string `json:"text_template" gorm:"type:text"`
|
||||||
|
HTMLTemplate string `json:"html_template" gorm:"type:text"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template" gorm:"type:text"`
|
||||||
|
|
||||||
|
// 占位符定义(JSON格式)
|
||||||
|
Placeholders string `json:"placeholders" gorm:"type:text"`
|
||||||
|
|
||||||
|
// @提醒配置
|
||||||
|
AtMobiles string `json:"at_mobiles" gorm:"type:text;comment:'@手机号列表,逗号分隔'"`
|
||||||
|
AtUserIds string `json:"at_user_ids" gorm:"type:text;comment:'@用户ID列表,逗号分隔'"`
|
||||||
|
IsAtAll bool `json:"is_at_all" gorm:"default:false;comment:'是否@所有人'"`
|
||||||
|
|
||||||
|
// 状态:enabled/disabled
|
||||||
|
Status string `json:"status" gorm:"type:varchar(20);default:'enabled';index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 添加消息模板
|
||||||
|
func (t *MessageTemplate) Add() error {
|
||||||
|
if err := db.Create(&t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新消息模板
|
||||||
|
func (t *MessageTemplate) Update() error {
|
||||||
|
if err := db.Model(&MessageTemplate{}).Where("id = ?", t.ID).Updates(t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除消息模板
|
||||||
|
func (t *MessageTemplate) Delete() error {
|
||||||
|
if err := db.Where("id = ?", t.ID).Delete(&MessageTemplate{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageTemplateResult 消息模板查询结果
|
||||||
|
type MessageTemplateResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TextTemplate string `json:"text_template"`
|
||||||
|
HTMLTemplate string `json:"html_template"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template"`
|
||||||
|
Placeholders string `json:"placeholders"`
|
||||||
|
AtMobiles string `json:"at_mobiles"`
|
||||||
|
AtUserIds string `json:"at_user_ids"`
|
||||||
|
IsAtAll bool `json:"is_at_all"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
ModifiedBy string `json:"modified_by"`
|
||||||
|
CreatedOn util.Time `json:"created_on"`
|
||||||
|
ModifiedOn util.Time `json:"modified_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageTemplates 获取消息模板列表
|
||||||
|
func GetMessageTemplates(pageNum int, pageSize int, text string, maps map[string]interface{}) ([]MessageTemplateResult, error) {
|
||||||
|
var datas []MessageTemplateResult
|
||||||
|
templateT := GetSchema(MessageTemplate{})
|
||||||
|
|
||||||
|
query := db.Table(templateT)
|
||||||
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
query = query.Where("name LIKE ? OR description LIKE ?",
|
||||||
|
fmt.Sprintf("%%%s%%", text),
|
||||||
|
fmt.Sprintf("%%%s%%", text))
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Order("created_on DESC")
|
||||||
|
|
||||||
|
if pageSize > 0 || pageNum > 0 {
|
||||||
|
query = query.Offset(pageNum).Limit(pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Scan(&datas)
|
||||||
|
return datas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageTemplatesTotal 获取消息模板总数
|
||||||
|
func GetMessageTemplatesTotal(text string, maps map[string]interface{}) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
templateT := GetSchema(MessageTemplate{})
|
||||||
|
|
||||||
|
query := db.Table(templateT)
|
||||||
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
query = query.Where("name LIKE ? OR description LIKE ?",
|
||||||
|
fmt.Sprintf("%%%s%%", text),
|
||||||
|
fmt.Sprintf("%%%s%%", text))
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageTemplateByID 根据ID获取消息模板
|
||||||
|
func GetMessageTemplateByID(id string) (*MessageTemplateResult, error) {
|
||||||
|
var data MessageTemplateResult
|
||||||
|
templateT := GetSchema(MessageTemplate{})
|
||||||
|
|
||||||
|
err := db.Table(templateT).Where("id = ?", id).First(&data).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistMessageTemplateByID 检查模板是否存在
|
||||||
|
func ExistMessageTemplateByID(id string) (bool, error) {
|
||||||
|
var template MessageTemplate
|
||||||
|
err := db.Select("id").Where("id = ?", id).First(&template).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.ID != "" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
type SendTasksIns struct {
|
type SendTasksIns struct {
|
||||||
UUIDModel
|
UUIDModel
|
||||||
|
|
||||||
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index"`
|
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index"`
|
||||||
|
TemplateID string `json:"template_id" gorm:"type:varchar(12) ;default:'';index"` // 模板ID
|
||||||
WayID string `json:"way_id" gorm:"type:varchar(12) ;default:'';index"`
|
WayID string `json:"way_id" gorm:"type:varchar(12) ;default:'';index"`
|
||||||
WayType string `json:"way_type" gorm:"type:varchar(100) ;default:'';index"`
|
WayType string `json:"way_type" gorm:"type:varchar(100) ;default:'';index"`
|
||||||
ContentType string `json:"content_type" gorm:"type:varchar(100) ;default:'';index"`
|
ContentType string `json:"content_type" gorm:"type:varchar(100) ;default:'';index"`
|
||||||
@@ -82,3 +85,23 @@ func UpdateMsgTaskIns(id string, data map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTemplateInsList 获取模板关联的实例列表(包含渠道名称)
|
||||||
|
func GetTemplateInsList(templateID string) ([]SendTasksInsRes, error) {
|
||||||
|
insTable := GetSchema(SendTasksIns{})
|
||||||
|
waysTable := GetSchema(SendWays{})
|
||||||
|
var insList []SendTasksInsRes
|
||||||
|
|
||||||
|
err := db.
|
||||||
|
Table(insTable).
|
||||||
|
Select(fmt.Sprintf("%s.*, %s.name as way_name", insTable, waysTable)).
|
||||||
|
Joins(fmt.Sprintf("JOIN %s ON %s.way_id = %s.id", waysTable, insTable, waysTable)).
|
||||||
|
Where(fmt.Sprintf("%s.template_id = ?", insTable), templateID).
|
||||||
|
Order(fmt.Sprintf("%s.created_on DESC", insTable)).
|
||||||
|
Scan(&insList).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return insList, nil
|
||||||
|
}
|
||||||
|
|||||||
+69
-12
@@ -9,6 +9,8 @@ import (
|
|||||||
type SendTasksLogs struct {
|
type SendTasksLogs struct {
|
||||||
ID int `gorm:"primaryKey" json:"id" `
|
ID int `gorm:"primaryKey" json:"id" `
|
||||||
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index:task_id"`
|
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index:task_id"`
|
||||||
|
Type string `json:"type" gorm:"type:varchar(20) ;default:'task';comment:'类型:task-任务,template-模板'"`
|
||||||
|
Name string `json:"name" gorm:"type:varchar(256) ;default:'';comment:'任务或模板名称'"`
|
||||||
Log string `json:"log" gorm:"type:text ;"`
|
Log string `json:"log" gorm:"type:text ;"`
|
||||||
Status *int `json:"status" gorm:"type:int ;default:0;"`
|
Status *int `json:"status" gorm:"type:int ;default:0;"`
|
||||||
CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"`
|
CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"`
|
||||||
@@ -29,10 +31,11 @@ func (log *SendTasksLogs) Add() error {
|
|||||||
type LogsResult struct {
|
type LogsResult struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
Log string `json:"log"`
|
Log string `json:"log"`
|
||||||
CreatedOn util.Time `json:"created_on"`
|
CreatedOn util.Time `json:"created_on"`
|
||||||
ModifiedOn util.Time `json:"modified_on"`
|
ModifiedOn util.Time `json:"modified_on"`
|
||||||
TaskName string `json:"task_name"`
|
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
CallerIp string `json:"caller_ip"`
|
CallerIp string `json:"caller_ip"`
|
||||||
}
|
}
|
||||||
@@ -41,12 +44,9 @@ type LogsResult struct {
|
|||||||
func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map[string]interface{}) ([]LogsResult, error) {
|
func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map[string]interface{}) ([]LogsResult, error) {
|
||||||
var logs []LogsResult
|
var logs []LogsResult
|
||||||
logt := GetSchema(SendTasksLogs{})
|
logt := GetSchema(SendTasksLogs{})
|
||||||
taskt := GetSchema(SendTasks{})
|
|
||||||
|
|
||||||
query := db.
|
// 简化查询,只查询日志表
|
||||||
Table(logt).
|
query := db.Table(logt)
|
||||||
Select(fmt.Sprintf("%s.*, %s.name as task_name", logt, taskt)).
|
|
||||||
Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt))
|
|
||||||
|
|
||||||
dayVal, ok := maps["day_created_on"]
|
dayVal, ok := maps["day_created_on"]
|
||||||
if ok {
|
if ok {
|
||||||
@@ -55,8 +55,10 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
|
|||||||
}
|
}
|
||||||
|
|
||||||
query = query.Where(maps)
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
// 按名称搜索(搜索日志表的 name 字段)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.name like ?", taskt), fmt.Sprintf("%%%s%%", name))
|
query = query.Where(fmt.Sprintf("%s.name like ?", logt), fmt.Sprintf("%%%s%%", name))
|
||||||
}
|
}
|
||||||
if taskId != "" {
|
if taskId != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
||||||
@@ -66,18 +68,71 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
|
|||||||
query = query.Offset(pageNum).Limit(pageSize)
|
query = query.Offset(pageNum).Limit(pageSize)
|
||||||
}
|
}
|
||||||
query.Scan(&logs)
|
query.Scan(&logs)
|
||||||
|
|
||||||
|
//v1 接口的历史日志数据兼容处理
|
||||||
|
// 应用层处理:为历史数据(type=task 且 name 为空)补充任务名称
|
||||||
|
fillTaskNamesForLogs(&logs)
|
||||||
|
|
||||||
return logs, nil
|
return logs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillTaskNamesForLogs 为历史日志数据补充任务名称
|
||||||
|
func fillTaskNamesForLogs(logs *[]LogsResult) {
|
||||||
|
if logs == nil || len(*logs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集需要查询的 task_id
|
||||||
|
taskIdsMap := make(map[string]bool)
|
||||||
|
for _, log := range *logs {
|
||||||
|
// 只处理 type=task 且 name 为空的记录
|
||||||
|
if (log.Type == "" || log.Type == "task") && log.Name == "" && log.TaskID != "" {
|
||||||
|
taskIdsMap[log.TaskID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有需要查询的任务,直接返回
|
||||||
|
if len(taskIdsMap) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询任务名称
|
||||||
|
taskIds := make([]string, 0, len(taskIdsMap))
|
||||||
|
for taskId := range taskIdsMap {
|
||||||
|
taskIds = append(taskIds, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks []SendTasks
|
||||||
|
taskt := GetSchema(SendTasks{})
|
||||||
|
db.Table(taskt).
|
||||||
|
Select("id, name").
|
||||||
|
Where("id IN ?", taskIds).
|
||||||
|
Scan(&tasks)
|
||||||
|
|
||||||
|
// 构建 taskId -> name 的映射
|
||||||
|
taskNameMap := make(map[string]string)
|
||||||
|
for _, task := range tasks {
|
||||||
|
taskNameMap[task.ID] = task.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充日志的 name 字段
|
||||||
|
for i := range *logs {
|
||||||
|
log := &(*logs)[i]
|
||||||
|
if (log.Type == "" || log.Type == "task") && log.Name == "" && log.TaskID != "" {
|
||||||
|
if taskName, exists := taskNameMap[log.TaskID]; exists {
|
||||||
|
log.Name = taskName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetSendLogsTotal 获取所有日志总数
|
// GetSendLogsTotal 获取所有日志总数
|
||||||
func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) {
|
func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
logt := GetSchema(SendTasksLogs{})
|
logt := GetSchema(SendTasksLogs{})
|
||||||
taskt := GetSchema(SendTasks{})
|
|
||||||
query := db.
|
// 简化查询,只查询日志表
|
||||||
Table(logt).
|
query := db.Table(logt)
|
||||||
Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt))
|
|
||||||
|
|
||||||
dayVal, ok := maps["day_created_on"]
|
dayVal, ok := maps["day_created_on"]
|
||||||
if ok {
|
if ok {
|
||||||
@@ -86,8 +141,10 @@ func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
query = query.Where(maps)
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
// 按名称搜索(搜索日志表的 name 字段)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.name like ?", taskt), fmt.Sprintf("%%%s%%", name))
|
query = query.Where(fmt.Sprintf("%s.name like ?", logt), fmt.Sprintf("%%%s%%", name))
|
||||||
}
|
}
|
||||||
if taskId != "" {
|
if taskId != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/app"
|
||||||
|
"message-nest/pkg/e"
|
||||||
|
"message-nest/pkg/util"
|
||||||
|
"message-nest/service/message_template_service"
|
||||||
|
"message-nest/service/send_ins_service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMessageTemplateList 获取消息模板列表
|
||||||
|
func GetMessageTemplateList(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
text := c.Query("text")
|
||||||
|
status := c.Query("status")
|
||||||
|
|
||||||
|
offset, limit := util.GetPageSize(c)
|
||||||
|
templateService := message_template_service.MessageTemplateService{
|
||||||
|
Text: text,
|
||||||
|
Status: status,
|
||||||
|
PageNum: offset,
|
||||||
|
PageSize: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err := templateService.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "获取消息模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := templateService.Count()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "获取消息模板总数失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "获取消息模板成功", map[string]interface{}{
|
||||||
|
"lists": templates,
|
||||||
|
"total": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageTemplate 获取单个消息模板
|
||||||
|
func GetMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
id := c.Query("id")
|
||||||
|
|
||||||
|
templateService := message_template_service.MessageTemplateService{
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := templateService.Get()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "获取模板详情失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "获取模板详情成功", template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessageTemplate 添加消息模板
|
||||||
|
func AddMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TextTemplate string `json:"text_template"`
|
||||||
|
HTMLTemplate string `json:"html_template"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template"`
|
||||||
|
Placeholders string `json:"placeholders"`
|
||||||
|
AtMobiles string `json:"at_mobiles"`
|
||||||
|
AtUserIds string `json:"at_user_ids"`
|
||||||
|
IsAtAll bool `json:"is_at_all"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TextTemplate == "" && req.HTMLTemplate == "" && req.MarkdownTemplate == "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "至少需要填写一种格式的模板内容", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status == "" {
|
||||||
|
req.Status = "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.MessageTemplateService{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
TextTemplate: req.TextTemplate,
|
||||||
|
HTMLTemplate: req.HTMLTemplate,
|
||||||
|
MarkdownTemplate: req.MarkdownTemplate,
|
||||||
|
Placeholders: req.Placeholders,
|
||||||
|
AtMobiles: req.AtMobiles,
|
||||||
|
AtUserIds: req.AtUserIds,
|
||||||
|
IsAtAll: req.IsAtAll,
|
||||||
|
Status: req.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templateService.Add(); err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "添加模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "添加模板成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessageTemplate 编辑消息模板
|
||||||
|
func EditMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TextTemplate string `json:"text_template"`
|
||||||
|
HTMLTemplate string `json:"html_template"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template"`
|
||||||
|
Placeholders string `json:"placeholders"`
|
||||||
|
AtMobiles string `json:"at_mobiles"`
|
||||||
|
AtUserIds string `json:"at_user_ids"`
|
||||||
|
IsAtAll bool `json:"is_at_all"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TextTemplate == "" && req.HTMLTemplate == "" && req.MarkdownTemplate == "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "至少需要填写一种格式的模板内容", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.MessageTemplateService{
|
||||||
|
ID: req.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService.Name = req.Name
|
||||||
|
templateService.Description = req.Description
|
||||||
|
templateService.TextTemplate = req.TextTemplate
|
||||||
|
templateService.HTMLTemplate = req.HTMLTemplate
|
||||||
|
templateService.MarkdownTemplate = req.MarkdownTemplate
|
||||||
|
templateService.Placeholders = req.Placeholders
|
||||||
|
templateService.AtMobiles = req.AtMobiles
|
||||||
|
templateService.AtUserIds = req.AtUserIds
|
||||||
|
templateService.IsAtAll = req.IsAtAll
|
||||||
|
templateService.Status = req.Status
|
||||||
|
|
||||||
|
if err := templateService.Update(); err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "更新模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "更新模板成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessageTemplate 删除消息模板
|
||||||
|
func DeleteMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.MessageTemplateService{
|
||||||
|
ID: req.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templateService.Delete(); err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "删除模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "删除模板成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewMessageTemplate 预览消息模板
|
||||||
|
func PreviewMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
Params map[string]string `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.MessageTemplateService{
|
||||||
|
ID: req.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := templateService.PreviewTemplate(req.Params)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "预览模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "预览模板成功", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateWithIns 获取模板及其关联的实例
|
||||||
|
func GetTemplateWithIns(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
id := c.Query("id")
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板ID为空!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板信息
|
||||||
|
template, err := models.GetMessageTemplateByID(id)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "获取模板信息失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取关联的实例列表
|
||||||
|
insList, err := models.GetTemplateInsList(id)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "获取实例列表失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"template": template,
|
||||||
|
"ins_list": insList,
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "获取模板信息成功", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateInsReq 模板实例请求结构
|
||||||
|
type TemplateInsReq struct {
|
||||||
|
ID string `json:"id" validate:"required,len=12" label:"实例id"`
|
||||||
|
TemplateID string `json:"template_id" validate:"required" label:"模板id"`
|
||||||
|
WayID string `json:"way_id" validate:"required,len=12" label:"渠道id"`
|
||||||
|
ContentType string `json:"content_type" validate:"required,max=100" label:"实例内容类型"`
|
||||||
|
Config string `json:"config" validate:"" label:"任务配置"`
|
||||||
|
Extra string `json:"extra" validate:"" label:"任务额外信息"`
|
||||||
|
WayType string `json:"way_type" validate:"required,max=100" label:"渠道类型"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTemplateIns 添加模板关联的实例
|
||||||
|
func AddTemplateIns(c *gin.Context) {
|
||||||
|
var (
|
||||||
|
appG = app.Gin{C: c}
|
||||||
|
req TemplateInsReq
|
||||||
|
)
|
||||||
|
|
||||||
|
errCode, errStr := app.BindJsonAndPlayValid(c, &req)
|
||||||
|
if errCode != e.SUCCESS {
|
||||||
|
appG.CResponse(errCode, errStr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTaskInsService := send_ins_service.SendTaskInsService{}
|
||||||
|
err := sendTaskInsService.AddOne(models.SendTasksIns{
|
||||||
|
UUIDModel: models.UUIDModel{ID: req.ID},
|
||||||
|
TemplateID: req.TemplateID,
|
||||||
|
WayID: req.WayID,
|
||||||
|
WayType: req.WayType,
|
||||||
|
ContentType: req.ContentType,
|
||||||
|
Config: req.Config,
|
||||||
|
Extra: req.Extra,
|
||||||
|
})
|
||||||
|
if err != "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("添加实例失败!错误原因:%s", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "添加实例成功!", nil)
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ func DoSendMassage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgService := send_message_service.SendMessageService{
|
msgService := send_message_service.SendMessageService{
|
||||||
|
SendMode: send_message_service.SendModeTask, // 明确标记为任务模式
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Text: req.Text,
|
Text: req.Text,
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/app"
|
||||||
|
"message-nest/pkg/e"
|
||||||
|
utilpkg "message-nest/pkg/util"
|
||||||
|
"message-nest/service/send_message_service"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendMessageByTemplateReq struct {
|
||||||
|
Token string `json:"token" validate:"required" label:"模板token"`
|
||||||
|
Title string `json:"title" validate:"required" label:"消息标题"`
|
||||||
|
Placeholders map[string]interface{} `json:"placeholders" label:"占位符"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoSendMessageByTemplate 使用模板发送消息
|
||||||
|
func DoSendMessageByTemplate(c *gin.Context) {
|
||||||
|
var (
|
||||||
|
appG = app.Gin{C: c}
|
||||||
|
req SendMessageByTemplateReq
|
||||||
|
)
|
||||||
|
|
||||||
|
errCode, errMsg := app.BindJsonAndPlayValid(c, &req)
|
||||||
|
if errCode != e.SUCCESS {
|
||||||
|
appG.CResponse(errCode, errMsg, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 token 为模板 ID
|
||||||
|
templateID, err := utilpkg.DecryptTokenHex(req.Token, 71) // 71 为简单对称密钥
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("token解析失败:%v", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板
|
||||||
|
template, err := models.GetMessageTemplateByID(templateID)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("模板不存在:%s", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模板状态
|
||||||
|
if template.Status != "enabled" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板已禁用", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换占位符
|
||||||
|
textContent := replacePlaceholders(template.TextTemplate, req.Placeholders)
|
||||||
|
htmlContent := replacePlaceholders(template.HTMLTemplate, req.Placeholders)
|
||||||
|
markdownContent := replacePlaceholders(template.MarkdownTemplate, req.Placeholders)
|
||||||
|
|
||||||
|
// 解析@提醒配置
|
||||||
|
var atMobiles []string
|
||||||
|
var atUserIds []string
|
||||||
|
if template.AtMobiles != "" {
|
||||||
|
atMobiles = strings.Split(template.AtMobiles, ",")
|
||||||
|
// 去除空格
|
||||||
|
for i := range atMobiles {
|
||||||
|
atMobiles[i] = strings.TrimSpace(atMobiles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if template.AtUserIds != "" {
|
||||||
|
atUserIds = strings.Split(template.AtUserIds, ",")
|
||||||
|
// 去除空格
|
||||||
|
for i := range atUserIds {
|
||||||
|
atUserIds[i] = strings.TrimSpace(atUserIds[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板关联的实例列表
|
||||||
|
insList, err := models.GetTemplateInsList(templateID)
|
||||||
|
if err != nil || len(insList) == 0 {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板没有配置发送实例", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤启用的实例
|
||||||
|
var enabledCount int
|
||||||
|
for _, ins := range insList {
|
||||||
|
if ins.Enable == 1 {
|
||||||
|
enabledCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabledCount == 0 {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板没有启用的发送实例", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用发送服务进行发送
|
||||||
|
// 将模板ID作为TaskID传入,用于日志记录
|
||||||
|
msgService := send_message_service.SendMessageService{
|
||||||
|
SendMode: send_message_service.SendModeTemplate, // 明确标记为模板模式
|
||||||
|
TaskID: templateID, // 使用模板ID作为TaskID(用于日志记录)
|
||||||
|
TemplateID: templateID, // 模板ID
|
||||||
|
Name: template.Name, // 模板名称
|
||||||
|
Title: req.Title,
|
||||||
|
Text: textContent,
|
||||||
|
HTML: htmlContent,
|
||||||
|
MarkDown: markdownContent,
|
||||||
|
CallerIp: c.ClientIP(),
|
||||||
|
AtMobiles: atMobiles,
|
||||||
|
AtUserIds: atUserIds,
|
||||||
|
AtAll: template.IsAtAll,
|
||||||
|
DefaultLogger: logrus.WithFields(logrus.Fields{
|
||||||
|
"prefix": "[Template Send]",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送前检查
|
||||||
|
task, err := msgService.SendPreCheck()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("发送检查不通过:%s", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步发送
|
||||||
|
msgService.AsyncSend(task)
|
||||||
|
appG.CResponse(http.StatusOK, "success", map[string]interface{}{
|
||||||
|
"token": req.Token,
|
||||||
|
"count": enabledCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// replacePlaceholders 替换模板中的占位符
|
||||||
|
func replacePlaceholders(template string, placeholders map[string]interface{}) string {
|
||||||
|
if template == "" || placeholders == nil {
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
result := template
|
||||||
|
for key, value := range placeholders {
|
||||||
|
placeholder := fmt.Sprintf("{{%s}}", key)
|
||||||
|
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"message-nest/pkg/setting"
|
"message-nest/pkg/setting"
|
||||||
"message-nest/routers/api"
|
"message-nest/routers/api"
|
||||||
"message-nest/routers/api/v1"
|
"message-nest/routers/api/v1"
|
||||||
|
"message-nest/routers/api/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,6 +99,26 @@ func InitRouter(f embed.FS) *gin.Engine {
|
|||||||
// hostedMessage
|
// hostedMessage
|
||||||
apiV1.GET("/hostedmessages/list", v1.GetHostMessageList)
|
apiV1.GET("/hostedmessages/list", v1.GetHostMessageList)
|
||||||
|
|
||||||
|
// messageTemplate
|
||||||
|
apiV1.GET("/templates/list", v1.GetMessageTemplateList)
|
||||||
|
apiV1.GET("/templates/get", v1.GetMessageTemplate)
|
||||||
|
apiV1.POST("/templates/add", v1.AddMessageTemplate)
|
||||||
|
apiV1.POST("/templates/edit", v1.EditMessageTemplate)
|
||||||
|
apiV1.POST("/templates/delete", v1.DeleteMessageTemplate)
|
||||||
|
apiV1.POST("/templates/preview", v1.PreviewMessageTemplate)
|
||||||
|
|
||||||
|
// messageTemplate instances
|
||||||
|
apiV1.GET("/templates/ins/get", v1.GetTemplateWithIns)
|
||||||
|
apiV1.POST("/templates/ins/addone", v1.AddTemplateIns)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// API v2
|
||||||
|
apiV2 := app.Group("/api/v2")
|
||||||
|
apiV2.Use(middleware.JWT())
|
||||||
|
{
|
||||||
|
// message/send - 使用模板发送消息
|
||||||
|
apiV2.POST("/message/send", v2.DoSendMessageByTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package message_template_service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"message-nest/models"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageTemplateService struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
TextTemplate string
|
||||||
|
HTMLTemplate string
|
||||||
|
MarkdownTemplate string
|
||||||
|
Placeholders string
|
||||||
|
AtMobiles string
|
||||||
|
AtUserIds string
|
||||||
|
IsAtAll bool
|
||||||
|
Status string
|
||||||
|
Text string
|
||||||
|
|
||||||
|
PageNum int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder 占位符定义
|
||||||
|
type Placeholder struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 添加消息模板
|
||||||
|
func (s *MessageTemplateService) Add() error {
|
||||||
|
if err := s.validatePlaceholders(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newUUID := models.GenerateTemplateUniqueID()
|
||||||
|
model := models.MessageTemplate{
|
||||||
|
UUIDModel: models.UUIDModel{
|
||||||
|
ID: newUUID,
|
||||||
|
},
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
TextTemplate: s.TextTemplate,
|
||||||
|
HTMLTemplate: s.HTMLTemplate,
|
||||||
|
MarkdownTemplate: s.MarkdownTemplate,
|
||||||
|
Placeholders: s.Placeholders,
|
||||||
|
AtMobiles: s.AtMobiles,
|
||||||
|
AtUserIds: s.AtUserIds,
|
||||||
|
IsAtAll: s.IsAtAll,
|
||||||
|
Status: s.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.Add()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新消息模板
|
||||||
|
func (s *MessageTemplateService) Update() error {
|
||||||
|
if err := s.validatePlaceholders(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
model := models.MessageTemplate{
|
||||||
|
UUIDModel: models.UUIDModel{
|
||||||
|
ID: s.ID,
|
||||||
|
},
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
TextTemplate: s.TextTemplate,
|
||||||
|
HTMLTemplate: s.HTMLTemplate,
|
||||||
|
MarkdownTemplate: s.MarkdownTemplate,
|
||||||
|
Placeholders: s.Placeholders,
|
||||||
|
AtMobiles: s.AtMobiles,
|
||||||
|
AtUserIds: s.AtUserIds,
|
||||||
|
IsAtAll: s.IsAtAll,
|
||||||
|
Status: s.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除消息模板
|
||||||
|
func (s *MessageTemplateService) Delete() error {
|
||||||
|
model := models.MessageTemplate{
|
||||||
|
UUIDModel: models.UUIDModel{
|
||||||
|
ID: s.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return model.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取单个消息模板
|
||||||
|
func (s *MessageTemplateService) Get() (*models.MessageTemplateResult, error) {
|
||||||
|
return models.GetMessageTemplateByID(s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 获取消息模板列表
|
||||||
|
func (s *MessageTemplateService) GetAll() ([]models.MessageTemplateResult, error) {
|
||||||
|
templates, err := models.GetMessageTemplates(s.PageNum, s.PageSize, s.Text, s.getMaps())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return templates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 获取消息模板总数
|
||||||
|
func (s *MessageTemplateService) Count() (int64, error) {
|
||||||
|
return models.GetMessageTemplatesTotal(s.Text, s.getMaps())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistByID 检查模板是否存在
|
||||||
|
func (s *MessageTemplateService) ExistByID() (bool, error) {
|
||||||
|
return models.ExistMessageTemplateByID(s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTemplate 渲染模板(替换占位符)
|
||||||
|
func (s *MessageTemplateService) RenderTemplate(templateContent string, params map[string]string) string {
|
||||||
|
result := templateContent
|
||||||
|
|
||||||
|
for key, value := range params {
|
||||||
|
placeholder := "{{" + key + "}}"
|
||||||
|
result = strings.ReplaceAll(result, placeholder, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewTemplate 预览模板效果
|
||||||
|
func (s *MessageTemplateService) PreviewTemplate(params map[string]string) (map[string]string, error) {
|
||||||
|
template, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
|
||||||
|
if template.TextTemplate != "" {
|
||||||
|
result["text"] = s.RenderTemplate(template.TextTemplate, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.HTMLTemplate != "" {
|
||||||
|
result["html"] = s.RenderTemplate(template.HTMLTemplate, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.MarkdownTemplate != "" {
|
||||||
|
result["markdown"] = s.RenderTemplate(template.MarkdownTemplate, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePlaceholders 验证占位符格式
|
||||||
|
func (s *MessageTemplateService) validatePlaceholders() error {
|
||||||
|
if s.Placeholders == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var placeholders []Placeholder
|
||||||
|
if err := json.Unmarshal([]byte(s.Placeholders), &placeholders); err != nil {
|
||||||
|
return errors.New("占位符格式错误,必须是有效的JSON数组")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range placeholders {
|
||||||
|
if p.Key == "" {
|
||||||
|
return errors.New("占位符的key不能为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMaps 获取查询条件
|
||||||
|
func (s *MessageTemplateService) getMaps() map[string]interface{} {
|
||||||
|
maps := make(map[string]interface{})
|
||||||
|
|
||||||
|
if s.Status != "" {
|
||||||
|
maps["status"] = s.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
return maps
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ const (
|
|||||||
SendFail = 0
|
SendFail = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 发送模式类型
|
||||||
|
const (
|
||||||
|
SendModeTask = "task" // 传统任务模式
|
||||||
|
SendModeTemplate = "template" // 模板模式
|
||||||
|
)
|
||||||
|
|
||||||
func errStrIsSuccess(errStr string) int {
|
func errStrIsSuccess(errStr string) int {
|
||||||
if errStr == "" {
|
if errStr == "" {
|
||||||
return SendSuccess
|
return SendSuccess
|
||||||
@@ -26,13 +32,16 @@ func errStrIsSuccess(errStr string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SendMessageService struct {
|
type SendMessageService struct {
|
||||||
TaskID string
|
SendMode string // 发送模式:task(任务模式) 或 template(模板模式)
|
||||||
Title string
|
TaskID string // 任务ID(任务模式)或模板ID(模板模式,用于日志记录)
|
||||||
Text string
|
TemplateID string // 模板ID(仅模板模式使用)
|
||||||
HTML string
|
Name string // 任务或模板名称(用于日志记录)
|
||||||
URL string
|
Title string
|
||||||
MarkDown string
|
Text string
|
||||||
CallerIp string
|
HTML string
|
||||||
|
URL string
|
||||||
|
MarkDown string
|
||||||
|
CallerIp string
|
||||||
|
|
||||||
// @提及相关字段
|
// @提及相关字段
|
||||||
AtMobiles []string
|
AtMobiles []string
|
||||||
@@ -82,31 +91,88 @@ func (sm *SendMessageService) AsyncSend(task models.TaskIns) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendPreCheck 发送前数据准备和预检查
|
// SendPreCheck 发送前数据准备和预检查
|
||||||
|
// 支持两种模式:
|
||||||
|
// 1. SendModeTask:传统任务模式,使用 TaskID 查询任务和实例
|
||||||
|
// 2. SendModeTemplate:模板模式,使用 TemplateID 查询模板关联的实例
|
||||||
func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) {
|
func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) {
|
||||||
errStr := ""
|
errStr := ""
|
||||||
entry := logrus.WithFields(logrus.Fields{
|
entry := logrus.WithFields(logrus.Fields{
|
||||||
"prefix": "[Message PreChecK]",
|
"prefix": "[Message PreChecK]",
|
||||||
})
|
})
|
||||||
sendTaskService := send_task_service.SendTaskService{
|
|
||||||
ID: sm.TaskID,
|
var task models.TaskIns
|
||||||
}
|
|
||||||
task, err := sendTaskService.GetTaskWithIns()
|
switch sm.SendMode {
|
||||||
if err != nil {
|
case SendModeTemplate:
|
||||||
errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID)
|
// 模板模式:使用模板ID获取实例
|
||||||
|
if sm.TemplateID == "" {
|
||||||
|
errStr = "模板模式下 TemplateID 不能为空"
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板关联的实例列表
|
||||||
|
insList, err := models.GetTemplateInsList(sm.TemplateID)
|
||||||
|
if err != nil {
|
||||||
|
errStr = fmt.Sprintf("模板[%s]实例查询失败:%s", sm.TemplateID, err)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
if len(insList) == 0 {
|
||||||
|
errStr = fmt.Sprintf("模板[%s]没有关联任何实例!", sm.TemplateID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造虚拟任务对象(用于兼容现有发送逻辑)
|
||||||
|
// 将模板ID作为TaskID使用,便于日志记录
|
||||||
|
task.ID = sm.TaskID // 使用传入的TaskID(实际是模板ID)
|
||||||
|
task.InsData = make([]models.SendTasksInsRes, 0, len(insList))
|
||||||
|
for _, ins := range insList {
|
||||||
|
task.InsData = append(task.InsData, ins)
|
||||||
|
}
|
||||||
|
entry.Infof("模板[%s]加载了 %d 个实例", sm.TemplateID, len(insList))
|
||||||
|
return task, nil
|
||||||
|
|
||||||
|
case SendModeTask:
|
||||||
|
// 传统任务模式:使用任务ID查询
|
||||||
|
if sm.TaskID == "" {
|
||||||
|
errStr = "任务模式下 TaskID 不能为空"
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTaskService := send_task_service.SendTaskService{
|
||||||
|
ID: sm.TaskID,
|
||||||
|
}
|
||||||
|
task, err := sendTaskService.GetTaskWithIns()
|
||||||
|
if err != nil {
|
||||||
|
errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
if task.ID == "" {
|
||||||
|
errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
if len(task.InsData) == 0 {
|
||||||
|
errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
// 设置任务名称用于日志记录
|
||||||
|
if sm.Name == "" {
|
||||||
|
sm.Name = task.Name
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// SendMode 未设置或无效
|
||||||
|
errStr = fmt.Sprintf("SendMode 未设置或无效: %s,必须是 '%s' 或 '%s'", sm.SendMode, SendModeTask, SendModeTemplate)
|
||||||
entry.Errorf(errStr)
|
entry.Errorf(errStr)
|
||||||
return task, errors.New(errStr)
|
return task, errors.New(errStr)
|
||||||
}
|
}
|
||||||
if task.ID == "" {
|
|
||||||
errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID)
|
|
||||||
entry.Errorf(errStr)
|
|
||||||
return task, errors.New(errStr)
|
|
||||||
}
|
|
||||||
if len(task.InsData) == 0 {
|
|
||||||
errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID)
|
|
||||||
entry.Errorf(errStr)
|
|
||||||
return task, errors.New(errStr)
|
|
||||||
}
|
|
||||||
return task, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send 发送一个消息任务的所有实例
|
// Send 发送一个消息任务的所有实例
|
||||||
@@ -143,13 +209,6 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
|
|||||||
sm.LogsAndStatusMark(fmt.Sprintf("实例类型: %s + %s", ins.WayType, ins.ContentType), sm.Status)
|
sm.LogsAndStatusMark(fmt.Sprintf("实例类型: %s + %s", ins.WayType, ins.ContentType), sm.Status)
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("实例配置: %s", ins.Config), sm.Status)
|
sm.LogsAndStatusMark(fmt.Sprintf("实例配置: %s", ins.Config), sm.Status)
|
||||||
|
|
||||||
// 发送内容校验绑定
|
|
||||||
typeC, content := sm.GetSendMsg(ins.SendTasksIns)
|
|
||||||
if content == "" {
|
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送渠道的校验
|
// 发送渠道的校验
|
||||||
errStr, msgObj := wayService.ValidateDiffWay()
|
errStr, msgObj := wayService.ValidateDiffWay()
|
||||||
if errStr != "" {
|
if errStr != "" {
|
||||||
@@ -165,16 +224,33 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建统一消息内容(支持@功能)
|
// 根据发送模式构建消息内容
|
||||||
unifiedContent := &unified.UnifiedMessageContent{
|
var unifiedContent *unified.UnifiedMessageContent
|
||||||
Title: sm.Title,
|
if sm.SendMode == SendModeTemplate {
|
||||||
Text: sm.Text,
|
// 模板模式:根据实例的 ContentType 精确发送对应类型的内容
|
||||||
HTML: sm.HTML,
|
unifiedContent = sm.BuildTemplateContent(ins.SendTasksIns)
|
||||||
Markdown: sm.MarkDown,
|
if unifiedContent == nil {
|
||||||
URL: sm.URL,
|
sm.LogsAndStatusMark(fmt.Sprintf("模板内容为空,实例类型: %s", ins.ContentType), SendFail)
|
||||||
AtMobiles: sm.AtMobiles,
|
continue
|
||||||
AtUserIds: sm.AtUserIds,
|
}
|
||||||
AtAll: sm.AtAll,
|
} else {
|
||||||
|
// 任务模式:使用现有逻辑(支持内容类型回退)
|
||||||
|
typeC, content := sm.GetSendMsg(ins.SendTasksIns)
|
||||||
|
if content == "" {
|
||||||
|
sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 构建统一消息内容(支持@功能)
|
||||||
|
unifiedContent = &unified.UnifiedMessageContent{
|
||||||
|
Title: sm.Title,
|
||||||
|
Text: sm.Text,
|
||||||
|
HTML: sm.HTML,
|
||||||
|
Markdown: sm.MarkDown,
|
||||||
|
URL: sm.URL,
|
||||||
|
AtMobiles: sm.AtMobiles,
|
||||||
|
AtUserIds: sm.AtUserIds,
|
||||||
|
AtAll: sm.AtAll,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 SendUnified 方法(自动格式转换和@功能支持)
|
// 使用 SendUnified 方法(自动格式转换和@功能支持)
|
||||||
@@ -216,9 +292,17 @@ func (sm *SendMessageService) AppendSendContent() {
|
|||||||
|
|
||||||
// RecordSendLog 记录发送日志
|
// RecordSendLog 记录发送日志
|
||||||
func (sm *SendMessageService) RecordSendLog() {
|
func (sm *SendMessageService) RecordSendLog() {
|
||||||
|
// 确定日志类型
|
||||||
|
logType := "task"
|
||||||
|
if sm.SendMode == SendModeTemplate {
|
||||||
|
logType = "template"
|
||||||
|
}
|
||||||
|
|
||||||
log := models.SendTasksLogs{
|
log := models.SendTasksLogs{
|
||||||
Log: strings.Join(sm.LogOutput, "\n"),
|
Log: strings.Join(sm.LogOutput, "\n"),
|
||||||
TaskID: sm.TaskID,
|
TaskID: sm.TaskID,
|
||||||
|
Type: logType,
|
||||||
|
Name: sm.Name,
|
||||||
Status: &sm.Status,
|
Status: &sm.Status,
|
||||||
CallerIp: sm.CallerIp,
|
CallerIp: sm.CallerIp,
|
||||||
}
|
}
|
||||||
@@ -237,7 +321,52 @@ func (sm *SendMessageService) TransError(err string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSendMsg 获取对应消息内容
|
// BuildTemplateContent 构建模板模式的消息内容
|
||||||
|
// 模板模式:根据实例的 ContentType 精确匹配对应类型的内容,只传递该类型的内容
|
||||||
|
func (sm *SendMessageService) BuildTemplateContent(ins models.SendTasksIns) *unified.UnifiedMessageContent {
|
||||||
|
contentType := strings.ToLower(ins.ContentType)
|
||||||
|
|
||||||
|
// 内容类型映射表
|
||||||
|
contentMap := map[string]string{
|
||||||
|
unified.FormatTypeText: sm.Text,
|
||||||
|
unified.FormatTypeHTML: sm.HTML,
|
||||||
|
unified.FormatTypeMarkdown: sm.MarkDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查内容是否存在
|
||||||
|
contentValue, exists := contentMap[contentType]
|
||||||
|
if !exists {
|
||||||
|
logrus.Warnf("模板模式:未知的内容类型 %s", ins.ContentType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if contentValue == "" {
|
||||||
|
logrus.Warnf("模板模式:实例要求的 %s 类型内容为空", contentType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建消息内容,只填充实例要求的类型
|
||||||
|
content := &unified.UnifiedMessageContent{
|
||||||
|
Title: sm.Title,
|
||||||
|
URL: sm.URL,
|
||||||
|
AtMobiles: sm.AtMobiles,
|
||||||
|
AtUserIds: sm.AtUserIds,
|
||||||
|
AtAll: sm.AtAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据类型填充对应字段
|
||||||
|
switch contentType {
|
||||||
|
case unified.FormatTypeText:
|
||||||
|
content.Text = contentValue
|
||||||
|
case unified.FormatTypeHTML:
|
||||||
|
content.HTML = contentValue
|
||||||
|
case unified.FormatTypeMarkdown:
|
||||||
|
content.Markdown = contentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSendMsg 获取对应消息内容(任务模式使用)
|
||||||
// 先根据实例设置的类型取,取不到或者取到的是空,则使用text发送
|
// 先根据实例设置的类型取,取不到或者取到的是空,则使用text发送
|
||||||
func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) {
|
func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) {
|
||||||
data := map[string]string{}
|
data := map[string]string{}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ const tabRoutes: TabRoute[] = [
|
|||||||
{ name: '发信日志', path: '/sendlogs' },
|
{ name: '发信日志', path: '/sendlogs' },
|
||||||
{ name: '托管消息', path: '/hostedmessage' },
|
{ name: '托管消息', path: '/hostedmessage' },
|
||||||
{ name: '定时消息', path: '/cronmessages' },
|
{ name: '定时消息', path: '/cronmessages' },
|
||||||
|
{ name: '模板任务', path: '/templates' },
|
||||||
{ name: '发信任务', path: '/sendtasks' },
|
{ name: '发信任务', path: '/sendtasks' },
|
||||||
{ name: '发信渠道', path: '/sendways' },
|
{ name: '发信渠道', path: '/sendways' },
|
||||||
{ name: '设置偏好', path: '/settings' }
|
{ name: '设置偏好', path: '/settings' }
|
||||||
@@ -326,9 +327,9 @@ const siteTitle = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 桌面端导航 -->
|
<!-- 桌面端导航 -->
|
||||||
<div class="hidden md:flex space-x-6 lg:space-x-8">
|
<div class="hidden md:flex space-x-2 lg:space-x-3">
|
||||||
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab)" :class="[
|
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab)" :class="[
|
||||||
'relative py-2 px-3 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
|
'relative py-1.5 px-2 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
|
||||||
activeTab === tab.name
|
activeTab === tab.name
|
||||||
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-400/10'
|
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-400/10'
|
||||||
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:text-blue-400 dark:hover:bg-white/5'
|
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:text-blue-400 dark:hover:bg-white/5'
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||||
|
import Pagination from '@/components/ui/Pagination.vue'
|
||||||
|
import TemplateApiViewer from './TemplateApiViewer.vue'
|
||||||
|
import TemplateInstanceConfig from './TemplateInstanceConfig.vue'
|
||||||
|
import TemplateEditor from './TemplateEditor.vue'
|
||||||
|
import { request } from '@/api/api'
|
||||||
|
import { getPageSize } from '@/util/pageUtils'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
interface MessageTemplate {
|
||||||
|
id: string // 模板ID是字符串类型(UUID)
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
text_template: string
|
||||||
|
html_template: string
|
||||||
|
markdown_template: string
|
||||||
|
placeholders: string
|
||||||
|
at_mobiles?: string
|
||||||
|
at_user_ids?: string
|
||||||
|
is_at_all?: boolean
|
||||||
|
status: string
|
||||||
|
created_on: string
|
||||||
|
modified_on: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Placeholder {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
default: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
let state = reactive({
|
||||||
|
tableData: [] as MessageTemplate[],
|
||||||
|
total: 0,
|
||||||
|
currPage: 1,
|
||||||
|
pageSize: getPageSize() as number,
|
||||||
|
search: '',
|
||||||
|
status: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPreviewOpen = ref(false)
|
||||||
|
const currentTemplate = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
// API代码查看器状态
|
||||||
|
const isApiViewerOpen = ref(false)
|
||||||
|
const selectedTemplateForApi = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
// 配置实例状态
|
||||||
|
const isInstanceConfigOpen = ref(false)
|
||||||
|
const selectedTemplateForInstance = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
// 模板编辑器状态
|
||||||
|
const isEditorOpen = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const selectedTemplateForEdit = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
const previewData = reactive({
|
||||||
|
text: '',
|
||||||
|
html: '',
|
||||||
|
markdown: '',
|
||||||
|
params: {} as Record<string, string>
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||||
|
|
||||||
|
const queryListData = async (page: number, size: number, text = '', status = '') => {
|
||||||
|
const params: any = { page, size, text, status }
|
||||||
|
const rsp = await request.get('/templates/list', { params })
|
||||||
|
state.tableData = rsp.data.data.lists || []
|
||||||
|
state.total = rsp.data.data.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = async (page: number) => {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
state.currPage = page
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterFunc = async () => {
|
||||||
|
state.currPage = 1
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
selectedTemplateForEdit.value = null
|
||||||
|
isEditorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (template: MessageTemplate) => {
|
||||||
|
isEditing.value = true
|
||||||
|
selectedTemplateForEdit.value = template
|
||||||
|
isEditorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditorSaved = async () => {
|
||||||
|
// 刷新列表
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTemplate = async (id: string) => {
|
||||||
|
const rsp = await request.post('/templates/delete', { id })
|
||||||
|
if (rsp.status === 200 && rsp.data.code === 200) {
|
||||||
|
toast.success(rsp.data.msg)
|
||||||
|
// 刷新列表,处理status参数
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开API查看器
|
||||||
|
const handleViewApi = (template: MessageTemplate) => {
|
||||||
|
selectedTemplateForApi.value = template
|
||||||
|
isApiViewerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开配置实例
|
||||||
|
const handleConfigInstance = (template: MessageTemplate) => {
|
||||||
|
selectedTemplateForInstance.value = template
|
||||||
|
isInstanceConfigOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看日志
|
||||||
|
const handleViewLogs = (template: MessageTemplate) => {
|
||||||
|
// 跳转到发信日志页面,携带 taskid 参数(传递模板 id)
|
||||||
|
router.push(`/sendlogs?taskid=${template.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPreview = async (template: MessageTemplate) => {
|
||||||
|
currentTemplate.value = template
|
||||||
|
|
||||||
|
// 解析占位符
|
||||||
|
let placeholders: Placeholder[] = []
|
||||||
|
try {
|
||||||
|
placeholders = JSON.parse(template.placeholders || '[]')
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 初始化预览参数
|
||||||
|
previewData.params = {}
|
||||||
|
placeholders.forEach(p => {
|
||||||
|
previewData.params[p.key] = p.default || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshPreview()
|
||||||
|
isPreviewOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshPreview = async () => {
|
||||||
|
if (!currentTemplate.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rsp = await request.post('/templates/preview', {
|
||||||
|
id: currentTemplate.value.id,
|
||||||
|
params: previewData.params
|
||||||
|
})
|
||||||
|
previewData.text = rsp.data.data.text || ''
|
||||||
|
previewData.html = rsp.data.data.html || ''
|
||||||
|
previewData.markdown = rsp.data.data.markdown || ''
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || '预览失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await queryListData(1, state.pageSize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-2 w-full max-w-6xl mx-auto space-y-2">
|
||||||
|
<!-- 搜索和筛选 -->
|
||||||
|
<div class="flex flex-row sm:flex-row sm:items-center gap-2 -mx-2 px-2 sm:mx-0 sm:px-0">
|
||||||
|
<div class="flex-[3] sm:flex-initial min-w-0">
|
||||||
|
<Input
|
||||||
|
v-model="state.search"
|
||||||
|
placeholder="搜索模板名称或描述..."
|
||||||
|
class="w-full sm:w-64"
|
||||||
|
@keyup.enter="filterFunc"
|
||||||
|
@blur="filterFunc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-[2] sm:flex-initial min-w-0">
|
||||||
|
<Select v-model="state.status" class="w-full" @update:model-value="filterFunc">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="enabled">启用</SelectItem>
|
||||||
|
<SelectItem value="disabled">禁用</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<Button @click="openAddDialog">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
新建模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-24">ID</TableHead>
|
||||||
|
<TableHead>模板名称</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>支持格式</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead class="whitespace-nowrap w-[160px]">创建时间</TableHead>
|
||||||
|
<TableHead class="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<!-- 空数据展示 -->
|
||||||
|
<TableRow v-if="state.tableData.length === 0">
|
||||||
|
<TableCell colspan="7" class="text-center py-12">
|
||||||
|
<EmptyTableState
|
||||||
|
title="暂无消息模板"
|
||||||
|
description="还没有创建任何消息模板,点击右上角按钮创建新模板"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</EmptyTableState>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
<!-- 数据行 -->
|
||||||
|
<TableRow v-for="item in state.tableData" :key="item.id">
|
||||||
|
<TableCell>{{ item.id }}</TableCell>
|
||||||
|
<TableCell class="font-medium">{{ item.name }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="max-w-xs truncate">{{ item.description || '-' }}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Badge v-if="item.text_template" variant="secondary">Text</Badge>
|
||||||
|
<Badge v-if="item.html_template" variant="secondary">HTML</Badge>
|
||||||
|
<Badge v-if="item.markdown_template" variant="secondary">Markdown</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="item.status === 'enabled' ? 'default' : 'secondary'">
|
||||||
|
{{ item.status === 'enabled' ? '启用' : '禁用' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="whitespace-nowrap w-[160px]">{{ item.created_on }}</TableCell>
|
||||||
|
<TableCell class="text-center space-x-2">
|
||||||
|
<Button size="sm" variant="outline" @click="handleViewLogs(item)">日志</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="handleViewApi(item)">接口</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="openPreview(item)">预览</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="openEditDialog(item)">编辑</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="handleConfigInstance(item)">实例</Button>
|
||||||
|
|
||||||
|
|
||||||
|
<Button size="sm" variant="destructive" @click="deleteTemplate(item.id)">删除</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<Pagination
|
||||||
|
:total="state.total"
|
||||||
|
:current-page="state.currPage"
|
||||||
|
:page-size="state.pageSize"
|
||||||
|
@page-change="changePage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 模板编辑器 -->
|
||||||
|
<TemplateEditor
|
||||||
|
:open="isEditorOpen"
|
||||||
|
:is-editing="isEditing"
|
||||||
|
:template-data="selectedTemplateForEdit"
|
||||||
|
@update:open="isEditorOpen = $event"
|
||||||
|
@saved="handleEditorSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 预览对话框 -->
|
||||||
|
<Dialog v-model:open="isPreviewOpen">
|
||||||
|
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>模板预览 - {{ currentTemplate?.name }}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- 参数输入 -->
|
||||||
|
<div v-if="currentTemplate" class="space-y-2">
|
||||||
|
<Label>填写占位符参数</Label>
|
||||||
|
<div
|
||||||
|
v-for="(_, key) in previewData.params"
|
||||||
|
:key="key"
|
||||||
|
class="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<Label class="w-32">{{ key }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model="previewData.params[key]"
|
||||||
|
:placeholder="`请输入 ${key}`"
|
||||||
|
@input="refreshPreview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览结果 -->
|
||||||
|
<Tabs default-value="text" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="text">Text</TabsTrigger>
|
||||||
|
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||||
|
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="text">
|
||||||
|
<div class="p-4 border rounded-md bg-muted/50">
|
||||||
|
<pre class="whitespace-pre-wrap">{{ previewData.text || '无Text模板' }}</pre>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="html">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="p-4 border rounded-md bg-muted/50">
|
||||||
|
<div v-html="previewData.html || '无HTML模板'"></div>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs">
|
||||||
|
<summary class="cursor-pointer">查看HTML源码</summary>
|
||||||
|
<pre class="mt-2 p-2 bg-muted rounded">{{ previewData.html }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="markdown">
|
||||||
|
<div class="p-4 border rounded-md bg-muted/50">
|
||||||
|
<pre class="whitespace-pre-wrap">{{ previewData.markdown || '无Markdown模板' }}</pre>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button @click="isPreviewOpen = false">关闭</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- API代码查看器 -->
|
||||||
|
<TemplateApiViewer
|
||||||
|
:open="isApiViewerOpen"
|
||||||
|
:template-data="selectedTemplateForApi || undefined"
|
||||||
|
@update:open="isApiViewerOpen = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 配置实例 -->
|
||||||
|
<TemplateInstanceConfig
|
||||||
|
:open="isInstanceConfigOpen"
|
||||||
|
:template-data="selectedTemplateForInstance"
|
||||||
|
@update:open="isInstanceConfigOpen = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ref, defineComponent } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
// @ts-ignore
|
||||||
|
import { TemplateApiStrGenerate } from '@/util/viewApi'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'TemplateApiViewer',
|
||||||
|
components: {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
Badge
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
templateData: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:open'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// 处理关闭事件
|
||||||
|
const handleUpdateOpen = (value: boolean) => {
|
||||||
|
emit('update:open', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前选中的标签
|
||||||
|
const activeTab = ref('curl')
|
||||||
|
|
||||||
|
// 代码语言选项
|
||||||
|
const codeLanguages = [
|
||||||
|
{ value: 'curl', label: 'cURL', icon: '🌐' },
|
||||||
|
{ value: 'javascript', label: 'JS', icon: '🟨' },
|
||||||
|
{ value: 'python', label: 'Python', icon: '🐍' },
|
||||||
|
{ value: 'php', label: 'PHP', icon: '🐘' },
|
||||||
|
{ value: 'golang', label: 'Go', icon: '🐹' },
|
||||||
|
{ value: 'java', label: 'Java', icon: '☕' },
|
||||||
|
{ value: 'rust', label: 'Rust', icon: '🦀' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 生成API代码示例
|
||||||
|
const generateApiCode = (language: string) => {
|
||||||
|
const templateId = props.templateData?.id || 'TEMPLATE_ID'
|
||||||
|
const placeholders = props.templateData?.placeholders || '[]'
|
||||||
|
|
||||||
|
switch (language) {
|
||||||
|
case 'curl':
|
||||||
|
return TemplateApiStrGenerate.getCurlString(templateId, placeholders)
|
||||||
|
case 'javascript':
|
||||||
|
return TemplateApiStrGenerate.getNodeString(templateId, placeholders)
|
||||||
|
case 'python':
|
||||||
|
return TemplateApiStrGenerate.getPythonString(templateId, placeholders)
|
||||||
|
case 'php':
|
||||||
|
return TemplateApiStrGenerate.getPHPString(templateId, placeholders)
|
||||||
|
case 'golang':
|
||||||
|
return TemplateApiStrGenerate.getGolangString(templateId, placeholders)
|
||||||
|
case 'java':
|
||||||
|
return TemplateApiStrGenerate.getJavaString(templateId, placeholders)
|
||||||
|
case 'rust':
|
||||||
|
return TemplateApiStrGenerate.getRustString(templateId, placeholders)
|
||||||
|
default:
|
||||||
|
return '// 请选择一种编程语言查看示例代码'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制代码到剪贴板
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.success('复制成功')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleUpdateOpen,
|
||||||
|
activeTab,
|
||||||
|
codeLanguages,
|
||||||
|
generateApiCode,
|
||||||
|
copyToClipboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="handleUpdateOpen">
|
||||||
|
<DialogContent class="w-[800px] sm:w-[900px] lg:w-[1000px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<span>模板API接口</span>
|
||||||
|
<Badge v-if="templateData" variant="outline">{{ templateData.name }}</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- API 信息概览 -->
|
||||||
|
<div class="border rounded-lg p-4 space-y-2 bg-white dark:bg-slate-900">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="default">POST</Badge>
|
||||||
|
<code class="text-sm bg-gray-100 dark:bg-slate-800 px-2 py-1 rounded">/api/v2/message/send</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">使用模板发送消息(V2接口)</p>
|
||||||
|
<div class="mt-3 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p><strong>模板ID:</strong> <code class="bg-gray-100 dark:bg-slate-800 px-1 py-0.5 rounded">{{ templateData?.id }}</code></p>
|
||||||
|
<p><strong>必填参数:</strong> token (加密token), title (消息标题), placeholders (占位符键值对)</p>
|
||||||
|
<p><strong>可选参数:</strong> 根据模板配置的@提醒字段自动应用</p>
|
||||||
|
<p class="text-amber-600 dark:text-amber-400"><strong>⚠️ 注意:</strong> V2接口使用加密token,不支持明文ID</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 代码示例 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold">代码示例</h3>
|
||||||
|
|
||||||
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-7 gap-1">
|
||||||
|
<TabsTrigger v-for="lang in codeLanguages" :key="lang.value" :value="lang.value"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs">
|
||||||
|
<span>{{ lang.icon }}</span>
|
||||||
|
<span class="hidden sm:inline">{{ lang.label }}</span>
|
||||||
|
<span class="sm:hidden">{{ lang.label.slice(0, 3) }}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent v-for="lang in codeLanguages" :key="lang.value" :value="lang.value" class="mt-4">
|
||||||
|
<div class="relative">
|
||||||
|
<Button size="sm" variant="outline" class="absolute top-2 right-2 z-10"
|
||||||
|
@click="copyToClipboard(generateApiCode(lang.value))">
|
||||||
|
复制代码
|
||||||
|
</Button>
|
||||||
|
<pre
|
||||||
|
class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-xs leading-relaxed max-w-full whitespace-pre-wrap break-words"><code class="text-xs font-mono">{{ generateApiCode(lang.value) }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 说明 -->
|
||||||
|
<div class="border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950 p-3 rounded text-sm space-y-1">
|
||||||
|
<p class="font-semibold text-blue-900 dark:text-blue-200">💡 使用说明</p>
|
||||||
|
<ul class="text-blue-800 dark:text-blue-300 space-y-1 ml-4 list-disc">
|
||||||
|
<li><strong>token 参数:</strong>需要使用加密后的 token,不能直接使用明文模板ID(安全考虑)</li>
|
||||||
|
<li><strong>placeholders 参数:</strong>用于替换模板中的占位符,格式为 <code class="bg-blue-100 dark:bg-blue-900 px-1 rounded">{"key": "value"}</code></li>
|
||||||
|
<li>如果模板配置了@提醒,会自动应用到发送的消息中</li>
|
||||||
|
<li>支持 Text、HTML、Markdown 三种格式,根据实例配置精确发送对应类型</li>
|
||||||
|
<li>系统会自动遍历所有启用的实例进行发送</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 代码块样式优化 */
|
||||||
|
pre {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { request } from '@/api/api'
|
||||||
|
|
||||||
|
interface Placeholder {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
default: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateData {
|
||||||
|
id?: string // 模板ID是字符串类型(UUID)
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
text_template: string
|
||||||
|
html_template: string
|
||||||
|
markdown_template: string
|
||||||
|
placeholders: string
|
||||||
|
at_mobiles?: string
|
||||||
|
at_user_ids?: string
|
||||||
|
is_at_all?: boolean
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件props
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
isEditing?: boolean
|
||||||
|
templateData?: TemplateData | null
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
open: false,
|
||||||
|
isEditing: false,
|
||||||
|
templateData: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Textarea refs for inserting placeholders
|
||||||
|
const textTemplateRef = ref<any>(null)
|
||||||
|
const htmlTemplateRef = ref<any>(null)
|
||||||
|
const markdownTemplateRef = ref<any>(null)
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<TemplateData>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
text_template: '',
|
||||||
|
html_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
placeholders: '[]',
|
||||||
|
at_mobiles: '',
|
||||||
|
at_user_ids: '',
|
||||||
|
is_at_all: false,
|
||||||
|
status: 'enabled'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用独立的响应式数组来管理占位符,避免频繁的 JSON 序列化
|
||||||
|
const placeholdersList = ref<Placeholder[]>([])
|
||||||
|
|
||||||
|
// 过滤出有效的占位符(key 不为空)
|
||||||
|
const validPlaceholders = computed(() => {
|
||||||
|
return placeholdersList.value.filter(ph => ph.key && ph.key.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听占位符列表变化,同步到 formData(使用防抖)
|
||||||
|
let placeholderDebounceTimer: number | null = null
|
||||||
|
watch(placeholdersList, () => {
|
||||||
|
if (placeholderDebounceTimer) {
|
||||||
|
clearTimeout(placeholderDebounceTimer)
|
||||||
|
}
|
||||||
|
placeholderDebounceTimer = window.setTimeout(() => {
|
||||||
|
formData.value.placeholders = JSON.stringify(placeholdersList.value)
|
||||||
|
}, 300)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 添加占位符
|
||||||
|
const addPlaceholder = () => {
|
||||||
|
placeholdersList.value.push({ key: '', label: '', default: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除占位符
|
||||||
|
const removePlaceholder = (index: number) => {
|
||||||
|
placeholdersList.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入占位符到模板
|
||||||
|
const insertPlaceholder = async (type: 'text' | 'html' | 'markdown', key: string) => {
|
||||||
|
const placeholder = `{{${key}}}`
|
||||||
|
let targetRef: any = null
|
||||||
|
|
||||||
|
if (type === 'text') targetRef = textTemplateRef.value
|
||||||
|
else if (type === 'html') targetRef = htmlTemplateRef.value
|
||||||
|
else if (type === 'markdown') targetRef = markdownTemplateRef.value
|
||||||
|
|
||||||
|
if (!targetRef) return
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const textarea = targetRef.$el || targetRef
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const text = formData.value[`${type}_template`]
|
||||||
|
|
||||||
|
const before = text.substring(0, start)
|
||||||
|
const after = text.substring(end)
|
||||||
|
|
||||||
|
formData.value[`${type}_template`] = before + placeholder + after
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
textarea.focus()
|
||||||
|
const newPosition = start + placeholder.length
|
||||||
|
textarea.setSelectionRange(newPosition, newPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
text_template: '',
|
||||||
|
html_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
placeholders: '[]',
|
||||||
|
at_mobiles: '',
|
||||||
|
at_user_ids: '',
|
||||||
|
is_at_all: false,
|
||||||
|
status: 'enabled'
|
||||||
|
}
|
||||||
|
placeholdersList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载模板数据
|
||||||
|
const loadTemplateData = (template: TemplateData) => {
|
||||||
|
formData.value = {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
text_template: template.text_template,
|
||||||
|
html_template: template.html_template,
|
||||||
|
markdown_template: template.markdown_template,
|
||||||
|
placeholders: template.placeholders,
|
||||||
|
at_mobiles: template.at_mobiles || '',
|
||||||
|
at_user_ids: template.at_user_ids || '',
|
||||||
|
is_at_all: template.is_at_all || false,
|
||||||
|
status: template.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析占位符
|
||||||
|
try {
|
||||||
|
placeholdersList.value = JSON.parse(template.placeholders || '[]')
|
||||||
|
} catch {
|
||||||
|
placeholdersList.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存模板
|
||||||
|
const saveTemplate = async () => {
|
||||||
|
if (!formData.value.name.trim()) {
|
||||||
|
toast.error('请输入模板名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步占位符数据
|
||||||
|
formData.value.placeholders = JSON.stringify(placeholdersList.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = props.isEditing ? '/templates/edit' : '/templates/add'
|
||||||
|
await request.post(url, formData.value)
|
||||||
|
toast.success(props.isEditing ? '更新模板成功' : '添加模板成功')
|
||||||
|
emit('update:open', false)
|
||||||
|
emit('saved')
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听对话框打开状态
|
||||||
|
watch(() => props.open, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
if (props.isEditing && props.templateData) {
|
||||||
|
loadTemplateData(props.templateData)
|
||||||
|
} else {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="(value) => $emit('update:open', value)">
|
||||||
|
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ isEditing ? '编辑模板' : '新建模板' }}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">模板名称 *</Label>
|
||||||
|
<Input id="name" v-model="formData.name" placeholder="请输入模板名称" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">描述</Label>
|
||||||
|
<Textarea id="description" v-model="formData.description" placeholder="请输入模板描述" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>状态</Label>
|
||||||
|
<Select v-model="formData.status">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue placeholder="全部" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="enabled">启用</SelectItem>
|
||||||
|
<SelectItem value="disabled">禁用</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 占位符配置 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<Label>占位符配置</Label>
|
||||||
|
<Button size="sm" variant="outline" @click="addPlaceholder">添加占位符</Button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(placeholder, index) in placeholdersList" :key="index" class="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
v-model="placeholder.key"
|
||||||
|
placeholder="key (如: username)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="placeholder.label"
|
||||||
|
placeholder="标签 (如: 用户名)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="placeholder.default"
|
||||||
|
placeholder="默认值"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" @click="removePlaceholder(index)">删除</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
在模板中使用 <code v-text="'{{key}}'"></code> 来引用占位符,例如:Hello <code v-text="'{{username}}'"></code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- @提醒配置 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>@提醒配置</Label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_at_all"
|
||||||
|
v-model="formData.is_at_all"
|
||||||
|
class="w-4 h-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label for="is_at_all" class="cursor-pointer">@所有人</Label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="at_mobiles">@手机号(多个用逗号分隔)</Label>
|
||||||
|
<Input
|
||||||
|
id="at_mobiles"
|
||||||
|
v-model="formData.at_mobiles"
|
||||||
|
placeholder="例如:13800138000,13900139000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="at_user_ids">@用户ID(多个用逗号分隔)</Label>
|
||||||
|
<Input
|
||||||
|
id="at_user_ids"
|
||||||
|
v-model="formData.at_user_ids"
|
||||||
|
placeholder="例如:user001,user002"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
配置后,使用此模板发送消息时会自动@指定的用户(适用于支持@功能的渠道,如钉钉、企业微信等)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模板内容 -->
|
||||||
|
<Tabs default-value="text" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="text">Text</TabsTrigger>
|
||||||
|
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||||
|
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="text" class="space-y-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label>纯文本模板</Label>
|
||||||
|
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||||
|
<Button
|
||||||
|
v-for="ph in validPlaceholders"
|
||||||
|
:key="ph.key"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||||
|
@click="insertPlaceholder('text', ph.key)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ph.key}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref="textTemplateRef"
|
||||||
|
v-model="formData.text_template"
|
||||||
|
placeholder="请输入纯文本模板内容,可使用 {{key}} 作为占位符"
|
||||||
|
rows="10"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="html" class="space-y-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label>HTML模板</Label>
|
||||||
|
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||||
|
<Button
|
||||||
|
v-for="ph in validPlaceholders"
|
||||||
|
:key="ph.key"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||||
|
@click="insertPlaceholder('html', ph.key)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ph.key}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref="htmlTemplateRef"
|
||||||
|
v-model="formData.html_template"
|
||||||
|
placeholder="请输入HTML模板内容,可使用 {{key}} 作为占位符"
|
||||||
|
rows="10"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="markdown" class="space-y-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label>Markdown模板</Label>
|
||||||
|
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||||
|
<Button
|
||||||
|
v-for="ph in validPlaceholders"
|
||||||
|
:key="ph.key"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||||
|
@click="insertPlaceholder('markdown', ph.key)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ph.key}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref="markdownTemplateRef"
|
||||||
|
v-model="formData.markdown_template"
|
||||||
|
placeholder="请输入Markdown模板内容,可使用 {{key}} 作为占位符"
|
||||||
|
rows="10"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="$emit('update:open', false)">取消</Button>
|
||||||
|
<Button @click="saveTemplate">保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxViewport
|
||||||
|
} from '@/components/ui/combobox'
|
||||||
|
import { CheckIcon } from 'lucide-vue-next'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { CONSTANT } from '@/constant'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { request } from '@/api/api'
|
||||||
|
import { generateBizUniqueID } from '@/util/uuid'
|
||||||
|
|
||||||
|
// 组件props
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
templateData?: any // 模板数据
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
open: false,
|
||||||
|
templateData: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 前端的页面添加配置
|
||||||
|
const waysConfigMap = CONSTANT.WAYS_DATA
|
||||||
|
|
||||||
|
// 搜索相关状态
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const isSearching = ref(false)
|
||||||
|
const channelName = ref('')
|
||||||
|
|
||||||
|
// 当前显示的选项(搜索结果或所有选项)
|
||||||
|
const displayOptions = ref<Array<{ id: string, name: string, type: string }>>([])
|
||||||
|
|
||||||
|
// 输入框显示值(只在用户搜索时显示搜索内容)
|
||||||
|
const inputDisplayValue = computed({
|
||||||
|
get: () => searchQuery.value,
|
||||||
|
set: (value: string) => {
|
||||||
|
searchQuery.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前选中渠道的配置
|
||||||
|
const currentChannelConfig = computed(() => {
|
||||||
|
// 先根据label找到type
|
||||||
|
let type = displayOptions.value.find(item => item.name === channelName.value)?.type
|
||||||
|
// 再根据type找到配置
|
||||||
|
let rs = waysConfigMap.find(item => item.type === type) || null;
|
||||||
|
|
||||||
|
return rs;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
// 监听渠道变化
|
||||||
|
const handlechannelNameChange = () => {
|
||||||
|
// 数据加载后,text/html单选设置默认选中(这里选第一个)
|
||||||
|
if (currentChannelConfig.value?.taskInsRadios.length > 0) {
|
||||||
|
formData.value.templ_type = currentChannelConfig.value?.taskInsRadios[0].subLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加单条实例配置
|
||||||
|
const handleAddSubmit = async () => {
|
||||||
|
// 组建表单数据
|
||||||
|
let postData = {
|
||||||
|
"id": generateBizUniqueID('I'),
|
||||||
|
"enable": 1,
|
||||||
|
"template_id": props.templateData.id,
|
||||||
|
"way_id": displayOptions.value[0]?.id,
|
||||||
|
"way_type": displayOptions.value[0]?.type,
|
||||||
|
"way_name": displayOptions.value[0]?.name,
|
||||||
|
"content_type": formData.value.templ_type,
|
||||||
|
"config": JSON.stringify(formData.value),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.post('/templates/ins/addone', postData)
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
toast.success(response.data.msg)
|
||||||
|
// 重新加载实例列表
|
||||||
|
await queryInsListData()
|
||||||
|
// 清空表单
|
||||||
|
channelName.value = ''
|
||||||
|
formData.value = {}
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '添加实例失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.msg || '添加实例失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实例表格数据
|
||||||
|
const insTableData = ref<any[]>([])
|
||||||
|
|
||||||
|
// 查询实例列表数据
|
||||||
|
const queryInsListData = async () => {
|
||||||
|
if (!props.templateData?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get('/templates/ins/get', {
|
||||||
|
params: { id: props.templateData.id }
|
||||||
|
})
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
const insList = response.data.data.ins_list || []
|
||||||
|
insTableData.value = insList
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取实例列表失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除实例
|
||||||
|
const handleDeleteIns = async (insId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await request.post('/sendtasks/ins/delete', { id: insId })
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
toast.success(response.data.msg)
|
||||||
|
await queryInsListData()
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.msg || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换实例启用状态
|
||||||
|
const handleToggleEnable = async (insId: string, currentStatus: number | string) => {
|
||||||
|
const isEnabled = Number(currentStatus) === 1
|
||||||
|
const newStatus = isEnabled ? 0 : 1
|
||||||
|
|
||||||
|
// 立即更新本地状态,提供即时反馈
|
||||||
|
const insIndex = insTableData.value.findIndex(ins => ins.id === insId)
|
||||||
|
if (insIndex !== -1) {
|
||||||
|
insTableData.value[insIndex].enable = newStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.post('/sendtasks/ins/update_enable', {
|
||||||
|
ins_id: insId,
|
||||||
|
status: newStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
toast.success(response.data.msg)
|
||||||
|
// 重新加载确保数据同步
|
||||||
|
await queryInsListData()
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '更新失败')
|
||||||
|
// 失败时恢复原状态
|
||||||
|
if (insIndex !== -1) {
|
||||||
|
insTableData.value[insIndex].enable = currentStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('状态切换失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '更新失败')
|
||||||
|
// 失败时恢复原状态
|
||||||
|
if (insIndex !== -1) {
|
||||||
|
insTableData.value[insIndex].enable = currentStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖定时器
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
|
// 搜索渠道(带防抖)
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
displayOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置防抖延迟(500ms)
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
isSearching.value = true
|
||||||
|
try {
|
||||||
|
const response = await request.get('/sendways/list', {
|
||||||
|
params: { name: query }
|
||||||
|
})
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
displayOptions.value = response.data.data.lists.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
type: item.type
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索渠道失败', error)
|
||||||
|
displayOptions.value = []
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听对话框打开状态,打开时加载实例列表
|
||||||
|
watch(() => props.open, (newVal) => {
|
||||||
|
if (newVal && props.templateData?.id) {
|
||||||
|
queryInsListData()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="(value) => $emit('update:open', value)">
|
||||||
|
<DialogContent class="w-[500px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader class="flex-shrink-0">
|
||||||
|
<DialogTitle>配置发送实例</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="px-4 pb-4 flex-1 overflow-y-auto">
|
||||||
|
<div class="space-y-4">
|
||||||
|
|
||||||
|
<!-- 模板信息 -->
|
||||||
|
<div class="mb-6 p-4 bg-muted rounded-lg">
|
||||||
|
<div class="text-sm text-muted-foreground">模板名称</div>
|
||||||
|
<div class="text-lg font-medium">{{ templateData?.name }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">ID: {{ templateData?.id }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加实例表单 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Label class="text-sm font-medium">选择发送渠道</Label>
|
||||||
|
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
|
||||||
|
<ComboboxAnchor class="w-full">
|
||||||
|
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
|
||||||
|
class="flex h-10 w-full" placeholder="搜索或选择渠道类型进行实例的添加..." />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
|
||||||
|
<ComboboxViewport>
|
||||||
|
<ComboboxItem v-for="option in displayOptions" :key="option.id" :value="option.name">
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<span>{{ option.name }}</span>
|
||||||
|
<CheckIcon v-if="channelName === option.name" class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</ComboboxItem>
|
||||||
|
<div v-if="isSearching" class="p-2 text-sm text-muted-foreground">搜索中...</div>
|
||||||
|
<div v-if="!isSearching && displayOptions.length === 0 && searchQuery" class="p-2 text-sm text-muted-foreground">
|
||||||
|
未找到匹配的渠道
|
||||||
|
</div>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxList>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 渠道配置表单 -->
|
||||||
|
<div v-if="currentChannelConfig" class="mt-4">
|
||||||
|
<!-- 实例配置输入字段 -->
|
||||||
|
<div v-if="currentChannelConfig.taskInsInputs && currentChannelConfig.taskInsInputs.length > 0" class="mb-2">
|
||||||
|
<Label class="text-sm font-medium mb-1">实例配置</Label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div v-for="input in currentChannelConfig.taskInsInputs" :key="input.col" class="space-y-2">
|
||||||
|
<label class="text-xs font-medium text-muted-foreground">{{ input.label || input.desc }}</label>
|
||||||
|
<Input v-model="formData[input.col]" :placeholder="input.desc || `请输入${input.label}`"
|
||||||
|
:type="input.type || 'text'" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 单选框 -->
|
||||||
|
<div v-if="currentChannelConfig.taskInsRadios && currentChannelConfig.taskInsRadios.length > 0" class="mt-4">
|
||||||
|
<Label class="text-sm font-medium mb-2">消息格式</Label>
|
||||||
|
<RadioGroup v-model="formData.templ_type" class="flex gap-4">
|
||||||
|
<div v-for="radio in currentChannelConfig.taskInsRadios" :key="radio.subLabel" class="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem :value="radio.subLabel" :id="radio.subLabel" />
|
||||||
|
<Label :for="radio.subLabel" class="text-sm cursor-pointer">{{ radio.subLabel }}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 border-b pb-4 mt-2">
|
||||||
|
<Button @click="handleAddSubmit">添加实例</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关联的实例表 -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3">已经关联的实例</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>渠道名称</TableHead>
|
||||||
|
<TableHead>内容类型</TableHead>
|
||||||
|
<TableHead class="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="ins in insTableData" :key="ins.id">
|
||||||
|
<TableCell>
|
||||||
|
<div class="font-medium">{{ ins.way_name || '未命名' }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ ins.way_type }}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{{ ins.content_type }}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<Switch
|
||||||
|
:model-value="ins.enable === 1"
|
||||||
|
@update:model-value="() => handleToggleEnable(ins.id, ins.enable)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="text-red-500 border-red-300 hover:bg-red-50 hover:border-red-400 hover:text-red-600 hover:shadow-md transition-all duration-200"
|
||||||
|
@click="handleDeleteIns(ins.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-if="!insTableData || insTableData.length === 0">
|
||||||
|
<TableCell :colspan="3" class="h-24">
|
||||||
|
<EmptyTableState title="暂无实例" description="还没有配置任何实例,请先添加" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -19,7 +19,8 @@ import { getPageSize } from '@/util/pageUtils';
|
|||||||
interface LogItem {
|
interface LogItem {
|
||||||
id: number
|
id: number
|
||||||
task_id: number
|
task_id: number
|
||||||
task_name: string
|
type: string // 类型:task 或 template
|
||||||
|
name: string // 任务或模板名称
|
||||||
log: string
|
log: string
|
||||||
created_on: string
|
created_on: string
|
||||||
caller_ip?: string
|
caller_ip?: string
|
||||||
@@ -34,7 +35,7 @@ let state = reactive({
|
|||||||
currPage: 1,
|
currPage: 1,
|
||||||
pageSize: getPageSize(),
|
pageSize: getPageSize(),
|
||||||
search: '',
|
search: '',
|
||||||
optionValue: '',
|
optionValue: '', // 保存 taskid,用于过滤
|
||||||
})
|
})
|
||||||
|
|
||||||
// 状态过滤
|
// 状态过滤
|
||||||
@@ -50,10 +51,25 @@ const getStatusText = (status: number) => {
|
|||||||
return status === 1 ? '成功' : '失败'
|
return status === 1 ? '成功' : '失败'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取类型文本
|
||||||
|
const getTypeText = (type: string) => {
|
||||||
|
return type === 'template' ? '模板' : '任务'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取类型徽章样式
|
||||||
|
const getTypeBadgeVariant = (type: string) => {
|
||||||
|
return type === 'template' ? 'secondary' : 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取显示名称
|
||||||
|
const getDisplayName = (task: LogItem) => {
|
||||||
|
return task.name || '-'
|
||||||
|
}
|
||||||
|
|
||||||
// 打开日志详情Sheet
|
// 打开日志详情Sheet
|
||||||
const openLogSheet = (task: LogItem) => {
|
const openLogSheet = (task: LogItem) => {
|
||||||
selectedLog.value = formatLogDisplayHtml(task);
|
selectedLog.value = formatLogDisplayHtml(task);
|
||||||
selectedTaskName.value = task.task_name
|
selectedTaskName.value = getDisplayName(task)
|
||||||
isSheetOpen.value = true
|
isSheetOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +136,8 @@ const queryListData = async (page: number, size: number, name = '', taskid = '',
|
|||||||
// 解析URL参数并更新筛选状态
|
// 解析URL参数并更新筛选状态
|
||||||
const parseUrlParams = async () => {
|
const parseUrlParams = async () => {
|
||||||
state.search = router.query.name?.toString() || '';
|
state.search = router.query.name?.toString() || '';
|
||||||
|
// 保存 taskid 到 state,用于后续过滤
|
||||||
|
state.optionValue = router.query.taskid?.toString() || '';
|
||||||
|
|
||||||
// 解析URL中的query参数,设置状态筛选
|
// 解析URL中的query参数,设置状态筛选
|
||||||
const queryParam = router.query.query?.toString() || '';
|
const queryParam = router.query.query?.toString() || '';
|
||||||
@@ -140,8 +158,8 @@ const parseUrlParams = async () => {
|
|||||||
await queryListData(
|
await queryListData(
|
||||||
1,
|
1,
|
||||||
state.pageSize,
|
state.pageSize,
|
||||||
router.query.name?.toString() || '',
|
state.search,
|
||||||
router.query.taskid?.toString() || '',
|
state.optionValue,
|
||||||
queryParam
|
queryParam
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -185,7 +203,8 @@ onMounted(async () => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-20">ID</TableHead>
|
<TableHead class="w-20">ID</TableHead>
|
||||||
<TableHead>任务名称</TableHead>
|
<TableHead class="w-24">类型</TableHead>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
<TableHead>发信日志</TableHead>
|
<TableHead>发信日志</TableHead>
|
||||||
<TableHead class="whitespace-nowrap w-[160px]">发送时间</TableHead>
|
<TableHead class="whitespace-nowrap w-[160px]">发送时间</TableHead>
|
||||||
<TableHead class="text-center">详情/状态</TableHead>
|
<TableHead class="text-center">详情/状态</TableHead>
|
||||||
@@ -195,7 +214,7 @@ onMounted(async () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<!-- 空数据展示 -->
|
<!-- 空数据展示 -->
|
||||||
<TableRow v-if="state.tableData.length === 0">
|
<TableRow v-if="state.tableData.length === 0">
|
||||||
<TableCell colspan="5" class="text-center py-12">
|
<TableCell colspan="6" class="text-center py-12">
|
||||||
<EmptyTableState
|
<EmptyTableState
|
||||||
title="暂无发信日志"
|
title="暂无发信日志"
|
||||||
description="还没有任何发信日志记录"
|
description="还没有任何发信日志记录"
|
||||||
@@ -213,7 +232,12 @@ onMounted(async () => {
|
|||||||
<TableRow v-for="task in state.tableData" :key="task.id">
|
<TableRow v-for="task in state.tableData" :key="task.id">
|
||||||
<TableCell>{{ task.id }}</TableCell>
|
<TableCell>{{ task.id }}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ClickableTruncate :text="task.task_name" wrapper-class="max-w-[220px] sm:max-w-[360px]" preview-title="任务名称" />
|
<Badge :variant="getTypeBadgeVariant(task.type || 'task')">
|
||||||
|
{{ getTypeText(task.type || 'task') }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClickableTruncate :text="getDisplayName(task)" wrapper-class="max-w-[220px] sm:max-w-[360px]" preview-title="名称" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ClickableTruncate :text="task.log" wrapper-class="max-w-[320px] sm:max-w-[480px]" preview-title="发信日志" />
|
<ClickableTruncate :text="task.log" wrapper-class="max-w-[320px] sm:max-w-[480px]" preview-title="发信日志" />
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ export default defineComponent({
|
|||||||
<Badge variant="secondary" class="text-xs">新</Badge>
|
<Badge variant="secondary" class="text-xs">新</Badge>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">💡 提示:@功能仅钉钉和企业微信支持</p>
|
<div class="space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p>💡 提示:@功能仅钉钉和企业微信支持</p>
|
||||||
|
<p>📋 发送顺序:实例配置的内容类型优先,若为空则按 <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">HTML → Markdown → Text</code> 顺序回退</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 代码示例 -->
|
<!-- 代码示例 -->
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ const CONSTANT = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'QyWeiXin',
|
type: 'QyWeiXin',
|
||||||
label: '企业微信',
|
label: '企业微信机器人',
|
||||||
inputs: [
|
inputs: [
|
||||||
{ subLabel: 'token', value: '', col: 'access_token', desc: "企业微信webhook中的token" },
|
{ subLabel: 'token', value: '', col: 'access_token', desc: "企业微信webhook中的token" },
|
||||||
{ subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
|
{ subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ const router = createRouter({
|
|||||||
path: 'cronmessages',
|
path: 'cronmessages',
|
||||||
name: 'cronmessages',
|
name: 'cronmessages',
|
||||||
component: () => import('../components/pages/cronMessages/CronMessages.vue')
|
component: () => import('../components/pages/cronMessages/CronMessages.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'templates',
|
||||||
|
name: 'templates',
|
||||||
|
component: () => import('../components/pages/messageTemplate/MessageTemplate.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+151
-86
@@ -1,11 +1,10 @@
|
|||||||
|
|
||||||
|
|
||||||
const gethttpOrigin = () => {
|
const gethttpOrigin = () => {
|
||||||
return window.location.origin
|
return window.location.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiStrGenerate {
|
// ==================== 公共加密工具 ====================
|
||||||
|
|
||||||
|
class TokenEncryption {
|
||||||
// 根据字符串内容生成确定性 salt(范围 0~255)
|
// 根据字符串内容生成确定性 salt(范围 0~255)
|
||||||
static getDeterministicSalt(text) {
|
static getDeterministicSalt(text) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
@@ -17,7 +16,7 @@ class ApiStrGenerate {
|
|||||||
|
|
||||||
// 加密:首字节为salt,后续为按位异或后的数据
|
// 加密:首字节为salt,后续为按位异或后的数据
|
||||||
static encryptHex(text, key) {
|
static encryptHex(text, key) {
|
||||||
const salt = ApiStrGenerate.getDeterministicSalt(text);
|
const salt = TokenEncryption.getDeterministicSalt(text);
|
||||||
let result = salt.toString(16).padStart(2, '0');
|
let result = salt.toString(16).padStart(2, '0');
|
||||||
for (let i = 0; i < text.length; i++) {
|
for (let i = 0; i < text.length; i++) {
|
||||||
const code = text.charCodeAt(i) ^ (key & 0xFF) ^ ((salt + i) & 0xFF);
|
const code = text.charCodeAt(i) ^ (key & 0xFF) ^ ((salt + i) & 0xFF);
|
||||||
@@ -25,46 +24,19 @@ class ApiStrGenerate {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static getDataString(task_id, options) {
|
// ==================== 公共代码模板生成器 ====================
|
||||||
// 新版仅展示 token;兼容旧版 task_id(后端依然支持)
|
|
||||||
let data = { token: ApiStrGenerate.encryptHex(task_id, 71) };
|
class CodeTemplates {
|
||||||
data.title = 'message title';
|
static getCurl(url, dataStr) {
|
||||||
data.text = 'Hello World!';
|
return `curl -X POST --location '${url}' \\
|
||||||
if (options.html) {
|
--header 'Content-Type: application/json' \\
|
||||||
data.html = '<h1> Hello World! </h1>';
|
--data '${dataStr}'`;
|
||||||
}
|
|
||||||
if (options.markdown) {
|
|
||||||
data.markdown = '**Hello World!**';
|
|
||||||
}
|
|
||||||
if (options.url) {
|
|
||||||
data.url = 'https://github.com';
|
|
||||||
}
|
|
||||||
// @提及功能参数(可选)
|
|
||||||
if (options.at_mobiles) {
|
|
||||||
data.at_mobiles = ['13800138000', '13900139000'];
|
|
||||||
}
|
|
||||||
if (options.at_user_ids) {
|
|
||||||
data.at_user_ids = ['zhangsan', 'lisi'];
|
|
||||||
}
|
|
||||||
if (options.at_all) {
|
|
||||||
data.at_all = true;
|
|
||||||
}
|
|
||||||
let dataStr = JSON.stringify(data, null, 4);
|
|
||||||
return dataStr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCurlString(task_id, options) {
|
static getGolang(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `package main
|
||||||
let example = `curl -X POST --location '${gethttpOrigin()}/api/v1/message/send' \\
|
|
||||||
--header 'Content-Type: application/json' \\
|
|
||||||
--data '${dataStr}'`;
|
|
||||||
return example;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getGolangString(task_id, options) {
|
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
|
||||||
let example = `package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -77,7 +49,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
var data = strings.NewReader(\`${dataStr}\`)
|
var data = strings.NewReader(\`${dataStr}\`)
|
||||||
req, err := http.NewRequest("POST", "${gethttpOrigin()}/api/v1/message/send", data)
|
req, err := http.NewRequest("POST", "${url}", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -92,29 +64,23 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("%s\\n", bodyText)
|
fmt.Printf("%s\\n", bodyText)
|
||||||
}
|
}`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPythonString(task_id, options) {
|
static getPython(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `import requests
|
||||||
let example = `import requests
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
json_data = ${dataStr}
|
json_data = ${dataStr}
|
||||||
response = requests.post('${gethttpOrigin()}/api/v1/message/send', headers=headers, json=json_data)
|
response = requests.post('${url}', headers=headers, json=json_data)
|
||||||
|
|
||||||
print("response:", response.json())
|
print("response:", response.json())`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getJaveString(task_id, options) {
|
static getJava(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `import java.io.IOException;
|
||||||
let example = `import java.io.IOException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
@@ -126,19 +92,16 @@ HttpClient client = HttpClient.newBuilder()
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create("${gethttpOrigin()}/api/v1/message/send"))
|
.uri(URI.create("${url}"))
|
||||||
.POST(BodyPublishers.ofString(${JSON.stringify(dataStr).trim('\"')}))
|
.POST(BodyPublishers.ofString(${JSON.stringify(dataStr).trim('"')}))
|
||||||
.setHeader("Content-Type", "application/json")
|
.setHeader("Content-Type", "application/json")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRustString(task_id, options) {
|
static getRust(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `extern crate reqwest;
|
||||||
let example = `extern crate reqwest;
|
|
||||||
use reqwest::header;
|
use reqwest::header;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -146,7 +109,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
headers.insert("Content-Type", "application/json".parse().unwrap());
|
headers.insert("Content-Type", "application/json".parse().unwrap());
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let res = client.post("${gethttpOrigin()}/api/v1/message/send")
|
let res = client.post("${url}")
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.body(r#"
|
.body(r#"
|
||||||
${dataStr}
|
${dataStr}
|
||||||
@@ -157,43 +120,37 @@ ${dataStr}
|
|||||||
println!("{}", res);
|
println!("{}", res);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPHPString(task_id, options) {
|
static getPHP(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `<?php
|
||||||
let example = `<?php
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, '${gethttpOrigin()}/api/v1/message/send');
|
curl_setopt($ch, CURLOPT_URL, '${url}');
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
]);
|
]);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ${JSON.stringify(dataStr).trim('\"')});
|
curl_setopt($ch, CURLOPT_POSTFIELDS, ${JSON.stringify(dataStr).trim('"')});
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
curl_close($ch);
|
curl_close($ch);`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getNodeString(task_id, options) {
|
static getNode(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `var request = require('request');
|
||||||
let example = `var request = require('request');
|
|
||||||
|
|
||||||
var headers = {
|
var headers = {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
};
|
};
|
||||||
|
|
||||||
var dataString = ${JSON.stringify(dataStr).trim('\"')};
|
var dataString = ${JSON.stringify(dataStr).trim('"')};
|
||||||
|
|
||||||
var options = {
|
var options = {
|
||||||
url: '${gethttpOrigin()}/api/v1/message/send',
|
url: '${url}',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: dataString
|
body: dataString
|
||||||
@@ -201,15 +158,123 @@ var options = {
|
|||||||
|
|
||||||
function callback(error, response, body) {
|
function callback(error, response, body) {
|
||||||
if (!error && response.statusCode == 200) {
|
if (!error && response.statusCode == 200) {
|
||||||
|
console.log(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request(options, callback);
|
request(options, callback);`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ApiStrGenerate };
|
// ==================== 发信任务 API (V1) ====================
|
||||||
|
|
||||||
|
class ApiStrGenerate {
|
||||||
|
static getDataString(task_id, options) {
|
||||||
|
let data = { token: TokenEncryption.encryptHex(task_id, 71) };
|
||||||
|
data.title = 'message title';
|
||||||
|
data.text = 'Hello World!';
|
||||||
|
if (options.html) data.html = '<h1> Hello World! </h1>';
|
||||||
|
if (options.markdown) data.markdown = '**Hello World!**';
|
||||||
|
if (options.url) data.url = 'https://github.com';
|
||||||
|
if (options.at_mobiles) data.at_mobiles = ['13800138000', '13900139000'];
|
||||||
|
if (options.at_user_ids) data.at_user_ids = ['zhangsan', 'lisi'];
|
||||||
|
if (options.at_all) data.at_all = true;
|
||||||
|
return JSON.stringify(data, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getApiUrl() {
|
||||||
|
return `${gethttpOrigin()}/api/v1/message/send`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCurlString(task_id, options) {
|
||||||
|
return CodeTemplates.getCurl(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getGolangString(task_id, options) {
|
||||||
|
return CodeTemplates.getGolang(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPythonString(task_id, options) {
|
||||||
|
return CodeTemplates.getPython(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getJaveString(task_id, options) {
|
||||||
|
return CodeTemplates.getJava(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRustString(task_id, options) {
|
||||||
|
return CodeTemplates.getRust(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPHPString(task_id, options) {
|
||||||
|
return CodeTemplates.getPHP(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getNodeString(task_id, options) {
|
||||||
|
return CodeTemplates.getNode(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 模板 API (V2) ====================
|
||||||
|
|
||||||
|
class TemplateApiStrGenerate {
|
||||||
|
static getTemplateDataString(template_id, placeholders_json) {
|
||||||
|
// 解析占位符配置
|
||||||
|
let placeholders = {};
|
||||||
|
try {
|
||||||
|
const placeholdersList = JSON.parse(placeholders_json || '[]');
|
||||||
|
// 根据占位符配置生成示例值
|
||||||
|
placeholdersList.forEach(p => {
|
||||||
|
placeholders[p.key] = p.default || `mock_${p.key}`;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 如果解析失败,使用默认示例
|
||||||
|
placeholders = {
|
||||||
|
'username': 'John Doe',
|
||||||
|
'email': 'john@example.com',
|
||||||
|
'phone': '13800138000'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
token: TokenEncryption.encryptHex(template_id, 71),
|
||||||
|
title: 'message title',
|
||||||
|
placeholders: placeholders
|
||||||
|
};
|
||||||
|
return JSON.stringify(data, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getApiUrl() {
|
||||||
|
return `${gethttpOrigin()}/api/v2/message/send`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCurlString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getCurl(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getGolangString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getGolang(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPythonString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getPython(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getJavaString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getJava(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRustString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getRust(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPHPString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getPHP(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getNodeString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getNode(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiStrGenerate, TemplateApiStrGenerate };
|
||||||
|
|||||||
Reference in New Issue
Block a user