feat: add message template

This commit is contained in:
engigu
2025-12-06 00:28:18 +08:00
parent ff194daef2
commit a511512793
21 changed files with 2608 additions and 155 deletions
+3 -1
View File
@@ -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
View File
@@ -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 {
+149
View File
@@ -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
}
+23
View File
@@ -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
View File
@@ -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)
@@ -66,18 +68,71 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
query = query.Offset(pageNum).Limit(pageSize)
}
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)
+336
View File
@@ -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)
}
+1
View File
@@ -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,
+146
View File
@@ -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
}
+21
View File
@@ -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
}
+171 -42
View File
@@ -18,6 +18,12 @@ const (
SendFail = 0
)
// 发送模式类型
const (
SendModeTask = "task" // 传统任务模式
SendModeTemplate = "template" // 模板模式
)
func errStrIsSuccess(errStr string) int {
if errStr == "" {
return SendSuccess
@@ -26,13 +32,16 @@ func errStrIsSuccess(errStr string) int {
}
type SendMessageService struct {
TaskID string
Title string
Text string
HTML string
URL string
MarkDown string
CallerIp string
SendMode string // 发送模式:task(任务模式) 或 template(模板模式)
TaskID string // 任务ID(任务模式)或模板ID(模板模式,用于日志记录)
TemplateID string // 模板ID(仅模板模式使用)
Name string // 任务或模板名称(用于日志记录)
Title string
Text string
HTML string
URL string
MarkDown string
CallerIp string
// @提及相关字段
AtMobiles []string
@@ -82,31 +91,88 @@ 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]",
})
sendTaskService := send_task_service.SendTaskService{
ID: sm.TaskID,
}
task, err := sendTaskService.GetTaskWithIns()
if err != nil {
errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID)
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,
}
task, err := sendTaskService.GetTaskWithIns()
if err != nil {
errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID)
entry.Errorf(errStr)
return task, errors.New(errStr)
}
if task.ID == "" {
errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID)
entry.Errorf(errStr)
return task, errors.New(errStr)
}
if len(task.InsData) == 0 {
errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID)
entry.Errorf(errStr)
return task, errors.New(errStr)
}
// 设置任务名称用于日志记录
if sm.Name == "" {
sm.Name = task.Name
}
return task, nil
default:
// SendMode 未设置或无效
errStr = fmt.Sprintf("SendMode 未设置或无效: %s,必须是 '%s' 或 '%s'", sm.SendMode, SendModeTask, SendModeTemplate)
entry.Errorf(errStr)
return task, errors.New(errStr)
}
if task.ID == "" {
errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID)
entry.Errorf(errStr)
return task, errors.New(errStr)
}
if len(task.InsData) == 0 {
errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID)
entry.Errorf(errStr)
return task, errors.New(errStr)
}
return task, nil
}
// Send 发送一个消息任务的所有实例
@@ -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,16 +224,33 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
continue
}
// 构建统一消息内容(支持@功能)
unifiedContent := &unified.UnifiedMessageContent{
Title: sm.Title,
Text: sm.Text,
HTML: sm.HTML,
Markdown: sm.MarkDown,
URL: sm.URL,
AtMobiles: sm.AtMobiles,
AtUserIds: sm.AtUserIds,
AtAll: sm.AtAll,
// 根据发送模式构建消息内容
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{
Title: sm.Title,
Text: sm.Text,
HTML: sm.HTML,
Markdown: sm.MarkDown,
URL: sm.URL,
AtMobiles: sm.AtMobiles,
AtUserIds: sm.AtUserIds,
AtAll: sm.AtAll,
}
}
// 使用 SendUnified 方法(自动格式转换和@功能支持)
@@ -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{}
+3 -2
View File
@@ -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>支持 TextHTMLMarkdown 三种格式根据实例配置精确发送对应类型</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>
+32 -8
View File
@@ -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
View File
@@ -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: "想要设置的渠道名字" },
+5
View File
@@ -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
View File
@@ -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 };