feat: add message template
This commit is contained in:
+3
-1
@@ -2,9 +2,10 @@ package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"message-nest/pkg/e"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
var ExcludedRoutes = []string{
|
||||
"/api/v1/message/send",
|
||||
"/api/v2/message/send",
|
||||
"/api/v1/settings/getsetting",
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -3,10 +3,11 @@ package migrate
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"message-nest/models"
|
||||
"message-nest/service/settings_service"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 初始化admin账户
|
||||
@@ -67,6 +68,7 @@ func Setup() {
|
||||
&models.CronMessages{},
|
||||
&models.HostedMessage{},
|
||||
&models.LoginLog{},
|
||||
&models.MessageTemplate{},
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import "fmt"
|
||||
|
||||
type SendTasksIns struct {
|
||||
UUIDModel
|
||||
|
||||
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"`
|
||||
WayType string `json:"way_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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ID int `gorm:"primaryKey" json:"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 ;"`
|
||||
Status *int `json:"status" gorm:"type:int ;default:0;"`
|
||||
CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"`
|
||||
@@ -29,10 +31,11 @@ func (log *SendTasksLogs) Add() error {
|
||||
type LogsResult struct {
|
||||
ID int `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Log string `json:"log"`
|
||||
CreatedOn util.Time `json:"created_on"`
|
||||
ModifiedOn util.Time `json:"modified_on"`
|
||||
TaskName string `json:"task_name"`
|
||||
Status int `json:"status"`
|
||||
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) {
|
||||
var logs []LogsResult
|
||||
logt := GetSchema(SendTasksLogs{})
|
||||
taskt := GetSchema(SendTasks{})
|
||||
|
||||
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))
|
||||
// 简化查询,只查询日志表
|
||||
query := db.Table(logt)
|
||||
|
||||
dayVal, ok := maps["day_created_on"]
|
||||
if ok {
|
||||
@@ -55,8 +55,10 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
|
||||
}
|
||||
|
||||
query = query.Where(maps)
|
||||
|
||||
// 按名称搜索(搜索日志表的 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 != "" {
|
||||
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
||||
@@ -67,17 +69,70 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
|
||||
}
|
||||
query.Scan(&logs)
|
||||
|
||||
//v1 接口的历史日志数据兼容处理
|
||||
// 应用层处理:为历史数据(type=task 且 name 为空)补充任务名称
|
||||
fillTaskNamesForLogs(&logs)
|
||||
|
||||
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 获取所有日志总数
|
||||
func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) {
|
||||
var total int64
|
||||
logt := GetSchema(SendTasksLogs{})
|
||||
taskt := GetSchema(SendTasks{})
|
||||
query := db.
|
||||
Table(logt).
|
||||
Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt))
|
||||
|
||||
// 简化查询,只查询日志表
|
||||
query := db.Table(logt)
|
||||
|
||||
dayVal, ok := maps["day_created_on"]
|
||||
if ok {
|
||||
@@ -86,8 +141,10 @@ func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (
|
||||
}
|
||||
|
||||
query = query.Where(maps)
|
||||
|
||||
// 按名称搜索(搜索日志表的 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 != "" {
|
||||
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{
|
||||
SendMode: send_message_service.SendModeTask, // 明确标记为任务模式
|
||||
TaskID: taskID,
|
||||
Title: req.Title,
|
||||
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/routers/api"
|
||||
"message-nest/routers/api/v1"
|
||||
"message-nest/routers/api/v2"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -98,6 +99,26 @@ func InitRouter(f embed.FS) *gin.Engine {
|
||||
// hostedMessage
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
// 发送模式类型
|
||||
const (
|
||||
SendModeTask = "task" // 传统任务模式
|
||||
SendModeTemplate = "template" // 模板模式
|
||||
)
|
||||
|
||||
func errStrIsSuccess(errStr string) int {
|
||||
if errStr == "" {
|
||||
return SendSuccess
|
||||
@@ -26,7 +32,10 @@ func errStrIsSuccess(errStr string) int {
|
||||
}
|
||||
|
||||
type SendMessageService struct {
|
||||
TaskID string
|
||||
SendMode string // 发送模式:task(任务模式) 或 template(模板模式)
|
||||
TaskID string // 任务ID(任务模式)或模板ID(模板模式,用于日志记录)
|
||||
TemplateID string // 模板ID(仅模板模式使用)
|
||||
Name string // 任务或模板名称(用于日志记录)
|
||||
Title string
|
||||
Text string
|
||||
HTML string
|
||||
@@ -82,11 +91,57 @@ func (sm *SendMessageService) AsyncSend(task models.TaskIns) {
|
||||
}
|
||||
|
||||
// SendPreCheck 发送前数据准备和预检查
|
||||
// 支持两种模式:
|
||||
// 1. SendModeTask:传统任务模式,使用 TaskID 查询任务和实例
|
||||
// 2. SendModeTemplate:模板模式,使用 TemplateID 查询模板关联的实例
|
||||
func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) {
|
||||
errStr := ""
|
||||
entry := logrus.WithFields(logrus.Fields{
|
||||
"prefix": "[Message PreChecK]",
|
||||
})
|
||||
|
||||
var task models.TaskIns
|
||||
|
||||
switch sm.SendMode {
|
||||
case SendModeTemplate:
|
||||
// 模板模式:使用模板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,
|
||||
}
|
||||
@@ -106,7 +161,18 @@ func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) {
|
||||
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)
|
||||
return task, errors.New(errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// 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", 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()
|
||||
if errStr != "" {
|
||||
@@ -165,8 +224,24 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据发送模式构建消息内容
|
||||
var unifiedContent *unified.UnifiedMessageContent
|
||||
if sm.SendMode == SendModeTemplate {
|
||||
// 模板模式:根据实例的 ContentType 精确发送对应类型的内容
|
||||
unifiedContent = sm.BuildTemplateContent(ins.SendTasksIns)
|
||||
if unifiedContent == nil {
|
||||
sm.LogsAndStatusMark(fmt.Sprintf("模板内容为空,实例类型: %s", ins.ContentType), SendFail)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 任务模式:使用现有逻辑(支持内容类型回退)
|
||||
typeC, content := sm.GetSendMsg(ins.SendTasksIns)
|
||||
if content == "" {
|
||||
sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail)
|
||||
continue
|
||||
}
|
||||
// 构建统一消息内容(支持@功能)
|
||||
unifiedContent := &unified.UnifiedMessageContent{
|
||||
unifiedContent = &unified.UnifiedMessageContent{
|
||||
Title: sm.Title,
|
||||
Text: sm.Text,
|
||||
HTML: sm.HTML,
|
||||
@@ -176,6 +251,7 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
|
||||
AtUserIds: sm.AtUserIds,
|
||||
AtAll: sm.AtAll,
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 SendUnified 方法(自动格式转换和@功能支持)
|
||||
res, errMsg := channel.SendUnified(msgObj, ins.SendTasksIns, unifiedContent)
|
||||
@@ -216,9 +292,17 @@ func (sm *SendMessageService) AppendSendContent() {
|
||||
|
||||
// RecordSendLog 记录发送日志
|
||||
func (sm *SendMessageService) RecordSendLog() {
|
||||
// 确定日志类型
|
||||
logType := "task"
|
||||
if sm.SendMode == SendModeTemplate {
|
||||
logType = "template"
|
||||
}
|
||||
|
||||
log := models.SendTasksLogs{
|
||||
Log: strings.Join(sm.LogOutput, "\n"),
|
||||
TaskID: sm.TaskID,
|
||||
Type: logType,
|
||||
Name: sm.Name,
|
||||
Status: &sm.Status,
|
||||
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发送
|
||||
func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) {
|
||||
data := map[string]string{}
|
||||
|
||||
@@ -252,6 +252,7 @@ const tabRoutes: TabRoute[] = [
|
||||
{ name: '发信日志', path: '/sendlogs' },
|
||||
{ name: '托管消息', path: '/hostedmessage' },
|
||||
{ name: '定时消息', path: '/cronmessages' },
|
||||
{ name: '模板任务', path: '/templates' },
|
||||
{ name: '发信任务', path: '/sendtasks' },
|
||||
{ name: '发信渠道', path: '/sendways' },
|
||||
{ name: '设置偏好', path: '/settings' }
|
||||
@@ -326,9 +327,9 @@ const siteTitle = computed(() => {
|
||||
</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="[
|
||||
'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
|
||||
? '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'
|
||||
|
||||
@@ -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 {
|
||||
id: number
|
||||
task_id: number
|
||||
task_name: string
|
||||
type: string // 类型:task 或 template
|
||||
name: string // 任务或模板名称
|
||||
log: string
|
||||
created_on: string
|
||||
caller_ip?: string
|
||||
@@ -34,7 +35,7 @@ let state = reactive({
|
||||
currPage: 1,
|
||||
pageSize: getPageSize(),
|
||||
search: '',
|
||||
optionValue: '',
|
||||
optionValue: '', // 保存 taskid,用于过滤
|
||||
})
|
||||
|
||||
// 状态过滤
|
||||
@@ -50,10 +51,25 @@ const getStatusText = (status: number) => {
|
||||
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
|
||||
const openLogSheet = (task: LogItem) => {
|
||||
selectedLog.value = formatLogDisplayHtml(task);
|
||||
selectedTaskName.value = task.task_name
|
||||
selectedTaskName.value = getDisplayName(task)
|
||||
isSheetOpen.value = true
|
||||
}
|
||||
|
||||
@@ -120,6 +136,8 @@ const queryListData = async (page: number, size: number, name = '', taskid = '',
|
||||
// 解析URL参数并更新筛选状态
|
||||
const parseUrlParams = async () => {
|
||||
state.search = router.query.name?.toString() || '';
|
||||
// 保存 taskid 到 state,用于后续过滤
|
||||
state.optionValue = router.query.taskid?.toString() || '';
|
||||
|
||||
// 解析URL中的query参数,设置状态筛选
|
||||
const queryParam = router.query.query?.toString() || '';
|
||||
@@ -140,8 +158,8 @@ const parseUrlParams = async () => {
|
||||
await queryListData(
|
||||
1,
|
||||
state.pageSize,
|
||||
router.query.name?.toString() || '',
|
||||
router.query.taskid?.toString() || '',
|
||||
state.search,
|
||||
state.optionValue,
|
||||
queryParam
|
||||
);
|
||||
};
|
||||
@@ -185,7 +203,8 @@ onMounted(async () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">ID</TableHead>
|
||||
<TableHead>任务名称</TableHead>
|
||||
<TableHead class="w-24">类型</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>发信日志</TableHead>
|
||||
<TableHead class="whitespace-nowrap w-[160px]">发送时间</TableHead>
|
||||
<TableHead class="text-center">详情/状态</TableHead>
|
||||
@@ -195,7 +214,7 @@ onMounted(async () => {
|
||||
<TableBody>
|
||||
<!-- 空数据展示 -->
|
||||
<TableRow v-if="state.tableData.length === 0">
|
||||
<TableCell colspan="5" class="text-center py-12">
|
||||
<TableCell colspan="6" class="text-center py-12">
|
||||
<EmptyTableState
|
||||
title="暂无发信日志"
|
||||
description="还没有任何发信日志记录"
|
||||
@@ -213,7 +232,12 @@ onMounted(async () => {
|
||||
<TableRow v-for="task in state.tableData" :key="task.id">
|
||||
<TableCell>{{ task.id }}</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>
|
||||
<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>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
<!-- 代码示例 -->
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ const CONSTANT = {
|
||||
},
|
||||
{
|
||||
type: 'QyWeiXin',
|
||||
label: '企业微信',
|
||||
label: '企业微信机器人',
|
||||
inputs: [
|
||||
{ subLabel: 'token', value: '', col: 'access_token', desc: "企业微信webhook中的token" },
|
||||
{ subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
|
||||
|
||||
@@ -51,6 +51,11 @@ const router = createRouter({
|
||||
path: 'cronmessages',
|
||||
name: 'cronmessages',
|
||||
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 = () => {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
class ApiStrGenerate {
|
||||
// ==================== 公共加密工具 ====================
|
||||
|
||||
class TokenEncryption {
|
||||
// 根据字符串内容生成确定性 salt(范围 0~255)
|
||||
static getDeterministicSalt(text) {
|
||||
let sum = 0;
|
||||
@@ -17,7 +16,7 @@ class ApiStrGenerate {
|
||||
|
||||
// 加密:首字节为salt,后续为按位异或后的数据
|
||||
static encryptHex(text, key) {
|
||||
const salt = ApiStrGenerate.getDeterministicSalt(text);
|
||||
const salt = TokenEncryption.getDeterministicSalt(text);
|
||||
let result = salt.toString(16).padStart(2, '0');
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i) ^ (key & 0xFF) ^ ((salt + i) & 0xFF);
|
||||
@@ -25,46 +24,19 @@ class ApiStrGenerate {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
static getDataString(task_id, options) {
|
||||
// 新版仅展示 token;兼容旧版 task_id(后端依然支持)
|
||||
let data = { token: ApiStrGenerate.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;
|
||||
}
|
||||
let dataStr = JSON.stringify(data, null, 4);
|
||||
return dataStr
|
||||
// ==================== 公共代码模板生成器 ====================
|
||||
|
||||
class CodeTemplates {
|
||||
static getCurl(url, dataStr) {
|
||||
return `curl -X POST --location '${url}' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '${dataStr}'`;
|
||||
}
|
||||
|
||||
static getCurlString(task_id, options) {
|
||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
||||
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
|
||||
static getGolang(url, dataStr) {
|
||||
return `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -77,7 +49,7 @@ import (
|
||||
func main() {
|
||||
client := &http.Client{}
|
||||
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 {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -92,29 +64,23 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("%s\\n", bodyText)
|
||||
}
|
||||
`;
|
||||
return example;
|
||||
}`;
|
||||
}
|
||||
|
||||
static getPythonString(task_id, options) {
|
||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
||||
let example = `import requests
|
||||
static getPython(url, dataStr) {
|
||||
return `import requests
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
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())
|
||||
`;
|
||||
return example;
|
||||
print("response:", response.json())`;
|
||||
}
|
||||
|
||||
static getJaveString(task_id, options) {
|
||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
||||
let example = `import java.io.IOException;
|
||||
static getJava(url, dataStr) {
|
||||
return `import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -126,19 +92,16 @@ HttpClient client = HttpClient.newBuilder()
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${gethttpOrigin()}/api/v1/message/send"))
|
||||
.POST(BodyPublishers.ofString(${JSON.stringify(dataStr).trim('\"')}))
|
||||
.uri(URI.create("${url}"))
|
||||
.POST(BodyPublishers.ofString(${JSON.stringify(dataStr).trim('"')}))
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
`;
|
||||
return example;
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`;
|
||||
}
|
||||
|
||||
static getRustString(task_id, options) {
|
||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
||||
let example = `extern crate reqwest;
|
||||
static getRust(url, dataStr) {
|
||||
return `extern crate reqwest;
|
||||
use reqwest::header;
|
||||
|
||||
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());
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let res = client.post("${gethttpOrigin()}/api/v1/message/send")
|
||||
let res = client.post("${url}")
|
||||
.headers(headers)
|
||||
.body(r#"
|
||||
${dataStr}
|
||||
@@ -157,43 +120,37 @@ ${dataStr}
|
||||
println!("{}", res);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
`;
|
||||
return example;
|
||||
}`;
|
||||
}
|
||||
|
||||
static getPHPString(task_id, options) {
|
||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
||||
let example = `<?php
|
||||
static getPHP(url, dataStr) {
|
||||
return `<?php
|
||||
$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_CUSTOMREQUEST, 'POST');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'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);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
curl_close($ch);
|
||||
`;
|
||||
return example;
|
||||
curl_close($ch);`;
|
||||
}
|
||||
|
||||
static getNodeString(task_id, options) {
|
||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
||||
let example = `var request = require('request');
|
||||
static getNode(url, dataStr) {
|
||||
return `var request = require('request');
|
||||
|
||||
var headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
var dataString = ${JSON.stringify(dataStr).trim('\"')};
|
||||
var dataString = ${JSON.stringify(dataStr).trim('"')};
|
||||
|
||||
var options = {
|
||||
url: '${gethttpOrigin()}/api/v1/message/send',
|
||||
url: '${url}',
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: dataString
|
||||
@@ -201,15 +158,123 @@ var options = {
|
||||
|
||||
function callback(error, response, body) {
|
||||
if (!error && response.statusCode == 200) {
|
||||
|
||||
console.log(body);
|
||||
}
|
||||
}
|
||||
|
||||
request(options, callback);
|
||||
`;
|
||||
return example;
|
||||
request(options, callback);`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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