mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +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": {
|
"/api/v1/admin/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -3205,6 +3566,41 @@ const docTemplate = `{
|
|||||||
"TaskExecutionStatusFailed"
|
"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": {
|
"model.Upload": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"upload.batchDownloadRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"/api/v1/admin/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -3198,6 +3559,41 @@
|
|||||||
"TaskExecutionStatusFailed"
|
"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": {
|
"model.Upload": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"upload.batchDownloadRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -224,6 +224,29 @@ definitions:
|
|||||||
- TaskExecutionStatusRunning
|
- TaskExecutionStatusRunning
|
||||||
- TaskExecutionStatusSucceeded
|
- TaskExecutionStatusSucceeded
|
||||||
- TaskExecutionStatusFailed
|
- 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:
|
model.Upload:
|
||||||
properties:
|
properties:
|
||||||
created_at:
|
created_at:
|
||||||
@@ -547,6 +570,52 @@ definitions:
|
|||||||
description: 类型:string, text, number
|
description: 类型:string, text, number
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
upload.batchDownloadRequest:
|
||||||
properties:
|
properties:
|
||||||
ids:
|
ids:
|
||||||
@@ -1495,6 +1564,224 @@ paths:
|
|||||||
summary: 获取支持的任务类型
|
summary: 获取支持的任务类型
|
||||||
tags:
|
tags:
|
||||||
- admin
|
- 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:
|
/api/v1/admin/users:
|
||||||
get:
|
get:
|
||||||
description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
|
description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {Label} from "@/components/ui/label"
|
|||||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||||
import {useAuth} from "@/components/providers/auth-provider"
|
import {useAuth} from "@/components/providers/auth-provider"
|
||||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||||
|
import {TemplatesManager} from "@/components/common/settings/templates"
|
||||||
import {AdminService, apiConfig} from "@/lib/services"
|
import {AdminService, apiConfig} from "@/lib/services"
|
||||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
@@ -74,8 +75,6 @@ const SECURITY_KEYS = [
|
|||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type SecurityKey = (typeof SECURITY_KEYS)[number]["key"]
|
|
||||||
|
|
||||||
function systemConfigMap(configs: SystemConfig[]) {
|
function systemConfigMap(configs: SystemConfig[]) {
|
||||||
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||||
accumulator[config.key] = config
|
accumulator[config.key] = config
|
||||||
@@ -671,7 +670,9 @@ export function SecurityMain() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="operation" />
|
<TabsContent value="operation" className="pt-4">
|
||||||
|
<TemplatesManager />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="system" className="pt-4">
|
<TabsContent value="system" className="pt-4">
|
||||||
<div className="space-y-6">
|
<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,
|
AuthSource,
|
||||||
AuthSourceRequest,
|
AuthSourceRequest,
|
||||||
CreateSystemConfigRequest,
|
CreateSystemConfigRequest,
|
||||||
|
CreateTemplateRequest,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
DispatchTaskRequest,
|
DispatchTaskRequest,
|
||||||
ListTaskExecutionsRequest,
|
ListTaskExecutionsRequest,
|
||||||
@@ -15,8 +16,10 @@ import type {
|
|||||||
TaskExecution,
|
TaskExecution,
|
||||||
TaskMeta,
|
TaskMeta,
|
||||||
TaskTypeResponse,
|
TaskTypeResponse,
|
||||||
|
Template,
|
||||||
ToggleAuthSourceRequest,
|
ToggleAuthSourceRequest,
|
||||||
UpdateSystemConfigRequest,
|
UpdateSystemConfigRequest,
|
||||||
|
UpdateTemplateRequest,
|
||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -362,4 +365,41 @@ export class AdminService extends BaseService {
|
|||||||
}> {
|
}> {
|
||||||
return this.get('/logs', { cursor, limit });
|
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;
|
last_pause: string;
|
||||||
num_gc: number;
|
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秒有效
|
// 存冷却,60秒有效
|
||||||
_ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second)
|
_ = 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{
|
payload := SendEmailPayload{
|
||||||
To: user.Email,
|
To: user.Email,
|
||||||
Subject: "Wavelet 登录验证码",
|
Subject: emailSubject,
|
||||||
Body: fmt.Sprintf("<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>%s</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>", code),
|
Body: emailBody,
|
||||||
}
|
}
|
||||||
payloadBytes, _ := json.Marshal(payload)
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
_, 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)
|
_ = 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. 投递异步邮件发送任务
|
// 4. 投递异步邮件发送任务
|
||||||
payload := SendEmailPayload{
|
payload := SendEmailPayload{
|
||||||
To: req.Email,
|
To: req.Email,
|
||||||
Subject: "Wavelet 注册验证码",
|
Subject: emailSubject,
|
||||||
Body: fmt.Sprintf("<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>%s</strong>,5分钟内有效,请勿泄露给他人。</p>", code),
|
Body: emailBody,
|
||||||
}
|
}
|
||||||
payloadBytes, _ := json.Marshal(payload)
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func Migrate() {
|
|||||||
&model.Upload{},
|
&model.Upload{},
|
||||||
&model.AccessToken{},
|
&model.AccessToken{},
|
||||||
&model.TaskExecution{},
|
&model.TaskExecution{},
|
||||||
|
&model.Template{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
|
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,8 @@ func Migrate() {
|
|||||||
initSystemConfigs()
|
initSystemConfigs()
|
||||||
// 初始化默认管理员用户
|
// 初始化默认管理员用户
|
||||||
initDefaultAdmin()
|
initDefaultAdmin()
|
||||||
|
// 初始化系统内置模板
|
||||||
|
initTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureConfigKeyExists ensures a system config key exists in the database
|
// 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")
|
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_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
|
||||||
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
|
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
|
||||||
admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task"
|
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"
|
admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user"
|
||||||
capApp "github.com/Rain-kl/Wavelet/internal/apps/cap"
|
capApp "github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||||||
publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config"
|
publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config"
|
||||||
@@ -216,6 +217,17 @@ func Serve() {
|
|||||||
systemConfigRouter.DELETE("", system_config.DeleteSystemConfig)
|
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
|
// Auth Sources
|
||||||
adminRouter.GET("/auth-sources", admin_auth_source.ListAuthSources)
|
adminRouter.GET("/auth-sources", admin_auth_source.ListAuthSources)
|
||||||
adminRouter.POST("/auth-sources", admin_auth_source.CreateAuthSource)
|
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.SystemConfig{},
|
||||||
&model.Upload{},
|
&model.Upload{},
|
||||||
&model.TaskExecution{},
|
&model.TaskExecution{},
|
||||||
|
&model.Template{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to auto migrate tables: %v", err)
|
t.Fatalf("failed to auto migrate tables: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user