mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
模板系统
This commit is contained in:
+459
@@ -1346,6 +1346,367 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回所有通知模板列表,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取模板列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "模板列表",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Template"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "创建模板",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "创建请求参数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/template.CreateTemplateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "创建成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误或模板标识符已存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/templates/{key}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据模板标识符获取对应的模板详情,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取单个模板",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模板标识符",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "模板详情",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/model.Template"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "模板不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据模板标识符更新对应的模板内容,需要管理员权限",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新模板",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模板标识符",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "更新请求参数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/template.UpdateTemplateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "更新成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/model.Template"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "模板不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "删除模板",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模板标识符",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "删除成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "不可删除系统模板",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "模板不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3205,6 +3566,41 @@ const docTemplate = `{
|
||||
"TaskExecutionStatusFailed"
|
||||
]
|
||||
},
|
||||
"model.Template": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_system": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Upload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3677,6 +4073,69 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"template.CreateTemplateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"content",
|
||||
"key",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"maxLength": 80
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"maxLength": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"template.UpdateTemplateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"content",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"maxLength": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"upload.batchDownloadRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -1339,6 +1339,367 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回所有通知模板列表,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取模板列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "模板列表",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Template"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "创建模板",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "创建请求参数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/template.CreateTemplateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "创建成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误或模板标识符已存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/templates/{key}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据模板标识符获取对应的模板详情,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取单个模板",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模板标识符",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "模板详情",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/model.Template"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "模板不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据模板标识符更新对应的模板内容,需要管理员权限",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新模板",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模板标识符",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "更新请求参数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/template.UpdateTemplateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "更新成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/model.Template"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "模板不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "删除模板",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "模板标识符",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "删除成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "不可删除系统模板",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "模板不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3198,6 +3559,41 @@
|
||||
"TaskExecutionStatusFailed"
|
||||
]
|
||||
},
|
||||
"model.Template": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_system": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Upload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3670,6 +4066,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"template.CreateTemplateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"content",
|
||||
"key",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"maxLength": 80
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"maxLength": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"template.UpdateTemplateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"content",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"maxLength": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"upload.batchDownloadRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -224,6 +224,29 @@ definitions:
|
||||
- TaskExecutionStatusRunning
|
||||
- TaskExecutionStatusSucceeded
|
||||
- TaskExecutionStatusFailed
|
||||
model.Template:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
is_system:
|
||||
type: boolean
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
model.Upload:
|
||||
properties:
|
||||
created_at:
|
||||
@@ -547,6 +570,52 @@ definitions:
|
||||
description: 类型:string, text, number
|
||||
type: string
|
||||
type: object
|
||||
template.CreateTemplateRequest:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
description:
|
||||
maxLength: 255
|
||||
type: string
|
||||
key:
|
||||
maxLength: 80
|
||||
type: string
|
||||
name:
|
||||
maxLength: 100
|
||||
type: string
|
||||
subject:
|
||||
maxLength: 255
|
||||
type: string
|
||||
type:
|
||||
maxLength: 20
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
- key
|
||||
- name
|
||||
- type
|
||||
type: object
|
||||
template.UpdateTemplateRequest:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
description:
|
||||
maxLength: 255
|
||||
type: string
|
||||
name:
|
||||
maxLength: 100
|
||||
type: string
|
||||
subject:
|
||||
maxLength: 255
|
||||
type: string
|
||||
type:
|
||||
maxLength: 20
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
- name
|
||||
- type
|
||||
type: object
|
||||
upload.batchDownloadRequest:
|
||||
properties:
|
||||
ids:
|
||||
@@ -1495,6 +1564,224 @@ paths:
|
||||
summary: 获取支持的任务类型
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/templates:
|
||||
get:
|
||||
description: 返回所有通知模板列表,需要管理员权限
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 模板列表
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/model.Template'
|
||||
type: array
|
||||
type: object
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取模板列表
|
||||
tags:
|
||||
- admin
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限
|
||||
parameters:
|
||||
- description: 创建请求参数
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/template.CreateTemplateRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 创建成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误或模板标识符已存在
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 创建模板
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/templates/{key}:
|
||||
delete:
|
||||
description: 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限
|
||||
parameters:
|
||||
- description: 模板标识符
|
||||
in: path
|
||||
name: key
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 删除成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: 不可删除系统模板
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"404":
|
||||
description: 模板不存在
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 删除模板
|
||||
tags:
|
||||
- admin
|
||||
get:
|
||||
description: 根据模板标识符获取对应的模板详情,需要管理员权限
|
||||
parameters:
|
||||
- description: 模板标识符
|
||||
in: path
|
||||
name: key
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 模板详情
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.Template'
|
||||
type: object
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"404":
|
||||
description: 模板不存在
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取单个模板
|
||||
tags:
|
||||
- admin
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 根据模板标识符更新对应的模板内容,需要管理员权限
|
||||
parameters:
|
||||
- description: 模板标识符
|
||||
in: path
|
||||
name: key
|
||||
required: true
|
||||
type: string
|
||||
- description: 更新请求参数
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/template.UpdateTemplateRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 更新成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.Template'
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"404":
|
||||
description: 模板不存在
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 更新模板
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/users:
|
||||
get:
|
||||
description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
|
||||
|
||||
@@ -30,6 +30,7 @@ import {Label} from "@/components/ui/label"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import {TemplatesManager} from "@/components/common/settings/templates"
|
||||
import {AdminService, apiConfig} from "@/lib/services"
|
||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
@@ -74,8 +75,6 @@ const SECURITY_KEYS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
type SecurityKey = (typeof SECURITY_KEYS)[number]["key"]
|
||||
|
||||
function systemConfigMap(configs: SystemConfig[]) {
|
||||
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||
accumulator[config.key] = config
|
||||
@@ -671,7 +670,9 @@ export function SecurityMain() {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="operation" />
|
||||
<TabsContent value="operation" className="pt-4">
|
||||
<TemplatesManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="system" className="pt-4">
|
||||
<div className="space-y-6">
|
||||
{/* 通用设置 */}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {FileText, Loader2, Pencil, Plus, Trash2} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import type {Template} from "@/lib/services/admin/types"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function TemplatesManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
||||
|
||||
// Form states
|
||||
const [key, setKey] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [type, setType] = useState("email")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["admin", "templates"],
|
||||
queryFn: () => AdminService.listTemplates(),
|
||||
})
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await AdminService.createTemplate({
|
||||
key,
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功创建")
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await AdminService.updateTemplate(key, {
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功保存")
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await AdminService.deleteTemplate(key)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已删除")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setSelectedTemplate(null)
|
||||
setKey("")
|
||||
setName("")
|
||||
setType("email")
|
||||
setSubject("")
|
||||
setContent("")
|
||||
setDescription("")
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenEdit = (tmpl: Template) => {
|
||||
setSelectedTemplate(tmpl)
|
||||
setKey(tmpl.key)
|
||||
setName(tmpl.name)
|
||||
setType(tmpl.type)
|
||||
setSubject(tmpl.subject || "")
|
||||
setContent(tmpl.content)
|
||||
setDescription(tmpl.description || "")
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (selectedTemplate) {
|
||||
updateTemplateMutation.mutate(selectedTemplate.key)
|
||||
} else {
|
||||
createTemplateMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleOpenCreate}
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增模板
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{templatesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (templatesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{(templatesQuery.data ?? []).map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{tmpl.is_system ? "系统内置" : "自定义"}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono">
|
||||
{tmpl.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
标识符: <span className="font-mono text-indigo-500 bg-indigo-500/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed max-w-xl">
|
||||
{tmpl.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
onClick={() => handleOpenEdit(tmpl)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
disabled={tmpl.is_system || deleteTemplateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除模板「${tmpl.name}」吗?`)) {
|
||||
deleteTemplateMutation.mutate(tmpl.key)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<span>暂无配置的通知模板,点击上方按钮新增</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleOpenCreate}
|
||||
className="border-dashed"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-xl border border-dashed">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_key" className="text-xs font-semibold">
|
||||
模板标识符 (Key)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_key"
|
||||
type="text"
|
||||
required
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="例如: login_email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
disabled={!!selectedTemplate}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
模板的唯一标识,在代码中通过此 Key 调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_name" className="text-xs font-semibold">
|
||||
模板名称
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如: 登录验证码邮件"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
用于后台识别该模板的可读名称。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_type" className="text-xs font-semibold">
|
||||
模板类型
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_type"
|
||||
type="text"
|
||||
required
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
模板分类标识,目前支持 `email`。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_subject" className="text-xs font-semibold">
|
||||
模板主题 (Subject)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="例如: Wavelet 登录验证码"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
邮件标题(类型为 email 时生效)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_description" className="text-xs font-semibold">
|
||||
模板说明与变量描述
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_content" className="text-xs font-semibold">
|
||||
模板正文内容 (Content)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tmpl_content"
|
||||
required
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
|
||||
rows={8}
|
||||
className="bg-card border-dashed text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AuthSource,
|
||||
AuthSourceRequest,
|
||||
CreateSystemConfigRequest,
|
||||
CreateTemplateRequest,
|
||||
CreateUserRequest,
|
||||
DispatchTaskRequest,
|
||||
ListTaskExecutionsRequest,
|
||||
@@ -15,8 +16,10 @@ import type {
|
||||
TaskExecution,
|
||||
TaskMeta,
|
||||
TaskTypeResponse,
|
||||
Template,
|
||||
ToggleAuthSourceRequest,
|
||||
UpdateSystemConfigRequest,
|
||||
UpdateTemplateRequest,
|
||||
UpdateUserStatusRequest,
|
||||
} from './types';
|
||||
|
||||
@@ -362,4 +365,41 @@ export class AdminService extends BaseService {
|
||||
}> {
|
||||
return this.get('/logs', { cursor, limit });
|
||||
}
|
||||
|
||||
// ==================== 模板管理 ====================
|
||||
|
||||
/**
|
||||
* 获取模板列表
|
||||
*/
|
||||
static async listTemplates(): Promise<Template[]> {
|
||||
return this.get<Template[]>('/templates');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模板
|
||||
*/
|
||||
static async getTemplate(key: string): Promise<Template> {
|
||||
return this.get<Template>(`/templates/${ key }`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模板
|
||||
*/
|
||||
static async createTemplate(request: CreateTemplateRequest): Promise<Template> {
|
||||
return this.post<Template>('/templates', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
static async updateTemplate(key: string, request: UpdateTemplateRequest): Promise<Template> {
|
||||
return this.put<Template>(`/templates/${ key }`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
static async deleteTemplate(key: string): Promise<void> {
|
||||
return this.delete<void>(`/templates/${ key }`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,3 +324,43 @@ export interface SystemStatus {
|
||||
last_pause: string;
|
||||
num_gc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板配置信息
|
||||
*/
|
||||
export interface Template {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
description: string;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模板请求参数
|
||||
*/
|
||||
export interface CreateTemplateRequest {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板请求参数
|
||||
*/
|
||||
export interface UpdateTemplateRequest {
|
||||
name: string;
|
||||
type: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package template
|
||||
|
||||
const (
|
||||
TemplateNotFound = "模板不存在"
|
||||
TemplateKeyRequired = "模板标识符不能为空"
|
||||
TemplateNameRequired = "模板名称不能为空"
|
||||
TemplateContentRequired = "模板内容不能为空"
|
||||
TemplateKeyExists = "模板标识符已存在"
|
||||
SystemTemplateCannotDelete = "系统预置模板不可删除"
|
||||
SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符"
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package template
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateTemplateRequest 创建模板请求
|
||||
type CreateTemplateRequest struct {
|
||||
Key string `json:"key" binding:"required,max=80"`
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Type string `json:"type" binding:"required,max=20"`
|
||||
Subject string `json:"subject" binding:"max=255"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Description string `json:"description" binding:"max=255"`
|
||||
}
|
||||
|
||||
// UpdateTemplateRequest 更新模板请求
|
||||
type UpdateTemplateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Type string `json:"type" binding:"required,max=20"`
|
||||
Subject string `json:"subject" binding:"max=255"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Description string `json:"description" binding:"max=255"`
|
||||
}
|
||||
|
||||
// CreateTemplate 创建模板
|
||||
// @Summary 创建模板
|
||||
// @Description 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body template.CreateTemplateRequest true "创建请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=string} "创建成功"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误或模板标识符已存在"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/templates [post]
|
||||
func CreateTemplate(c *gin.Context) {
|
||||
var req CreateTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查模板 Key 是否已存在
|
||||
var existing model.Template
|
||||
if err := db.DB(c.Request.Context()).Where("key = ?", req.Key).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(TemplateKeyExists))
|
||||
return
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := model.Template{
|
||||
Key: req.Key,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Subject: req.Subject,
|
||||
Content: req.Content,
|
||||
Description: req.Description,
|
||||
IsSystem: false,
|
||||
}
|
||||
|
||||
if err := tmpl.Validate(); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Create(&tmpl).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(tmpl))
|
||||
}
|
||||
|
||||
// ListTemplates 获取模板列表
|
||||
// @Summary 获取模板列表
|
||||
// @Description 返回所有通知模板列表,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=[]model.Template} "模板列表"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/templates [get]
|
||||
func ListTemplates(c *gin.Context) {
|
||||
var templates []model.Template
|
||||
if err := db.DB(c.Request.Context()).Order("is_system DESC, created_at DESC").Find(&templates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(templates))
|
||||
}
|
||||
|
||||
// GetTemplate 获取单个模板
|
||||
// @Summary 获取单个模板
|
||||
// @Description 根据模板标识符获取对应的模板详情,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param key path string true "模板标识符"
|
||||
// @Success 200 {object} util.ResponseAny{data=model.Template} "模板详情"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 404 {object} util.ResponseAny "模板不存在"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/templates/{key} [get]
|
||||
func GetTemplate(c *gin.Context) {
|
||||
var tmpl model.Template
|
||||
if err := db.DB(c.Request.Context()).Where("key = ?", c.Param("key")).First(&tmpl).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, util.Err(TemplateNotFound))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(tmpl))
|
||||
}
|
||||
|
||||
// UpdateTemplate 更新模板
|
||||
// @Summary 更新模板
|
||||
// @Description 根据模板标识符更新对应的模板内容,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param key path string true "模板标识符"
|
||||
// @Param request body template.UpdateTemplateRequest true "更新请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=model.Template} "更新成功"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 404 {object} util.ResponseAny "模板不存在"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/templates/{key} [put]
|
||||
func UpdateTemplate(c *gin.Context) {
|
||||
var req UpdateTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
key := c.Param("key")
|
||||
|
||||
// 检查模板是否存在
|
||||
var tmpl model.Template
|
||||
if err := db.DB(c.Request.Context()).Where("key = ?", key).First(&tmpl).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, util.Err(TemplateNotFound))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.Name = req.Name
|
||||
tmpl.Type = req.Type
|
||||
tmpl.Subject = req.Subject
|
||||
tmpl.Content = req.Content
|
||||
tmpl.Description = req.Description
|
||||
|
||||
if err := tmpl.Validate(); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Save(&tmpl).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(tmpl))
|
||||
}
|
||||
|
||||
// DeleteTemplate 删除模板
|
||||
// @Summary 删除模板
|
||||
// @Description 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param key path string true "模板标识符"
|
||||
// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
|
||||
// @Failure 400 {object} util.ResponseAny "不可删除系统模板"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 404 {object} util.ResponseAny "模板不存在"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/templates/{key} [delete]
|
||||
func DeleteTemplate(c *gin.Context) {
|
||||
key := c.Param("key")
|
||||
|
||||
// 检查模板是否存在
|
||||
var tmpl model.Template
|
||||
if err := db.DB(c.Request.Context()).Where("key = ?", key).First(&tmpl).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, util.Err(TemplateNotFound))
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 限制系统模板删除
|
||||
if tmpl.IsSystem {
|
||||
c.JSON(http.StatusBadRequest, util.Err(SystemTemplateCannotDelete))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Delete(&tmpl).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestRouter(authUser *model.User) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
adminGroup := r.Group("/api/v1/admin")
|
||||
|
||||
// Mock authentication middleware
|
||||
adminGroup.Use(func(c *gin.Context) {
|
||||
if authUser != nil {
|
||||
util.SetToContext(c, oauth.UserObjKey, authUser)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
adminGroup.GET("/templates", ListTemplates)
|
||||
adminGroup.POST("/templates", CreateTemplate)
|
||||
|
||||
templateRouter := adminGroup.Group("/templates/:key")
|
||||
{
|
||||
templateRouter.GET("", GetTemplate)
|
||||
templateRouter.PUT("", UpdateTemplate)
|
||||
templateRouter.DELETE("", DeleteTemplate)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCreateTemplate(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("create successfully", func(t *testing.T) {
|
||||
payload := CreateTemplateRequest{
|
||||
Key: "test_template",
|
||||
Name: "Test Template",
|
||||
Type: "email",
|
||||
Subject: "Test Subject",
|
||||
Content: "Hello {{.Name}}",
|
||||
Description: "Test Desc",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/templates", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var tmpl model.Template
|
||||
err := dbConn.Where("key = ?", "test_template").First(&tmpl).Error
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find template in DB: %v", err)
|
||||
}
|
||||
if tmpl.Name != "Test Template" {
|
||||
t.Errorf("expected Name 'Test Template', got '%s'", tmpl.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create duplicate key error", func(t *testing.T) {
|
||||
payload := CreateTemplateRequest{
|
||||
Key: "test_template",
|
||||
Name: "Another Name",
|
||||
Type: "email",
|
||||
Subject: "Another Subject",
|
||||
Content: "Hello",
|
||||
Description: "desc",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/templates", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 Bad Request on duplicate key, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListTemplates(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed system templates manually for testing
|
||||
t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true}
|
||||
t2 := model.Template{Key: "register_email", Name: "Register Code", Type: "email", Content: "code {{.Code}}", IsSystem: true}
|
||||
dbConn.Create(&t1)
|
||||
dbConn.Create(&t2)
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("list templates", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/templates", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp util.ResponseAny
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var templates []model.Template
|
||||
json.Unmarshal(dataBytes, &templates)
|
||||
|
||||
if len(templates) != 2 {
|
||||
t.Errorf("expected 2 templates, got %d", len(templates))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTemplate(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true}
|
||||
dbConn.Create(&t1)
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("get existing", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/templates/login_email", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 OK, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get non-existent", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/templates/non_existent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 Not Found, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateTemplate(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true}
|
||||
dbConn.Create(&t1)
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("update successfully", func(t *testing.T) {
|
||||
payload := UpdateTemplateRequest{
|
||||
Name: "Updated Login Code",
|
||||
Type: "email",
|
||||
Subject: "New Subject",
|
||||
Content: "new code {{.Code}}",
|
||||
Description: "new desc",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/admin/templates/login_email", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var tmpl model.Template
|
||||
dbConn.Where("key = ?", "login_email").First(&tmpl)
|
||||
if tmpl.Name != "Updated Login Code" || tmpl.Subject != "New Subject" {
|
||||
t.Errorf("database values not updated: %+v", tmpl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteTemplate(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
t1 := model.Template{Key: "login_email", Name: "Login Code", Type: "email", Content: "code {{.Code}}", IsSystem: true}
|
||||
t2 := model.Template{Key: "custom_tmpl", Name: "Custom", Type: "email", Content: "hi", IsSystem: false}
|
||||
dbConn.Create(&t1)
|
||||
dbConn.Create(&t2)
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("delete system template should fail", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/admin/templates/login_email", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 Bad Request when deleting system template, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete custom template should succeed", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/admin/templates/custom_tmpl", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
dbConn.Model(&model.Template{}).Where("key = ?", "custom_tmpl").Count(&count)
|
||||
if count != 0 {
|
||||
t.Error("custom template was not deleted from DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -180,11 +180,20 @@ func Login(c *gin.Context) {
|
||||
// 存冷却,60秒有效
|
||||
_ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second)
|
||||
|
||||
// 使用模板管理获取并渲染邮件标题和正文
|
||||
emailSubject, emailBody := model.RenderTemplate(
|
||||
ctx,
|
||||
"login_email",
|
||||
map[string]any{"Code": code},
|
||||
"Wavelet 登录验证码",
|
||||
fmt.Sprintf("<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>%s</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>", code),
|
||||
)
|
||||
|
||||
// 构建异步邮件发送任务
|
||||
payload := SendEmailPayload{
|
||||
To: user.Email,
|
||||
Subject: "Wavelet 登录验证码",
|
||||
Body: fmt.Sprintf("<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>%s</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>", code),
|
||||
Subject: emailSubject,
|
||||
Body: emailBody,
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
||||
@@ -516,11 +525,20 @@ func SendEmailCode(c *gin.Context) {
|
||||
}
|
||||
_ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second)
|
||||
|
||||
// 使用模板管理获取并渲染邮件标题和正文
|
||||
emailSubject, emailBody := model.RenderTemplate(
|
||||
ctx,
|
||||
"register_email",
|
||||
map[string]any{"Code": code},
|
||||
"Wavelet 注册验证码",
|
||||
fmt.Sprintf("<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>%s</strong>,5分钟内有效,请勿泄露给他人。</p>", code),
|
||||
)
|
||||
|
||||
// 4. 投递异步邮件发送任务
|
||||
payload := SendEmailPayload{
|
||||
To: req.Email,
|
||||
Subject: "Wavelet 注册验证码",
|
||||
Body: fmt.Sprintf("<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>%s</strong>,5分钟内有效,请勿泄露给他人。</p>", code),
|
||||
Subject: emailSubject,
|
||||
Body: emailBody,
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
||||
|
||||
@@ -42,6 +42,7 @@ func Migrate() {
|
||||
&model.Upload{},
|
||||
&model.AccessToken{},
|
||||
&model.TaskExecution{},
|
||||
&model.Template{},
|
||||
); err != nil {
|
||||
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
|
||||
}
|
||||
@@ -51,6 +52,8 @@ func Migrate() {
|
||||
initSystemConfigs()
|
||||
// 初始化默认管理员用户
|
||||
initDefaultAdmin()
|
||||
// 初始化系统内置模板
|
||||
initTemplates()
|
||||
}
|
||||
|
||||
// ensureConfigKeyExists ensures a system config key exists in the database
|
||||
@@ -267,3 +270,55 @@ func initDefaultAdmin() {
|
||||
log.Printf("[PostgreSQL] default admin user created successfully (username: admin, password: 12345678)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// initTemplates 初始化系统内置模板
|
||||
func initTemplates() {
|
||||
tx := db.DB(context.Background())
|
||||
var count int64
|
||||
if err := tx.Model(&model.Template{}).Count(&count).Error; err != nil {
|
||||
log.Printf("[PostgreSQL] failed to check templates table: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
defaultTemplates := []model.Template{
|
||||
{
|
||||
Key: "login_email",
|
||||
Name: "登录验证码邮件",
|
||||
Type: "email",
|
||||
Subject: "Wavelet 登录验证码",
|
||||
Content: "<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>",
|
||||
Description: "用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}",
|
||||
IsSystem: true,
|
||||
},
|
||||
{
|
||||
Key: "register_email",
|
||||
Name: "注册验证码邮件",
|
||||
Type: "email",
|
||||
Subject: "Wavelet 注册验证码",
|
||||
Content: "<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿泄露给他人。</p>",
|
||||
Description: "用户注册时发送的验证码邮件模板,支持变量:{{.Code}}",
|
||||
IsSystem: true,
|
||||
},
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// 确保系统预置模板存在
|
||||
for _, dt := range defaultTemplates {
|
||||
var t model.Template
|
||||
if err := tx.Where("key = ?", dt.Key).First(&t).Error; err != nil {
|
||||
if err := tx.Create(&dt).Error; err != nil {
|
||||
log.Printf("[PostgreSQL] failed to create template key %s: %v\n", dt.Key, err)
|
||||
} else {
|
||||
log.Printf("[PostgreSQL] initialized template key %s\n", dt.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Create(&defaultTemplates).Error; err != nil {
|
||||
log.Printf("[PostgreSQL] failed to create default templates: %v\n", err)
|
||||
} else {
|
||||
log.Printf("[PostgreSQL] initialized %d default templates\n", len(defaultTemplates))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
)
|
||||
|
||||
type Template struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Key string `json:"key" gorm:"uniqueIndex;size:80;not null"`
|
||||
Name string `json:"name" gorm:"size:100;not null"`
|
||||
Type string `json:"type" gorm:"size:20;not null;default:'email'"`
|
||||
Subject string `json:"subject" gorm:"size:255"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
Description string `json:"description" gorm:"size:255"`
|
||||
IsSystem bool `json:"is_system" gorm:"index;not null;default:false"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
|
||||
}
|
||||
|
||||
func (t *Template) Normalize() {
|
||||
t.Key = strings.TrimSpace(t.Key)
|
||||
t.Name = strings.TrimSpace(t.Name)
|
||||
t.Type = strings.ToLower(strings.TrimSpace(t.Type))
|
||||
t.Subject = strings.TrimSpace(t.Subject)
|
||||
t.Content = strings.TrimSpace(t.Content)
|
||||
t.Description = strings.TrimSpace(t.Description)
|
||||
if t.Type == "" {
|
||||
t.Type = "email"
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Template) Validate() error {
|
||||
t.Normalize()
|
||||
if t.Key == "" {
|
||||
return errors.New("模板标识符不能为空")
|
||||
}
|
||||
if t.Name == "" {
|
||||
return errors.New("模板名称不能为空")
|
||||
}
|
||||
if t.Content == "" {
|
||||
return errors.New("模板内容不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Template) Render(data any) (string, string, error) {
|
||||
// Render Subject
|
||||
var subject string
|
||||
if t.Subject != "" {
|
||||
tmplSubject, err := template.New(t.Key + "_subject").Parse(t.Subject)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var subBuf bytes.Buffer
|
||||
if err := tmplSubject.Execute(&subBuf, data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
subject = subBuf.String()
|
||||
}
|
||||
|
||||
// Render Content
|
||||
tmplContent, err := template.New(t.Key + "_content").Parse(t.Content)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmplContent.Execute(&bodyBuf, data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return subject, bodyBuf.String(), nil
|
||||
}
|
||||
|
||||
// RenderTemplate 渲染模板的高级包装。如果读取或渲染失败,将使用 fallbackSubject 和 fallbackBody 进行解析和返回。
|
||||
func RenderTemplate(ctx context.Context, key string, data any, fallbackSubject, fallbackBody string) (string, string) {
|
||||
var t Template
|
||||
if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err == nil {
|
||||
subject, body, err := t.Render(data)
|
||||
if err == nil {
|
||||
return subject, body
|
||||
}
|
||||
}
|
||||
|
||||
// 降级使用传入的默认模板内容渲染
|
||||
tFallback := Template{
|
||||
Key: key + "_fallback",
|
||||
Subject: fallbackSubject,
|
||||
Content: fallbackBody,
|
||||
}
|
||||
subject, body, err := tFallback.Render(data)
|
||||
if err == nil {
|
||||
return subject, body
|
||||
}
|
||||
|
||||
return fallbackSubject, fallbackBody
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
|
||||
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
|
||||
admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task"
|
||||
admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template"
|
||||
admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user"
|
||||
capApp "github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||||
publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config"
|
||||
@@ -216,6 +217,17 @@ func Serve() {
|
||||
systemConfigRouter.DELETE("", system_config.DeleteSystemConfig)
|
||||
}
|
||||
|
||||
// Templates
|
||||
adminRouter.GET("/templates", admin_template.ListTemplates)
|
||||
adminRouter.POST("/templates", admin_template.CreateTemplate)
|
||||
|
||||
templateRouter := adminRouter.Group("/templates/:key")
|
||||
{
|
||||
templateRouter.GET("", admin_template.GetTemplate)
|
||||
templateRouter.PUT("", admin_template.UpdateTemplate)
|
||||
templateRouter.DELETE("", admin_template.DeleteTemplate)
|
||||
}
|
||||
|
||||
// Auth Sources
|
||||
adminRouter.GET("/auth-sources", admin_auth_source.ListAuthSources)
|
||||
adminRouter.POST("/auth-sources", admin_auth_source.CreateAuthSource)
|
||||
|
||||
@@ -48,6 +48,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
||||
&model.SystemConfig{},
|
||||
&model.Upload{},
|
||||
&model.TaskExecution{},
|
||||
&model.Template{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to auto migrate tables: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user