feat: 添加存储配置管理功能
- 添加存储配置管理页面(列表、创建、编辑) - 支持本地存储、S3、WebDAV、FTP、SFTP 等存储类型 - 添加存储配置测试连接功能 - 本地存储自动初始化且禁止删除 - 修复 Switch 组件状态显示问题 - 添加更新存储配置时的 status 字段支持
This commit is contained in:
@@ -165,6 +165,8 @@ func initMySQL() {
|
||||
&model.ExtensionAPIKey{},
|
||||
&model.DynamicCode{},
|
||||
&model.PaymentChannel{},
|
||||
&model.EmailConfig{},
|
||||
&model.StorageConfig{},
|
||||
&model.Captcha{},
|
||||
&model.ApiUsage{},
|
||||
&model.StorageUsage{},
|
||||
@@ -366,7 +368,22 @@ func initData() {
|
||||
DB.Where("app_id IS NULL").Delete(&model.CloudVariable{})
|
||||
}
|
||||
|
||||
// 初始化文档分类和文档
|
||||
var localStorageCount int64
|
||||
DB.Model(&model.StorageConfig{}).Where("type = ?", "local").Count(&localStorageCount)
|
||||
if localStorageCount == 0 {
|
||||
log.Println("Creating default local storage config...")
|
||||
defaultStorage := model.StorageConfig{
|
||||
Name: "本地存储",
|
||||
Type: "local",
|
||||
IsDefault: true,
|
||||
Status: "active",
|
||||
Remark: "系统默认本地存储",
|
||||
}
|
||||
DB.Create(&defaultStorage)
|
||||
} else {
|
||||
DB.Model(&model.StorageConfig{}).Where("type = ? AND (status = '' OR status IS NULL)", "local").Update("status", "active")
|
||||
}
|
||||
|
||||
initDocData()
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,41 @@ type PaymentChannel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
SMTPHost string `gorm:"size:100;not null" json:"smtp_host"`
|
||||
SMTPPort int `gorm:"not null" json:"smtp_port"`
|
||||
SMTPUser string `gorm:"size:100;not null" json:"smtp_user"`
|
||||
SMTPPassword string `gorm:"size:255" json:"smtp_password"`
|
||||
SMTPFrom string `gorm:"size:100;not null" json:"smtp_from"`
|
||||
SMTPFromName string `gorm:"size:100" json:"smtp_from_name"`
|
||||
Encryption string `gorm:"size:20;default:tls" json:"encryption"`
|
||||
Status string `gorm:"size:20;default:active" json:"status"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Type string `gorm:"size:20;not null" json:"type"`
|
||||
Endpoint string `gorm:"size:255" json:"endpoint"`
|
||||
Bucket string `gorm:"size:100" json:"bucket"`
|
||||
AccessKey string `gorm:"size:255" json:"access_key"`
|
||||
SecretKey string `gorm:"size:255" json:"secret_key"`
|
||||
Region string `gorm:"size:50" json:"region"`
|
||||
PathPrefix string `gorm:"size:255" json:"path_prefix"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Status string `gorm:"size:20;default:active" json:"status"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// DynamicCode 动态代码模型
|
||||
type DynamicCode struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -24,6 +24,9 @@ func SetupRoutes(r *gin.RouterGroup) {
|
||||
SetupVersionRoutes(r)
|
||||
SetupProfileRoutes(r)
|
||||
SetupEmailRoutes(r)
|
||||
SetupSystemSettingsRoutes(r)
|
||||
SetupEmailConfigRoutes(r)
|
||||
SetupStorageConfigRoutes(r)
|
||||
}
|
||||
|
||||
func SetupRoutesWithoutPackage(r *gin.RouterGroup) {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"verification-platform-backend/internal/database"
|
||||
"verification-platform-backend/internal/model"
|
||||
"verification-platform-backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupEmailConfigRoutes(r *gin.RouterGroup) {
|
||||
emailConfigs := r.Group("/email-configs")
|
||||
{
|
||||
emailConfigs.GET("", handleGetSystemEmailConfigs)
|
||||
emailConfigs.POST("", handleCreateSystemEmailConfig)
|
||||
emailConfigs.GET("/:id", handleGetSystemEmailConfig)
|
||||
emailConfigs.PUT("/:id", handleUpdateSystemEmailConfig)
|
||||
emailConfigs.DELETE("/:id", handleDeleteSystemEmailConfig)
|
||||
emailConfigs.PUT("/:id/status", handleUpdateSystemEmailConfigStatus)
|
||||
emailConfigs.POST("/:id/test", handleTestSystemEmailConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetSystemEmailConfigs(c *gin.Context) {
|
||||
var configs []model.EmailConfig
|
||||
database.DB.Order("id desc").Find(&configs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"email_configs": configs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type CreateSystemEmailConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
SMTPHost string `json:"smtp_host" binding:"required"`
|
||||
SMTPPort int `json:"smtp_port" binding:"required"`
|
||||
SMTPUser string `json:"smtp_user" binding:"required"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from" binding:"required"`
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
Encryption string `json:"encryption"`
|
||||
Status string `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleCreateSystemEmailConfig(c *gin.Context) {
|
||||
var req CreateSystemEmailConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := "active"
|
||||
if req.Status != "" {
|
||||
status = req.Status
|
||||
}
|
||||
|
||||
encryption := "tls"
|
||||
if req.Encryption != "" {
|
||||
encryption = req.Encryption
|
||||
}
|
||||
|
||||
config := model.EmailConfig{
|
||||
Name: req.Name,
|
||||
SMTPHost: req.SMTPHost,
|
||||
SMTPPort: req.SMTPPort,
|
||||
SMTPUser: req.SMTPUser,
|
||||
SMTPPassword: req.SMTPPassword,
|
||||
SMTPFrom: req.SMTPFrom,
|
||||
SMTPFromName: req.SMTPFromName,
|
||||
Encryption: encryption,
|
||||
Status: status,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "创建失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "创建成功",
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetSystemEmailConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.EmailConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "邮箱配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateSystemEmailConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUser string `json:"smtp_user"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from"`
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
Encryption string `json:"encryption"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleUpdateSystemEmailConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.EmailConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "邮箱配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSystemEmailConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
config.Name = req.Name
|
||||
}
|
||||
if req.SMTPHost != "" {
|
||||
config.SMTPHost = req.SMTPHost
|
||||
}
|
||||
if req.SMTPPort != 0 {
|
||||
config.SMTPPort = req.SMTPPort
|
||||
}
|
||||
if req.SMTPUser != "" {
|
||||
config.SMTPUser = req.SMTPUser
|
||||
}
|
||||
if req.SMTPPassword != "" {
|
||||
config.SMTPPassword = req.SMTPPassword
|
||||
}
|
||||
if req.SMTPFrom != "" {
|
||||
config.SMTPFrom = req.SMTPFrom
|
||||
}
|
||||
config.SMTPFromName = req.SMTPFromName
|
||||
if req.Encryption != "" {
|
||||
config.Encryption = req.Encryption
|
||||
}
|
||||
config.Remark = req.Remark
|
||||
|
||||
if err := database.DB.Save(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteSystemEmailConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.EmailConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "邮箱配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "删除失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateSystemEmailConfigStatusRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func handleUpdateSystemEmailConfigStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.EmailConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "邮箱配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSystemEmailConfigStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
config.Status = req.Status
|
||||
if err := database.DB.Save(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
})
|
||||
}
|
||||
|
||||
type TestSystemEmailConfigRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
func handleTestSystemEmailConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.EmailConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "邮箱配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req TestSystemEmailConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "请输入有效的邮箱地址",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
emailService := service.NewEmailService()
|
||||
emailConfig := service.EmailConfig{
|
||||
Host: config.SMTPHost,
|
||||
Port: config.SMTPPort,
|
||||
User: config.SMTPUser,
|
||||
Password: config.SMTPPassword,
|
||||
FromEmail: config.SMTPFrom,
|
||||
FromName: config.SMTPFromName,
|
||||
UseSSL: config.Encryption == "ssl",
|
||||
}
|
||||
|
||||
if err := emailService.SendTestEmail(emailConfig, req.Email, config.Name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": fmt.Sprintf("发送失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "测试邮件已发送",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"verification-platform-backend/internal/database"
|
||||
"verification-platform-backend/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupStorageConfigRoutes(r *gin.RouterGroup) {
|
||||
storageConfigs := r.Group("/storage-configs")
|
||||
{
|
||||
storageConfigs.GET("", handleGetStorageConfigs)
|
||||
storageConfigs.POST("", handleCreateStorageConfig)
|
||||
storageConfigs.POST("/test", handleTestStorageConfigNew)
|
||||
storageConfigs.GET("/:id", handleGetStorageConfig)
|
||||
storageConfigs.PUT("/:id", handleUpdateStorageConfig)
|
||||
storageConfigs.DELETE("/:id", handleDeleteStorageConfig)
|
||||
storageConfigs.PUT("/:id/status", handleUpdateStorageConfigStatus)
|
||||
storageConfigs.PUT("/:id/default", handleSetDefaultStorageConfig)
|
||||
storageConfigs.POST("/:id/test", handleTestStorageConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetStorageConfigs(c *gin.Context) {
|
||||
var configs []model.StorageConfig
|
||||
database.DB.Order("id desc").Find(&configs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"storage_configs": configs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type CreateStorageConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Region string `json:"region"`
|
||||
PathPrefix string `json:"path_prefix"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleCreateStorageConfig(c *gin.Context) {
|
||||
var req CreateStorageConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.IsDefault {
|
||||
database.DB.Model(&model.StorageConfig{}).Where("is_default = ?", true).Update("is_default", false)
|
||||
}
|
||||
|
||||
config := model.StorageConfig{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Endpoint: req.Endpoint,
|
||||
Bucket: req.Bucket,
|
||||
AccessKey: req.AccessKey,
|
||||
SecretKey: req.SecretKey,
|
||||
Region: req.Region,
|
||||
PathPrefix: req.PathPrefix,
|
||||
IsDefault: req.IsDefault,
|
||||
Status: "active",
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "创建失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "创建成功",
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetStorageConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.StorageConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "存储配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateStorageConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Region string `json:"region"`
|
||||
PathPrefix string `json:"path_prefix"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Status string `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleUpdateStorageConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.StorageConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "存储配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateStorageConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.IsDefault && !config.IsDefault {
|
||||
database.DB.Model(&model.StorageConfig{}).Where("is_default = ?", true).Update("is_default", false)
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
config.Name = req.Name
|
||||
}
|
||||
if req.Type != "" {
|
||||
config.Type = req.Type
|
||||
}
|
||||
config.Endpoint = req.Endpoint
|
||||
config.Bucket = req.Bucket
|
||||
config.AccessKey = req.AccessKey
|
||||
config.SecretKey = req.SecretKey
|
||||
config.Region = req.Region
|
||||
config.PathPrefix = req.PathPrefix
|
||||
config.IsDefault = req.IsDefault
|
||||
if req.Status != "" {
|
||||
config.Status = req.Status
|
||||
}
|
||||
config.Remark = req.Remark
|
||||
|
||||
if err := database.DB.Save(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
"data": config,
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteStorageConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.StorageConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "存储配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if config.Type == "local" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "本地存储不能删除",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "删除失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateStorageConfigStatusRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func handleUpdateStorageConfigStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.StorageConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "存储配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateStorageConfigStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
config.Status = req.Status
|
||||
if err := database.DB.Save(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleSetDefaultStorageConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.StorageConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "存储配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(&model.StorageConfig{}).Where("is_default = ?", true).Update("is_default", false)
|
||||
|
||||
config.IsDefault = true
|
||||
if err := database.DB.Save(&config).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "设置失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "设置成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleTestStorageConfigNew(c *gin.Context) {
|
||||
var req CreateStorageConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "连接成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleTestStorageConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.StorageConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "存储配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "连接成功",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"verification-platform-backend/internal/database"
|
||||
"verification-platform-backend/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupSystemSettingsRoutes(r *gin.RouterGroup) {
|
||||
settings := r.Group("/system-settings")
|
||||
{
|
||||
settings.GET("", handleGetSystemSettings)
|
||||
settings.PUT("", handleUpdateSystemSettings)
|
||||
settings.POST("/upload", handleUploadSystemImage)
|
||||
settings.GET("/payment", handleGetPaymentSettings)
|
||||
settings.PUT("/payment", handleUpdatePaymentSettings)
|
||||
settings.GET("/email", handleGetEmailSettings)
|
||||
settings.PUT("/email", handleUpdateEmailSettings)
|
||||
}
|
||||
|
||||
paymentChannels := r.Group("/payment-channels")
|
||||
{
|
||||
paymentChannels.GET("", handleGetPaymentChannels)
|
||||
paymentChannels.POST("", handleCreatePaymentChannel)
|
||||
paymentChannels.GET("/:id", handleGetPaymentChannel)
|
||||
paymentChannels.PUT("/:id", handleUpdatePaymentChannel)
|
||||
paymentChannels.DELETE("/:id", handleDeletePaymentChannel)
|
||||
paymentChannels.PUT("/:id/status", handleUpdatePaymentChannelStatus)
|
||||
}
|
||||
}
|
||||
|
||||
type SystemSettingsResponse struct {
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteFavicon string `json:"site_favicon"`
|
||||
SiteFooter string `json:"site_footer"`
|
||||
|
||||
EnableCaptcha bool `json:"enable_captcha"`
|
||||
LoginFailLockCount int `json:"login_fail_lock_count"`
|
||||
LoginFailLockMinutes int `json:"login_fail_lock_minutes"`
|
||||
PasswordMinLength int `json:"password_min_length"`
|
||||
SessionTimeout int `json:"session_timeout"`
|
||||
|
||||
EnableBackup bool `json:"enable_backup"`
|
||||
BackupInterval int `json:"backup_interval"`
|
||||
BackupRetention int `json:"backup_retention"`
|
||||
BackupStorageType string `json:"backup_storage_type"`
|
||||
|
||||
EnableTicketSystem bool `json:"enable_ticket_system"`
|
||||
DefaultTheme string `json:"default_theme"`
|
||||
EnableMultiLang bool `json:"enable_multi_lang"`
|
||||
|
||||
EnableNotification bool `json:"enable_notification"`
|
||||
AdminNotifyEmail string `json:"admin_notify_email"`
|
||||
NotifyOnLogin bool `json:"notify_on_login"`
|
||||
NotifyOnRecharge bool `json:"notify_on_recharge"`
|
||||
NotifyOnTicket bool `json:"notify_on_ticket"`
|
||||
}
|
||||
|
||||
type PaymentSettingsResponse struct {
|
||||
EnabledPaymentTypes []string `json:"enabled_payment_types"`
|
||||
PaymentChannels []PaymentChannel `json:"payment_channels"`
|
||||
}
|
||||
|
||||
type PaymentChannel struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon"`
|
||||
Status string `json:"status"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
|
||||
type EmailSettingsResponse struct {
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUser string `json:"smtp_user"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func handleGetSystemSettings(c *gin.Context) {
|
||||
var settings []model.Setting
|
||||
database.DB.Where("category = ?", "system").Find(&settings)
|
||||
|
||||
response := SystemSettingsResponse{
|
||||
SiteName: "验证平台",
|
||||
SiteLogo: "",
|
||||
SiteFavicon: "",
|
||||
SiteFooter: "",
|
||||
EnableCaptcha: true,
|
||||
LoginFailLockCount: 5,
|
||||
LoginFailLockMinutes: 30,
|
||||
PasswordMinLength: 6,
|
||||
SessionTimeout: 24,
|
||||
EnableBackup: false,
|
||||
BackupInterval: 24,
|
||||
BackupRetention: 7,
|
||||
BackupStorageType: "local",
|
||||
EnableTicketSystem: true,
|
||||
DefaultTheme: "system",
|
||||
EnableMultiLang: false,
|
||||
EnableNotification: false,
|
||||
AdminNotifyEmail: "",
|
||||
NotifyOnLogin: false,
|
||||
NotifyOnRecharge: true,
|
||||
NotifyOnTicket: true,
|
||||
}
|
||||
|
||||
for _, s := range settings {
|
||||
switch s.Key {
|
||||
case "site_name":
|
||||
response.SiteName = s.Value
|
||||
case "site_logo":
|
||||
response.SiteLogo = s.Value
|
||||
case "site_favicon":
|
||||
response.SiteFavicon = s.Value
|
||||
case "site_footer":
|
||||
response.SiteFooter = s.Value
|
||||
case "enable_captcha":
|
||||
response.EnableCaptcha = s.Value == "true"
|
||||
case "login_fail_lock_count":
|
||||
response.LoginFailLockCount = parseSettingInt(s.Value, 5)
|
||||
case "login_fail_lock_minutes":
|
||||
response.LoginFailLockMinutes = parseSettingInt(s.Value, 30)
|
||||
case "password_min_length":
|
||||
response.PasswordMinLength = parseSettingInt(s.Value, 6)
|
||||
case "session_timeout":
|
||||
response.SessionTimeout = parseSettingInt(s.Value, 24)
|
||||
case "enable_backup":
|
||||
response.EnableBackup = s.Value == "true"
|
||||
case "backup_interval":
|
||||
response.BackupInterval = parseSettingInt(s.Value, 24)
|
||||
case "backup_retention":
|
||||
response.BackupRetention = parseSettingInt(s.Value, 7)
|
||||
case "backup_storage_type":
|
||||
response.BackupStorageType = s.Value
|
||||
case "enable_ticket_system":
|
||||
response.EnableTicketSystem = s.Value == "true"
|
||||
case "default_theme":
|
||||
response.DefaultTheme = s.Value
|
||||
case "enable_multi_lang":
|
||||
response.EnableMultiLang = s.Value == "true"
|
||||
case "enable_notification":
|
||||
response.EnableNotification = s.Value == "true"
|
||||
case "admin_notify_email":
|
||||
response.AdminNotifyEmail = s.Value
|
||||
case "notify_on_login":
|
||||
response.NotifyOnLogin = s.Value == "true"
|
||||
case "notify_on_recharge":
|
||||
response.NotifyOnRecharge = s.Value == "true"
|
||||
case "notify_on_ticket":
|
||||
response.NotifyOnTicket = s.Value == "true"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": response,
|
||||
})
|
||||
}
|
||||
|
||||
func parseSettingInt(value string, defaultValue int) int {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var result int
|
||||
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func handleUpdateSystemSettings(c *gin.Context) {
|
||||
var req SystemSettingsResponse
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settings := []struct {
|
||||
Key string
|
||||
Value string
|
||||
}{
|
||||
{"site_name", req.SiteName},
|
||||
{"site_logo", req.SiteLogo},
|
||||
{"site_favicon", req.SiteFavicon},
|
||||
{"site_footer", req.SiteFooter},
|
||||
{"enable_captcha", fmt.Sprintf("%v", req.EnableCaptcha)},
|
||||
{"login_fail_lock_count", fmt.Sprintf("%d", req.LoginFailLockCount)},
|
||||
{"login_fail_lock_minutes", fmt.Sprintf("%d", req.LoginFailLockMinutes)},
|
||||
{"password_min_length", fmt.Sprintf("%d", req.PasswordMinLength)},
|
||||
{"session_timeout", fmt.Sprintf("%d", req.SessionTimeout)},
|
||||
{"enable_backup", fmt.Sprintf("%v", req.EnableBackup)},
|
||||
{"backup_interval", fmt.Sprintf("%d", req.BackupInterval)},
|
||||
{"backup_retention", fmt.Sprintf("%d", req.BackupRetention)},
|
||||
{"backup_storage_type", req.BackupStorageType},
|
||||
{"enable_ticket_system", fmt.Sprintf("%v", req.EnableTicketSystem)},
|
||||
{"default_theme", req.DefaultTheme},
|
||||
{"enable_multi_lang", fmt.Sprintf("%v", req.EnableMultiLang)},
|
||||
{"enable_notification", fmt.Sprintf("%v", req.EnableNotification)},
|
||||
{"admin_notify_email", req.AdminNotifyEmail},
|
||||
{"notify_on_login", fmt.Sprintf("%v", req.NotifyOnLogin)},
|
||||
{"notify_on_recharge", fmt.Sprintf("%v", req.NotifyOnRecharge)},
|
||||
{"notify_on_ticket", fmt.Sprintf("%v", req.NotifyOnTicket)},
|
||||
}
|
||||
|
||||
for _, s := range settings {
|
||||
var setting model.Setting
|
||||
result := database.DB.Where("category = ? AND key = ?", "system", s.Key).First(&setting)
|
||||
if result.Error == nil {
|
||||
setting.Value = s.Value
|
||||
database.DB.Save(&setting)
|
||||
} else {
|
||||
setting = model.Setting{
|
||||
Category: "system",
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
}
|
||||
database.DB.Create(&setting)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "保存成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleUploadSystemImage(c *gin.Context) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "请选择要上传的文件",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
uploadType := c.PostForm("type")
|
||||
if uploadType != "logo" && uploadType != "favicon" {
|
||||
uploadType = "logo"
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
allowedExts := map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
".svg": true,
|
||||
".ico": true,
|
||||
}
|
||||
|
||||
if !allowedExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "不支持的文件格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 2 * 1024 * 1024
|
||||
if header.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "文件大小不能超过2MB",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uploadDir := "uploads/system"
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
log.Printf("创建上传目录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "创建上传目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%d%s", uploadType, time.Now().UnixNano(), ext)
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
log.Printf("创建文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "创建文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
log.Printf("保存文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "保存文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
imageURL := "/uploads/system/" + filename
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"url": imageURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetPaymentSettings(c *gin.Context) {
|
||||
var channels []model.PaymentChannel
|
||||
database.DB.Order("sort asc").Find(&channels)
|
||||
|
||||
var paymentChannels []PaymentChannel
|
||||
for _, ch := range channels {
|
||||
paymentChannels = append(paymentChannels, PaymentChannel{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Type: ch.Type,
|
||||
Icon: ch.Icon,
|
||||
Status: ch.Status,
|
||||
Sort: ch.Sort,
|
||||
})
|
||||
}
|
||||
|
||||
var enabledTypes []string
|
||||
var setting model.Setting
|
||||
if err := database.DB.Where("category = ? AND key = ?", "payment", "enabled_types").First(&setting).Error; err == nil {
|
||||
if setting.Value != "" {
|
||||
enabledTypes = []string{setting.Value}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"enabled_payment_types": enabledTypes,
|
||||
"payment_channels": paymentChannels,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdatePaymentSettings(c *gin.Context) {
|
||||
var req struct {
|
||||
EnabledPaymentTypes []string `json:"enabled_payment_types"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
enabledTypes := ""
|
||||
if len(req.EnabledPaymentTypes) > 0 {
|
||||
enabledTypes = req.EnabledPaymentTypes[0]
|
||||
}
|
||||
|
||||
var setting model.Setting
|
||||
result := database.DB.Where("category = ? AND key = ?", "payment", "enabled_types").First(&setting)
|
||||
if result.Error == nil {
|
||||
setting.Value = enabledTypes
|
||||
database.DB.Save(&setting)
|
||||
} else {
|
||||
setting = model.Setting{
|
||||
Category: "payment",
|
||||
Key: "enabled_types",
|
||||
Value: enabledTypes,
|
||||
}
|
||||
database.DB.Create(&setting)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "保存成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetEmailSettings(c *gin.Context) {
|
||||
var settings []model.Setting
|
||||
database.DB.Where("category = ?", "email").Find(&settings)
|
||||
|
||||
response := EmailSettingsResponse{
|
||||
SMTPHost: "",
|
||||
SMTPPort: 587,
|
||||
SMTPUser: "",
|
||||
SMTPPassword: "",
|
||||
SMTPFrom: "",
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
for _, s := range settings {
|
||||
switch s.Key {
|
||||
case "smtp_host":
|
||||
response.SMTPHost = s.Value
|
||||
case "smtp_port":
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(s.Value, "%d", &port); err == nil {
|
||||
response.SMTPPort = port
|
||||
}
|
||||
case "smtp_user":
|
||||
response.SMTPUser = s.Value
|
||||
case "smtp_password":
|
||||
response.SMTPPassword = s.Value
|
||||
case "smtp_from":
|
||||
response.SMTPFrom = s.Value
|
||||
case "enabled":
|
||||
response.Enabled = s.Value == "true"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": response,
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdateEmailSettings(c *gin.Context) {
|
||||
var req EmailSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settings := []struct {
|
||||
Key string
|
||||
Value string
|
||||
}{
|
||||
{"smtp_host", req.SMTPHost},
|
||||
{"smtp_port", fmt.Sprintf("%d", req.SMTPPort)},
|
||||
{"smtp_user", req.SMTPUser},
|
||||
{"smtp_password", req.SMTPPassword},
|
||||
{"smtp_from", req.SMTPFrom},
|
||||
{"enabled", fmt.Sprintf("%v", req.Enabled)},
|
||||
}
|
||||
|
||||
for _, s := range settings {
|
||||
var setting model.Setting
|
||||
result := database.DB.Where("category = ? AND key = ?", "email", s.Key).First(&setting)
|
||||
if result.Error == nil {
|
||||
setting.Value = s.Value
|
||||
database.DB.Save(&setting)
|
||||
} else {
|
||||
setting = model.Setting{
|
||||
Category: "email",
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
}
|
||||
database.DB.Create(&setting)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "保存成功",
|
||||
})
|
||||
}
|
||||
|
||||
type EmailSettingsRequest struct {
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUser string `json:"smtp_user"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func handleGetPaymentChannels(c *gin.Context) {
|
||||
var channels []model.PaymentChannel
|
||||
database.DB.Order("sort asc").Find(&channels)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": channels,
|
||||
})
|
||||
}
|
||||
|
||||
type CreatePaymentChannelRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Icon string `json:"icon"`
|
||||
Config string `json:"config"`
|
||||
Sort int `json:"sort"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleCreatePaymentChannel(c *gin.Context) {
|
||||
var req CreatePaymentChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
channel := model.PaymentChannel{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Icon: req.Icon,
|
||||
Config: req.Config,
|
||||
Sort: req.Sort,
|
||||
Status: "active",
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "创建失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "创建成功",
|
||||
"data": channel,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetPaymentChannel(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var channel model.PaymentChannel
|
||||
if err := database.DB.First(&channel, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "支付渠道不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": channel,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdatePaymentChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon"`
|
||||
Config string `json:"config"`
|
||||
Sort int `json:"sort"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleUpdatePaymentChannel(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var channel model.PaymentChannel
|
||||
if err := database.DB.First(&channel, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "支付渠道不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePaymentChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
channel.Name = req.Name
|
||||
}
|
||||
if req.Type != "" {
|
||||
channel.Type = req.Type
|
||||
}
|
||||
channel.Icon = req.Icon
|
||||
channel.Config = req.Config
|
||||
channel.Sort = req.Sort
|
||||
channel.Remark = req.Remark
|
||||
|
||||
if err := database.DB.Save(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
"data": channel,
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeletePaymentChannel(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var channel model.PaymentChannel
|
||||
if err := database.DB.First(&channel, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "支付渠道不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "删除失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
type UpdatePaymentChannelStatusRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func handleUpdatePaymentChannelStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var channel model.PaymentChannel
|
||||
if err := database.DB.First(&channel, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "支付渠道不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePaymentChannelStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的请求数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
channel.Status = req.Status
|
||||
if err := database.DB.Save(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
})
|
||||
}
|
||||
@@ -41,14 +41,14 @@ func (s *EmailService) SendEmail(config EmailConfig, to, subject, body string) e
|
||||
|
||||
auth := smtp.PlainAuth("", config.User, config.Password, config.Host)
|
||||
|
||||
if config.UseSSL || config.Port == 465 || config.Port == 587 {
|
||||
return s.sendWithTLS(addr, auth, from, []string{to}, []byte(msg))
|
||||
if config.Port == 465 || config.UseSSL {
|
||||
return s.sendWithImplicitTLS(addr, auth, from, []string{to}, []byte(msg))
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
|
||||
return s.sendWithSTARTTLS(addr, auth, from, []string{to}, []byte(msg))
|
||||
}
|
||||
|
||||
func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
func (s *EmailService) sendWithImplicitTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
host := strings.Split(addr, ":")[0]
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
@@ -99,6 +99,62 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (s *EmailService) sendWithSTARTTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
host := strings.Split(addr, ":")[0]
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接SMTP服务器失败: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("localhost"); err != nil {
|
||||
return fmt.Errorf("HELO命令失败: %v", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: host,
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("STARTTLS失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP认证失败: %v", err)
|
||||
}
|
||||
|
||||
if err = client.Mail(from); err != nil {
|
||||
return fmt.Errorf("设置发件人失败: %v", err)
|
||||
}
|
||||
|
||||
for _, addr := range to {
|
||||
if err = client.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("设置收件人失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("准备邮件数据失败: %v", err)
|
||||
}
|
||||
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入邮件内容失败: %v", err)
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭邮件写入失败: %v", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (s *EmailService) buildMessage(fromName, from, to, subject, body string) string {
|
||||
msg := ""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { Boxes, Code, DollarSign, FileLock, Gauge, GitBranch, Hash, Key, Megaphone, MessageSquare, Network, Plug, ScrollText, Shield, Users, Variable } from 'lucide-vue-next'
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { Boxes, Code, CreditCard, DollarSign, FileLock, Gauge, GitBranch, HardDrive, Hash, Key, Mail, Megaphone, MessageSquare, Network, Plug, ScrollText, Settings, Shield, Users, Variable } from 'lucide-vue-next'
|
||||
import { onMounted, onUnmounted, reactive } from 'vue'
|
||||
|
||||
import NavTeam from '@/components/app-sidebar/nav-team.vue'
|
||||
import TeamSwitcher from '@/components/app-sidebar/team-switcher.vue'
|
||||
@@ -13,6 +13,49 @@ const user = reactive({
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
const siteSettings = reactive({
|
||||
name: '管理后台',
|
||||
logo: '',
|
||||
})
|
||||
|
||||
const teams = reactive([
|
||||
{
|
||||
name: '管理后台',
|
||||
logo: Code,
|
||||
plan: 'Admin',
|
||||
},
|
||||
])
|
||||
|
||||
function loadSystemSettings() {
|
||||
const storedSettings = localStorage.getItem('systemSettings')
|
||||
if (storedSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedSettings)
|
||||
if (parsed.site_name) {
|
||||
siteSettings.name = parsed.site_name
|
||||
teams[0].name = parsed.site_name
|
||||
}
|
||||
if (parsed.site_logo) {
|
||||
siteSettings.logo = parsed.site_logo
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse systemSettings from localStorage', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsChange(event: CustomEvent) {
|
||||
const settings = event.detail
|
||||
if (settings.site_name) {
|
||||
siteSettings.name = settings.site_name
|
||||
teams[0].name = settings.site_name
|
||||
}
|
||||
if (settings.site_logo) {
|
||||
siteSettings.logo = settings.site_logo
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const storedUser = localStorage.getItem('user')
|
||||
if (storedUser) {
|
||||
@@ -27,15 +70,14 @@ onMounted(() => {
|
||||
console.error('Failed to parse user from localStorage', e)
|
||||
}
|
||||
}
|
||||
|
||||
loadSystemSettings()
|
||||
window.addEventListener('system-settings-changed', handleSettingsChange as EventListener)
|
||||
})
|
||||
|
||||
const teams = [
|
||||
{
|
||||
name: '管理后台',
|
||||
logo: Code,
|
||||
plan: 'Admin',
|
||||
},
|
||||
]
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('system-settings-changed', handleSettingsChange as EventListener)
|
||||
})
|
||||
|
||||
const navMain = [
|
||||
{
|
||||
@@ -133,6 +175,31 @@ const navMain = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
items: [
|
||||
{
|
||||
title: '系统设置',
|
||||
url: '/admin/system-settings',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: '支付渠道',
|
||||
url: '/admin/payment-channels',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: '邮箱配置',
|
||||
url: '/admin/email-settings',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
title: '存储管理',
|
||||
url: '/admin/storage-configs',
|
||||
icon: HardDrive,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AdminSidebar from '@/components/admin-sidebar/index.vue'
|
||||
import LanguageChange from '@/components/language-change.vue'
|
||||
import ThemePopover from '@/components/custom-theme/theme-popover.vue'
|
||||
import ToggleTheme from '@/components/toggle-theme.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -34,6 +35,10 @@ const breadcrumbs = computed(() => {
|
||||
'risk-control': '风控管理',
|
||||
extension: '扩展配置',
|
||||
profile: '个人中心',
|
||||
'system-settings': '系统设置',
|
||||
'payment-channels': '支付渠道',
|
||||
'email-settings': '邮箱配置',
|
||||
'storage-configs': '存储管理',
|
||||
settings: '基本设置',
|
||||
security: '安全设置',
|
||||
email: '邮箱设置',
|
||||
@@ -65,6 +70,33 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
return crumbs
|
||||
})
|
||||
|
||||
async function loadSystemSettings() {
|
||||
const storedSettings = localStorage.getItem('systemSettings')
|
||||
if (storedSettings) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.get('/dev/system-settings')
|
||||
if (data) {
|
||||
const settings = {
|
||||
site_name: data.site_name || '',
|
||||
site_logo: data.site_logo || '',
|
||||
site_favicon: data.site_favicon || '',
|
||||
}
|
||||
localStorage.setItem('systemSettings', JSON.stringify(settings))
|
||||
window.dispatchEvent(new CustomEvent('system-settings-changed', { detail: settings }))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载系统设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSystemSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
smtp_from: '',
|
||||
smtp_from_name: '',
|
||||
encryption: 'tls' as 'none' | 'ssl' | 'tls',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const encryptionOptions = [
|
||||
{ value: 'none', label: '无加密', desc: '不使用加密连接' },
|
||||
{ value: 'ssl', label: 'SSL', desc: '使用SSL加密(端口465)' },
|
||||
{ value: 'tls', label: 'TLS', desc: '使用TLS加密(端口587)' },
|
||||
]
|
||||
|
||||
const selectedEncryption = computed(() => {
|
||||
return encryptionOptions.find(e => e.value === formData.value.encryption)
|
||||
})
|
||||
|
||||
async function fetchEmailConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/email-configs/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name,
|
||||
smtp_host: data.smtp_host,
|
||||
smtp_port: data.smtp_port,
|
||||
smtp_user: data.smtp_user,
|
||||
smtp_password: data.smtp_password || '',
|
||||
smtp_from: data.smtp_from,
|
||||
smtp_from_name: data.smtp_from_name || '',
|
||||
encryption: data.encryption || 'tls',
|
||||
status: data.status,
|
||||
remark: data.remark || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('加载邮箱配置失败:', error)
|
||||
toast.error(error.message || '加载失败')
|
||||
router.push('/admin/email-settings')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入配置名称')
|
||||
return
|
||||
}
|
||||
if (!formData.value.smtp_host) {
|
||||
toast.error('请输入SMTP服务器地址')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/email-configs/${route.params.id}`, formData.value)
|
||||
toast.success('更新成功')
|
||||
router.push('/admin/email-settings')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新邮箱配置失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEmailConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑邮箱配置"
|
||||
description="修改SMTP邮箱服务配置"
|
||||
:breadcrumbs="[
|
||||
{ title: '邮箱配置', href: '/admin/email-settings' },
|
||||
{ title: '编辑配置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:mail" class="size-5" />
|
||||
基本配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>修改SMTP服务器的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
配置名称 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="如:主邮箱、通知邮箱"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_host">
|
||||
SMTP服务器 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_host"
|
||||
v-model="formData.smtp_host"
|
||||
placeholder="smtp.example.com"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_port">端口</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_port"
|
||||
v-model.number="formData.smtp_port"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_user">
|
||||
用户名 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_user"
|
||||
v-model="formData.smtp_user"
|
||||
placeholder="user@example.com"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_password">密码</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_password"
|
||||
v-model="formData.smtp_password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from">
|
||||
发件人地址 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_from"
|
||||
v-model="formData.smtp_from"
|
||||
placeholder="noreply@example.com"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_name">发件人名称</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_from_name"
|
||||
v-model="formData.smtp_from_name"
|
||||
placeholder="系统通知"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>加密方式</UiLabel>
|
||||
<UiSelect v-model="formData.encryption" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择加密方式" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="enc in encryptionOptions" :key="enc.value" :value="enc.value">
|
||||
<div class="flex flex-col">
|
||||
<span>{{ enc.label }}</span>
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">{{ selectedEncryption?.desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="remark">备注</UiLabel>
|
||||
<UiInput
|
||||
id="remark"
|
||||
v-model="formData.remark"
|
||||
placeholder="备注信息(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
启用状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后该邮箱配置可用于发送邮件
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:checked="formData.status === 'active'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">配置名称</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">SMTP服务器</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.smtp_host || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">端口</span>
|
||||
<span>{{ formData.smtp_port }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">用户名</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.smtp_user || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">发件人</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.smtp_from_name ? `${formData.smtp_from_name} <${formData.smtp_from}>` : formData.smtp_from || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">加密方式</span>
|
||||
<span>{{ selectedEncryption?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.name || !formData.smtp_host"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
|
||||
保存修改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { EmailConfig } from '@/pages/admin/email-settings/data/schema'
|
||||
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
interface ColumnOptions {
|
||||
onToggleStatus: (row: EmailConfig) => void
|
||||
onEdit: (row: EmailConfig) => void
|
||||
onDelete: (row: EmailConfig) => void
|
||||
onTest: (row: EmailConfig) => void
|
||||
}
|
||||
|
||||
export function getColumns(options: ColumnOptions, t: (key: string) => string): ColumnDef<EmailConfig>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => '配置名称',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(Icon, { icon: 'lucide:mail', class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'font-medium' }, row.original.name),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'smtp_host',
|
||||
header: () => 'SMTP服务器',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-sm' }, [
|
||||
h('div', {}, row.original.smtp_host),
|
||||
h('div', { class: 'text-muted-foreground text-xs' }, `端口: ${row.original.smtp_port}`),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'smtp_user',
|
||||
header: () => '用户名',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, row.original.smtp_user)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'smtp_from',
|
||||
header: () => '发件人',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm' }, row.original.smtp_from)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => '状态',
|
||||
cell: ({ row }) => {
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '启用',
|
||||
inactive: '禁用',
|
||||
}
|
||||
const statusClasses: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 cursor-pointer',
|
||||
inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 cursor-pointer',
|
||||
}
|
||||
return h(Badge, {
|
||||
class: statusClasses[row.original.status],
|
||||
onClick: () => options.onToggleStatus(row.original),
|
||||
}, () => statusLabels[row.original.status])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => '创建时间',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, new Date(row.original.created_at).toLocaleString('zh-CN'))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => '操作',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center justify-end gap-1' }, [
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
title: '测试发送',
|
||||
onClick: () => options.onTest(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:send', class: 'h-4 w-4 text-blue-500' }),
|
||||
]),
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onEdit(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:edit', class: 'h-4 w-4' }),
|
||||
]),
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onDelete(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:trash-2', class: 'h-4 w-4 text-destructive' }),
|
||||
]),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { EmailConfig } from '@/pages/admin/email-settings/data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/email-settings/components/columns'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<EmailConfig>, 'columns'> & {
|
||||
onToggleStatus: (row: EmailConfig) => void
|
||||
onEdit: (row: EmailConfig) => void
|
||||
onDelete: (row: EmailConfig) => void
|
||||
onTest: (row: EmailConfig) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const columns = computed<ColumnDef<EmailConfig>[]>(() => [
|
||||
SelectColumn as ColumnDef<EmailConfig>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
onTest: props.onTest,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<EmailConfig>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: '选择',
|
||||
name: '配置名称',
|
||||
smtp_host: 'SMTP服务器',
|
||||
smtp_user: '用户名',
|
||||
smtp_from: '发件人',
|
||||
status: '状态',
|
||||
created_at: '创建时间',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ data.length }} 个邮箱配置
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
smtp_from: '',
|
||||
smtp_from_name: '',
|
||||
encryption: 'tls' as 'none' | 'ssl' | 'tls',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const encryptionOptions = [
|
||||
{ value: 'none', label: '无加密', desc: '不使用加密连接' },
|
||||
{ value: 'ssl', label: 'SSL', desc: '使用SSL加密(端口465)' },
|
||||
{ value: 'tls', label: 'TLS', desc: '使用TLS加密(端口587)' },
|
||||
]
|
||||
|
||||
const selectedEncryption = computed(() => {
|
||||
return encryptionOptions.find(e => e.value === formData.value.encryption)
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入配置名称')
|
||||
return
|
||||
}
|
||||
if (!formData.value.smtp_host) {
|
||||
toast.error('请输入SMTP服务器地址')
|
||||
return
|
||||
}
|
||||
if (!formData.value.smtp_user) {
|
||||
toast.error('请输入SMTP用户名')
|
||||
return
|
||||
}
|
||||
if (!formData.value.smtp_from) {
|
||||
toast.error('请输入发件人地址')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/email-configs', formData.value)
|
||||
toast.success('创建成功')
|
||||
router.push('/admin/email-settings')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建邮箱配置失败:', error)
|
||||
toast.error(error.message || '创建失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="添加邮箱配置"
|
||||
description="配置新的SMTP邮箱服务"
|
||||
:breadcrumbs="[
|
||||
{ title: '邮箱配置', href: '/admin/email-settings' },
|
||||
{ title: '添加配置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:mail" class="size-5" />
|
||||
基本配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>填写SMTP服务器的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
配置名称 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="如:主邮箱、通知邮箱"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_host">
|
||||
SMTP服务器 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_host"
|
||||
v-model="formData.smtp_host"
|
||||
placeholder="smtp.example.com"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_port">端口</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_port"
|
||||
v-model.number="formData.smtp_port"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_user">
|
||||
用户名 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_user"
|
||||
v-model="formData.smtp_user"
|
||||
placeholder="user@example.com"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_password">密码</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_password"
|
||||
v-model="formData.smtp_password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from">
|
||||
发件人地址 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_from"
|
||||
v-model="formData.smtp_from"
|
||||
placeholder="noreply@example.com"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_name">发件人名称</UiLabel>
|
||||
<UiInput
|
||||
id="smtp_from_name"
|
||||
v-model="formData.smtp_from_name"
|
||||
placeholder="系统通知"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>加密方式</UiLabel>
|
||||
<UiSelect v-model="formData.encryption" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择加密方式" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="enc in encryptionOptions" :key="enc.value" :value="enc.value">
|
||||
<div class="flex flex-col">
|
||||
<span>{{ enc.label }}</span>
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">{{ selectedEncryption?.desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="remark">备注</UiLabel>
|
||||
<UiInput
|
||||
id="remark"
|
||||
v-model="formData.remark"
|
||||
placeholder="备注信息(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
启用状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后该邮箱配置可用于发送邮件
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:checked="formData.status === 'active'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">配置名称</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">SMTP服务器</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.smtp_host || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">端口</span>
|
||||
<span>{{ formData.smtp_port }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">用户名</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.smtp_user || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">发件人</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.smtp_from_name ? `${formData.smtp_from_name} <${formData.smtp_from}>` : formData.smtp_from || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">加密方式</span>
|
||||
<span>{{ selectedEncryption?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.name || !formData.smtp_host"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
添加配置
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const emailConfigStatusSchema = z.enum(['active', 'inactive'])
|
||||
|
||||
export const emailConfigSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
smtp_host: z.string(),
|
||||
smtp_port: z.number(),
|
||||
smtp_user: z.string(),
|
||||
smtp_password: z.string(),
|
||||
smtp_from: z.string(),
|
||||
encryption: z.enum(['none', 'ssl', 'tls']).optional(),
|
||||
status: emailConfigStatusSchema,
|
||||
remark: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export type EmailConfig = z.infer<typeof emailConfigSchema>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { EmailConfig } from '@/pages/admin/email-settings/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/email-settings/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const emailConfigs = ref<EmailConfig[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<EmailConfig | null>(null)
|
||||
|
||||
const testDialogOpen = ref(false)
|
||||
const testTarget = ref<EmailConfig | null>(null)
|
||||
const testEmail = ref('')
|
||||
const testSending = ref(false)
|
||||
|
||||
const activeCount = computed(() => emailConfigs.value.filter(c => c.status === 'active').length)
|
||||
const inactiveCount = computed(() => emailConfigs.value.filter(c => c.status === 'inactive').length)
|
||||
|
||||
async function fetchEmailConfigs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ email_configs: EmailConfig[] }>('/dev/email-configs')
|
||||
emailConfigs.value = data?.email_configs || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载邮箱配置失败:', error)
|
||||
emailConfigs.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/email-settings/create')
|
||||
}
|
||||
|
||||
function goToEdit(config: EmailConfig) {
|
||||
router.push(`/admin/email-settings/${config.id}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(config: EmailConfig) {
|
||||
const newStatus = config.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await api.put(`/dev/email-configs/${config.id}/status`, { status: newStatus })
|
||||
toast.success('状态更新成功')
|
||||
fetchEmailConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新状态失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(config: EmailConfig) {
|
||||
deleteTarget.value = config
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/email-configs/${deleteTarget.value.id}`)
|
||||
toast.success('删除成功')
|
||||
fetchEmailConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除邮箱配置失败:', error)
|
||||
toast.error(error.message || '删除失败')
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openTestDialog(config: EmailConfig) {
|
||||
testTarget.value = config
|
||||
testEmail.value = ''
|
||||
testDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!testTarget.value || !testEmail.value)
|
||||
return
|
||||
|
||||
testSending.value = true
|
||||
try {
|
||||
await api.post(`/dev/email-configs/${testTarget.value.id}/test`, { email: testEmail.value })
|
||||
toast.success('测试邮件已发送')
|
||||
testDialogOpen.value = false
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('发送测试邮件失败:', error)
|
||||
toast.error(error.message || '发送失败')
|
||||
}
|
||||
finally {
|
||||
testSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEmailConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="邮箱配置"
|
||||
description="管理系统的SMTP邮箱服务配置"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
添加配置
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
总配置数
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:mail" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ emailConfigs.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
已启用
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
已禁用
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ inactiveCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="emailConfigs"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-edit="goToEdit"
|
||||
:on-delete="confirmDelete"
|
||||
:on-test="openTestDialog"
|
||||
@refresh="fetchEmailConfigs"
|
||||
/>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
删除邮箱配置
|
||||
</template>
|
||||
<template #description>
|
||||
确定要删除邮箱配置"{{ deleteTarget?.name }}"吗?此操作不可撤销。
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<UiDialog v-model:open="testDialogOpen">
|
||||
<UiDialogContent class="sm:max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>发送测试邮件</UiDialogTitle>
|
||||
<UiDialogDescription>
|
||||
将使用"{{ testTarget?.name }}"配置发送测试邮件
|
||||
</UiDialogDescription>
|
||||
</UiDialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="test-email">收件人邮箱</UiLabel>
|
||||
<UiInput
|
||||
id="test-email"
|
||||
v-model="testEmail"
|
||||
type="email"
|
||||
placeholder="请输入收件人邮箱地址"
|
||||
:disabled="testSending"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="testDialogOpen = false">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="!testEmail || testSending" @click="handleTest">
|
||||
<Icon v-if="testSending" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:send" class="mr-2 h-4 w-4" />
|
||||
发送
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
type: 'alipay' as 'alipay' | 'wechat' | 'stripe' | 'paypal' | 'bepusdt' | 'epay' | 'other',
|
||||
icon: '',
|
||||
config: '',
|
||||
sort: 0,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'alipay', label: '支付宝', icon: 'ri:alipay-fill', color: 'text-blue-500' },
|
||||
{ value: 'wechat', label: '微信支付', icon: 'ri:wechat-pay-fill', color: 'text-green-500' },
|
||||
{ value: 'stripe', label: 'Stripe', icon: 'logos:stripe', color: '' },
|
||||
{ value: 'paypal', label: 'PayPal', icon: 'logos:paypal', color: '' },
|
||||
{ value: 'bepusdt', label: 'BEPUSDT', icon: 'cryptocurrency:usdt', color: 'text-green-500' },
|
||||
{ value: 'epay', label: '易支付', icon: 'lucide:wallet', color: 'text-orange-500' },
|
||||
{ value: 'other', label: '其他', icon: 'lucide:credit-card', color: 'text-gray-500' },
|
||||
]
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
async function fetchPaymentChannel() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/payment-channels/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
icon: data.icon || '',
|
||||
config: data.config || '',
|
||||
sort: data.sort || 0,
|
||||
status: data.status,
|
||||
remark: data.remark || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('加载支付渠道失败:', error)
|
||||
toast.error(error.message || '加载失败')
|
||||
router.push('/admin/payment-channels')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入渠道名称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/payment-channels/${route.params.id}`, formData.value)
|
||||
toast.success('更新成功')
|
||||
router.push('/admin/payment-channels')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新支付渠道失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaymentChannel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑支付渠道"
|
||||
description="修改支付渠道配置"
|
||||
:breadcrumbs="[
|
||||
{ title: '支付渠道', href: '/admin/payment-channels' },
|
||||
{ title: '编辑渠道' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:credit-card" class="size-5" />
|
||||
渠道配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>修改支付渠道的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
渠道名称 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="输入渠道名称"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>渠道类型 <span class="text-destructive">*</span></UiLabel>
|
||||
<UiSelect v-model="formData.type" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择渠道类型" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in typeOptions" :key="type.value" :value="type.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="type.icon" :class="['h-4 w-4', type.color]" />
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="icon">图标URL</UiLabel>
|
||||
<UiInput
|
||||
id="icon"
|
||||
v-model="formData.icon"
|
||||
placeholder="输入图标URL(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="sort">排序</UiLabel>
|
||||
<UiInput
|
||||
id="sort"
|
||||
v-model.number="formData.sort"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="config">配置信息</UiLabel>
|
||||
<textarea
|
||||
id="config"
|
||||
v-model="formData.config"
|
||||
class="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="JSON格式的配置信息(如AppID、密钥等)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="remark">备注</UiLabel>
|
||||
<UiInput
|
||||
id="remark"
|
||||
v-model="formData.remark"
|
||||
placeholder="备注信息(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
启用状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后该支付渠道将对用户可见
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:checked="formData.status === 'active'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">渠道名称</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">渠道类型</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon :icon="selectedType?.icon || 'lucide:credit-card'" :class="['h-4 w-4', selectedType?.color]" />
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">排序</span>
|
||||
<span>{{ formData.sort }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
|
||||
保存修改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { PaymentChannel } from '@/pages/admin/payment-channels/data/schema'
|
||||
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
interface ColumnOptions {
|
||||
onToggleStatus: (row: PaymentChannel) => void
|
||||
onEdit: (row: PaymentChannel) => void
|
||||
onDelete: (row: PaymentChannel) => void
|
||||
}
|
||||
|
||||
export function getColumns(options: ColumnOptions, t: (key: string) => string): ColumnDef<PaymentChannel>[] {
|
||||
const typeLabels: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
stripe: 'Stripe',
|
||||
paypal: 'PayPal',
|
||||
bepusdt: 'BEPUSDT',
|
||||
epay: '易支付',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
alipay: 'ri:alipay-fill',
|
||||
wechat: 'ri:wechat-pay-fill',
|
||||
stripe: 'logos:stripe',
|
||||
paypal: 'logos:paypal',
|
||||
bepusdt: 'cryptocurrency:usdt',
|
||||
epay: 'lucide:wallet',
|
||||
other: 'lucide:credit-card',
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => '渠道名称',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
row.original.icon
|
||||
? h('img', { src: row.original.icon, class: 'h-5 w-5 rounded', alt: '' })
|
||||
: h(Icon, { icon: typeIcons[row.original.type] || 'lucide:credit-card', class: 'h-5 w-5' }),
|
||||
h('span', { class: 'font-medium' }, row.original.name),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => '渠道类型',
|
||||
cell: ({ row }) => {
|
||||
return h(Badge, { variant: 'secondary' }, () => typeLabels[row.original.type] || row.original.type)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'sort',
|
||||
header: () => '排序',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-muted-foreground' }, row.original.sort)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => '状态',
|
||||
cell: ({ row }) => {
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '启用',
|
||||
inactive: '禁用',
|
||||
}
|
||||
const statusClasses: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 cursor-pointer',
|
||||
inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 cursor-pointer',
|
||||
}
|
||||
return h(Badge, {
|
||||
class: statusClasses[row.original.status],
|
||||
onClick: () => options.onToggleStatus(row.original),
|
||||
}, () => statusLabels[row.original.status])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'remark',
|
||||
header: () => '备注',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground truncate max-w-[200px] block' }, row.original.remark || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => '创建时间',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, new Date(row.original.created_at).toLocaleString('zh-CN'))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => '操作',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center justify-end gap-1' }, [
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onEdit(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:edit', class: 'h-4 w-4' }),
|
||||
]),
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onDelete(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:trash-2', class: 'h-4 w-4 text-destructive' }),
|
||||
]),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { PaymentChannel } from '@/pages/admin/payment-channels/data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/payment-channels/components/columns'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<PaymentChannel>, 'columns'> & {
|
||||
onToggleStatus: (row: PaymentChannel) => void
|
||||
onEdit: (row: PaymentChannel) => void
|
||||
onDelete: (row: PaymentChannel) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const columns = computed<ColumnDef<PaymentChannel>[]>(() => [
|
||||
SelectColumn as ColumnDef<PaymentChannel>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<PaymentChannel>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: '选择',
|
||||
name: '渠道名称',
|
||||
type: '渠道类型',
|
||||
sort: '排序',
|
||||
status: '状态',
|
||||
remark: '备注',
|
||||
created_at: '创建时间',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ data.length }} 个支付渠道
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
type: 'alipay' as 'alipay' | 'wechat' | 'stripe' | 'paypal' | 'bepusdt' | 'epay' | 'other',
|
||||
icon: '',
|
||||
config: '',
|
||||
sort: 0,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'alipay', label: '支付宝', icon: 'ri:alipay-fill', color: 'text-blue-500' },
|
||||
{ value: 'wechat', label: '微信支付', icon: 'ri:wechat-pay-fill', color: 'text-green-500' },
|
||||
{ value: 'stripe', label: 'Stripe', icon: 'logos:stripe', color: '' },
|
||||
{ value: 'paypal', label: 'PayPal', icon: 'logos:paypal', color: '' },
|
||||
{ value: 'bepusdt', label: 'BEPUSDT', icon: 'cryptocurrency:usdt', color: 'text-green-500' },
|
||||
{ value: 'epay', label: '易支付', icon: 'lucide:wallet', color: 'text-orange-500' },
|
||||
{ value: 'other', label: '其他', icon: 'lucide:credit-card', color: 'text-gray-500' },
|
||||
]
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入渠道名称')
|
||||
return
|
||||
}
|
||||
if (!formData.value.type) {
|
||||
toast.error('请选择渠道类型')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/payment-channels', formData.value)
|
||||
toast.success('创建成功')
|
||||
router.push('/admin/payment-channels')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建支付渠道失败:', error)
|
||||
toast.error(error.message || '创建失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="添加支付渠道"
|
||||
description="配置新的支付渠道"
|
||||
:breadcrumbs="[
|
||||
{ title: '支付渠道', href: '/admin/payment-channels' },
|
||||
{ title: '添加渠道' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:credit-card" class="size-5" />
|
||||
渠道配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>填写支付渠道的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
渠道名称 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="输入渠道名称"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>渠道类型 <span class="text-destructive">*</span></UiLabel>
|
||||
<UiSelect v-model="formData.type" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择渠道类型" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in typeOptions" :key="type.value" :value="type.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="type.icon" :class="['h-4 w-4', type.color]" />
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="icon">图标URL</UiLabel>
|
||||
<UiInput
|
||||
id="icon"
|
||||
v-model="formData.icon"
|
||||
placeholder="输入图标URL(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="sort">排序</UiLabel>
|
||||
<UiInput
|
||||
id="sort"
|
||||
v-model.number="formData.sort"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="config">配置信息</UiLabel>
|
||||
<textarea
|
||||
id="config"
|
||||
v-model="formData.config"
|
||||
class="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="JSON格式的配置信息(如AppID、密钥等)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="remark">备注</UiLabel>
|
||||
<UiInput
|
||||
id="remark"
|
||||
v-model="formData.remark"
|
||||
placeholder="备注信息(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
启用状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后该支付渠道将对用户可见
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:checked="formData.status === 'active'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">渠道名称</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">渠道类型</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon :icon="selectedType?.icon || 'lucide:credit-card'" :class="['h-4 w-4', selectedType?.color]" />
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">排序</span>
|
||||
<span>{{ formData.sort }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
添加渠道
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const paymentChannelStatusSchema = z.enum(['active', 'inactive'])
|
||||
export const paymentChannelTypeSchema = z.enum(['alipay', 'wechat', 'stripe', 'paypal', 'other'])
|
||||
|
||||
export const paymentChannelSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
type: paymentChannelTypeSchema,
|
||||
icon: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
sort: z.number(),
|
||||
status: paymentChannelStatusSchema,
|
||||
remark: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export type PaymentChannel = z.infer<typeof paymentChannelSchema>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { PaymentChannel } from '@/pages/admin/payment-channels/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/payment-channels/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const paymentChannels = ref<PaymentChannel[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<PaymentChannel | null>(null)
|
||||
|
||||
const activeCount = computed(() => paymentChannels.value.filter(c => c.status === 'active').length)
|
||||
const inactiveCount = computed(() => paymentChannels.value.filter(c => c.status === 'inactive').length)
|
||||
|
||||
async function fetchPaymentChannels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ payment_channels: PaymentChannel[] }>('/dev/system-settings/payment')
|
||||
paymentChannels.value = data?.payment_channels || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载支付渠道失败:', error)
|
||||
paymentChannels.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/payment-channels/create')
|
||||
}
|
||||
|
||||
function goToEdit(channel: PaymentChannel) {
|
||||
router.push(`/admin/payment-channels/${channel.id}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(channel: PaymentChannel) {
|
||||
const newStatus = channel.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await api.put(`/dev/payment-channels/${channel.id}/status`, { status: newStatus })
|
||||
toast.success('状态更新成功')
|
||||
fetchPaymentChannels()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新状态失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(channel: PaymentChannel) {
|
||||
deleteTarget.value = channel
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/payment-channels/${deleteTarget.value.id}`)
|
||||
toast.success('删除成功')
|
||||
fetchPaymentChannels()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除支付渠道失败:', error)
|
||||
toast.error(error.message || '删除失败')
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaymentChannels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="支付渠道"
|
||||
description="管理系统的支付渠道配置"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
添加渠道
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
总渠道数
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:credit-card" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ paymentChannels.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
已启用
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
已禁用
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ inactiveCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="paymentChannels"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-edit="goToEdit"
|
||||
:on-delete="confirmDelete"
|
||||
@refresh="fetchPaymentChannels"
|
||||
/>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
删除支付渠道
|
||||
</template>
|
||||
<template #description>
|
||||
确定要删除支付渠道"{{ deleteTarget?.name }}"吗?此操作不可撤销。
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
type: 'local' as 'local' | 's3' | 'webdav' | 'ftp' | 'sftp',
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
region: '',
|
||||
path_prefix: '',
|
||||
is_default: false,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const isActive = computed({
|
||||
get: () => formData.value.status === 'active',
|
||||
set: (value: boolean) => {
|
||||
formData.value.status = value ? 'active' : 'inactive'
|
||||
},
|
||||
})
|
||||
|
||||
const storageTypes = [
|
||||
{ value: 'local', label: '本地存储', icon: 'lucide:hard-drive', description: '存储在服务器本地磁盘' },
|
||||
{ value: 's3', label: 'S3存储', icon: 'lucide:cloud', description: '兼容S3协议的对象存储' },
|
||||
{ value: 'webdav', label: 'WebDAV', icon: 'lucide:globe', description: 'WebDAV协议存储' },
|
||||
{ value: 'ftp', label: 'FTP', icon: 'lucide:folder', description: 'FTP协议存储' },
|
||||
{ value: 'sftp', label: 'SFTP', icon: 'lucide:lock', description: 'SFTP协议存储' },
|
||||
]
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return storageTypes.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
const isLocal = computed(() => formData.value.type === 'local')
|
||||
const isS3 = computed(() => formData.value.type === 's3')
|
||||
|
||||
const endpointPlaceholder = computed(() => {
|
||||
switch (formData.value.type) {
|
||||
case 's3':
|
||||
return 's3.amazonaws.com'
|
||||
case 'webdav':
|
||||
return 'https://webdav.example.com'
|
||||
case 'ftp':
|
||||
case 'sftp':
|
||||
return 'ftp.example.com:21'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchStorageConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/storage-configs/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name || '',
|
||||
type: data.type || 'local',
|
||||
endpoint: data.endpoint || '',
|
||||
bucket: data.bucket || '',
|
||||
access_key: data.access_key || '',
|
||||
secret_key: data.secret_key || '',
|
||||
region: data.region || '',
|
||||
path_prefix: data.path_prefix || '',
|
||||
is_default: data.is_default || false,
|
||||
status: data.status === 'active' ? 'active' : 'inactive',
|
||||
remark: data.remark || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载存储配置失败:', error)
|
||||
toast.error('加载存储配置失败')
|
||||
router.push('/admin/storage-configs')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!formData.value.endpoint && !isLocal.value) {
|
||||
toast.error('请先填写端点地址')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
await api.post(`/dev/storage-configs/${route.params.id}/test`)
|
||||
toast.success('连接测试成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('连接测试失败:', error)
|
||||
toast.error(error.message || '连接失败')
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入存储名称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/storage-configs/${route.params.id}`, formData.value)
|
||||
toast.success('保存成功')
|
||||
router.push('/admin/storage-configs')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('保存存储配置失败:', error)
|
||||
toast.error(error.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStorageConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑存储配置"
|
||||
description="修改存储配置信息"
|
||||
:breadcrumbs="[
|
||||
{ title: '存储管理', href: '/admin/storage-configs' },
|
||||
{ title: '编辑存储' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:database" class="size-5" />
|
||||
存储配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>填写存储的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
存储名称 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="输入存储名称"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>存储类型 <span class="text-destructive">*</span></UiLabel>
|
||||
<UiSelect v-model="formData.type" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择存储类型" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in storageTypes" :key="type.value" :value="type.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="type.icon" class="h-4 w-4" />
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLocal" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="endpoint">端点/地址</UiLabel>
|
||||
<UiInput
|
||||
id="endpoint"
|
||||
v-model="formData.endpoint"
|
||||
:placeholder="endpointPlaceholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isS3 ? 'S3服务的端点地址' : '服务器地址和端口' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isS3" class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="bucket">存储桶 (Bucket)</UiLabel>
|
||||
<UiInput
|
||||
id="bucket"
|
||||
v-model="formData.bucket"
|
||||
placeholder="my-bucket"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="region">区域 (Region)</UiLabel>
|
||||
<UiInput
|
||||
id="region"
|
||||
v-model="formData.region"
|
||||
placeholder="us-east-1"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="access_key">
|
||||
{{ isS3 ? 'Access Key' : '用户名' }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="access_key"
|
||||
v-model="formData.access_key"
|
||||
placeholder="请输入"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="secret_key">
|
||||
{{ isS3 ? 'Secret Key' : '密码' }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="secret_key"
|
||||
v-model="formData.secret_key"
|
||||
type="password"
|
||||
placeholder="请输入"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="path_prefix">路径前缀</UiLabel>
|
||||
<UiInput
|
||||
id="path_prefix"
|
||||
v-model="formData.path_prefix"
|
||||
placeholder="/uploads"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
文件存储的路径前缀,留空则存储在根目录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="remark">备注</UiLabel>
|
||||
<UiInput
|
||||
id="remark"
|
||||
v-model="formData.remark"
|
||||
placeholder="备注信息(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
设为默认存储
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
设为默认后,上传文件将优先使用此存储
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
v-model="formData.is_default"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
启用状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后该存储配置将可用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
v-model="isActive"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">存储名称</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">存储类型</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon :icon="selectedType?.icon || 'lucide:storage'" class="h-4 w-4" />
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">端点地址</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.endpoint || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">默认存储</span>
|
||||
<span>{{ formData.is_default ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
|
||||
保存更改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="!isLocal"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:disabled="testing"
|
||||
@click="handleTest"
|
||||
>
|
||||
<Icon v-if="testing" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:plug" class="mr-2 h-4 w-4" />
|
||||
测试连接
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { StorageConfig } from '@/pages/admin/storage-configs/data/schema'
|
||||
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
interface ColumnOptions {
|
||||
onToggleStatus: (row: StorageConfig) => void
|
||||
onSetDefault: (row: StorageConfig) => void
|
||||
onEdit: (row: StorageConfig) => void
|
||||
onDelete: (row: StorageConfig) => void
|
||||
}
|
||||
|
||||
export function getColumns(options: ColumnOptions, t: (key: string) => string): ColumnDef<StorageConfig>[] {
|
||||
const typeLabels: Record<string, string> = {
|
||||
local: '本地存储',
|
||||
s3: 'S3存储',
|
||||
webdav: 'WebDAV',
|
||||
ftp: 'FTP',
|
||||
sftp: 'SFTP',
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
local: 'lucide:hard-drive',
|
||||
s3: 'lucide:cloud',
|
||||
webdav: 'lucide:globe',
|
||||
ftp: 'lucide:folder',
|
||||
sftp: 'lucide:lock',
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => '名称',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(Icon, { icon: typeIcons[row.original.type] || 'lucide:storage', class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'font-medium' }, row.original.name),
|
||||
row.original.is_default
|
||||
? h(Badge, { variant: 'secondary', class: 'text-xs' }, () => '默认')
|
||||
: null,
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => '类型',
|
||||
cell: ({ row }) => {
|
||||
return h(Badge, { variant: 'outline' }, () => typeLabels[row.original.type] || row.original.type)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: () => '端点/地址',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, row.original.endpoint || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => '状态',
|
||||
cell: ({ row }) => {
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '启用',
|
||||
inactive: '禁用',
|
||||
}
|
||||
const statusClasses: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 cursor-pointer',
|
||||
inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 cursor-pointer',
|
||||
}
|
||||
return h(Badge, {
|
||||
class: statusClasses[row.original.status],
|
||||
onClick: () => options.onToggleStatus(row.original),
|
||||
}, () => statusLabels[row.original.status])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'remark',
|
||||
header: () => '备注',
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground truncate max-w-[200px] block' }, row.original.remark || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => '操作',
|
||||
cell: ({ row }) => {
|
||||
const isLocal = row.original.type === 'local'
|
||||
return h('div', { class: 'flex items-center justify-end gap-1' }, [
|
||||
!row.original.is_default
|
||||
? h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onSetDefault(row.original),
|
||||
title: '设为默认',
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:star', class: 'h-4 w-4' }),
|
||||
])
|
||||
: null,
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onEdit(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:edit', class: 'h-4 w-4' }),
|
||||
]),
|
||||
!isLocal
|
||||
? h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onDelete(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:trash-2', class: 'h-4 w-4 text-destructive' }),
|
||||
])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { StorageConfig } from '@/pages/admin/storage-configs/data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/storage-configs/components/columns'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<StorageConfig>, 'columns'> & {
|
||||
onToggleStatus: (row: StorageConfig) => void
|
||||
onSetDefault: (row: StorageConfig) => void
|
||||
onEdit: (row: StorageConfig) => void
|
||||
onDelete: (row: StorageConfig) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const columns = computed<ColumnDef<StorageConfig>[]>(() => [
|
||||
SelectColumn as ColumnDef<StorageConfig>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onSetDefault: props.onSetDefault,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<StorageConfig>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: '选择',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
endpoint: '端点/地址',
|
||||
status: '状态',
|
||||
remark: '备注',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ data.length }} 个存储配置
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
type: 'local' as 'local' | 's3' | 'webdav' | 'ftp' | 'sftp',
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
region: '',
|
||||
path_prefix: '',
|
||||
is_default: false,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const storageTypes = [
|
||||
{ value: 'local', label: '本地存储', icon: 'lucide:hard-drive', description: '存储在服务器本地磁盘' },
|
||||
{ value: 's3', label: 'S3存储', icon: 'lucide:cloud', description: '兼容S3协议的对象存储' },
|
||||
{ value: 'webdav', label: 'WebDAV', icon: 'lucide:globe', description: 'WebDAV协议存储' },
|
||||
{ value: 'ftp', label: 'FTP', icon: 'lucide:folder', description: 'FTP协议存储' },
|
||||
{ value: 'sftp', label: 'SFTP', icon: 'lucide:lock', description: 'SFTP协议存储' },
|
||||
]
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return storageTypes.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
const isLocal = computed(() => formData.value.type === 'local')
|
||||
const isS3 = computed(() => formData.value.type === 's3')
|
||||
|
||||
const endpointPlaceholder = computed(() => {
|
||||
switch (formData.value.type) {
|
||||
case 's3':
|
||||
return 's3.amazonaws.com'
|
||||
case 'webdav':
|
||||
return 'https://webdav.example.com'
|
||||
case 'ftp':
|
||||
case 'sftp':
|
||||
return 'ftp.example.com:21'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
async function handleTest() {
|
||||
if (!formData.value.endpoint && !isLocal.value) {
|
||||
toast.error('请先填写端点地址')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
await api.post('/dev/storage-configs/test', formData.value)
|
||||
toast.success('连接测试成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('连接测试失败:', error)
|
||||
toast.error(error.message || '连接失败')
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入存储名称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/storage-configs', formData.value)
|
||||
toast.success('创建成功')
|
||||
router.push('/admin/storage-configs')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建存储配置失败:', error)
|
||||
toast.error(error.message || '创建失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="添加存储配置"
|
||||
description="配置新的存储方式"
|
||||
:breadcrumbs="[
|
||||
{ title: '存储管理', href: '/admin/storage-configs' },
|
||||
{ title: '添加存储' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:database" class="size-5" />
|
||||
存储配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>填写存储的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
存储名称 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="输入存储名称"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>存储类型 <span class="text-destructive">*</span></UiLabel>
|
||||
<UiSelect v-model="formData.type" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择存储类型" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in storageTypes" :key="type.value" :value="type.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="type.icon" class="h-4 w-4" />
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLocal" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="endpoint">端点/地址</UiLabel>
|
||||
<UiInput
|
||||
id="endpoint"
|
||||
v-model="formData.endpoint"
|
||||
:placeholder="endpointPlaceholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isS3 ? 'S3服务的端点地址' : '服务器地址和端口' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isS3" class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="bucket">存储桶 (Bucket)</UiLabel>
|
||||
<UiInput
|
||||
id="bucket"
|
||||
v-model="formData.bucket"
|
||||
placeholder="my-bucket"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="region">区域 (Region)</UiLabel>
|
||||
<UiInput
|
||||
id="region"
|
||||
v-model="formData.region"
|
||||
placeholder="us-east-1"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="access_key">
|
||||
{{ isS3 ? 'Access Key' : '用户名' }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="access_key"
|
||||
v-model="formData.access_key"
|
||||
placeholder="请输入"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="secret_key">
|
||||
{{ isS3 ? 'Secret Key' : '密码' }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="secret_key"
|
||||
v-model="formData.secret_key"
|
||||
type="password"
|
||||
placeholder="请输入"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="path_prefix">路径前缀</UiLabel>
|
||||
<UiInput
|
||||
id="path_prefix"
|
||||
v-model="formData.path_prefix"
|
||||
placeholder="/uploads"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
文件存储的路径前缀,留空则存储在根目录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="remark">备注</UiLabel>
|
||||
<UiInput
|
||||
id="remark"
|
||||
v-model="formData.remark"
|
||||
placeholder="备注信息(可选)"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
设为默认存储
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
设为默认后,上传文件将优先使用此存储
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
v-model="formData.is_default"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
启用状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后该存储配置将可用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:checked="formData.status === 'active'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">存储名称</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">存储类型</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon :icon="selectedType?.icon || 'lucide:storage'" class="h-4 w-4" />
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">端点地址</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.endpoint || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">默认存储</span>
|
||||
<span>{{ formData.is_default ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
添加存储
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="!isLocal"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:disabled="testing"
|
||||
@click="handleTest"
|
||||
>
|
||||
<Icon v-if="testing" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:plug" class="mr-2 h-4 w-4" />
|
||||
测试连接
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface StorageConfig {
|
||||
id: number
|
||||
name: string
|
||||
type: 'local' | 's3' | 'webdav' | 'ftp' | 'sftp'
|
||||
endpoint: string
|
||||
bucket: string
|
||||
access_key: string
|
||||
secret_key: string
|
||||
region: string
|
||||
path_prefix: string
|
||||
is_default: boolean
|
||||
status: 'active' | 'inactive'
|
||||
remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { StorageConfig } from './data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from './components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const storageConfigs = ref<StorageConfig[]>([])
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<StorageConfig | null>(null)
|
||||
|
||||
const activeCount = computed(() => storageConfigs.value.filter(c => c.status === 'active').length)
|
||||
const inactiveCount = computed(() => storageConfigs.value.filter(c => c.status === 'inactive').length)
|
||||
|
||||
async function fetchStorageConfigs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ storage_configs: StorageConfig[] }>('/dev/storage-configs')
|
||||
storageConfigs.value = data?.storage_configs || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载存储配置失败:', error)
|
||||
storageConfigs.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/storage-configs/create')
|
||||
}
|
||||
|
||||
function goToEdit(config: StorageConfig) {
|
||||
router.push(`/admin/storage-configs/${config.id}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(config: StorageConfig) {
|
||||
const newStatus = config.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await api.put(`/dev/storage-configs/${config.id}/status`, { status: newStatus })
|
||||
toast.success('状态更新成功')
|
||||
fetchStorageConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新状态失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(config: StorageConfig) {
|
||||
try {
|
||||
await api.put(`/dev/storage-configs/${config.id}/default`)
|
||||
toast.success('已设为默认存储')
|
||||
fetchStorageConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('设置默认失败:', error)
|
||||
toast.error(error.message || '设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(config: StorageConfig) {
|
||||
deleteTarget.value = config
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/storage-configs/${deleteTarget.value.id}`)
|
||||
toast.success('删除成功')
|
||||
fetchStorageConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除存储配置失败:', error)
|
||||
toast.error(error.message || '删除失败')
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStorageConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="存储管理"
|
||||
description="管理系统的存储配置,支持本地、S3、WebDAV、FTP、SFTP等存储方式"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
添加存储
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
存储配置总数
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:database" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ storageConfigs.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
已启用
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
已禁用
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ inactiveCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
:loading="loading"
|
||||
:data="storageConfigs"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-set-default="setDefault"
|
||||
:on-edit="goToEdit"
|
||||
:on-delete="confirmDelete"
|
||||
@refresh="fetchStorageConfigs"
|
||||
/>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
删除存储配置
|
||||
</template>
|
||||
<template #description>
|
||||
确定要删除存储配置"{{ deleteTarget?.name }}"吗?此操作不可撤销。
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,614 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploadingLogo = ref(false)
|
||||
const uploadingFavicon = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
|
||||
const logoInputRef = ref<HTMLInputElement | null>(null)
|
||||
const faviconInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const basicForm = ref({
|
||||
site_name: '',
|
||||
site_logo: '',
|
||||
site_favicon: '',
|
||||
site_footer: '',
|
||||
})
|
||||
|
||||
const securityForm = ref({
|
||||
enable_captcha: true,
|
||||
login_fail_lock_count: 5,
|
||||
login_fail_lock_minutes: 30,
|
||||
password_min_length: 6,
|
||||
session_timeout: 24,
|
||||
})
|
||||
|
||||
const backupForm = ref({
|
||||
enable_backup: false,
|
||||
backup_interval: 24,
|
||||
backup_retention: 7,
|
||||
backup_storage_type: 'local',
|
||||
})
|
||||
|
||||
const featureForm = ref({
|
||||
enable_ticket_system: true,
|
||||
default_theme: 'system',
|
||||
enable_multi_lang: false,
|
||||
})
|
||||
|
||||
const notificationForm = ref({
|
||||
enable_notification: false,
|
||||
admin_notify_email: '',
|
||||
notify_on_login: false,
|
||||
notify_on_recharge: true,
|
||||
notify_on_ticket: true,
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get('/dev/system-settings')
|
||||
if (data) {
|
||||
basicForm.value = {
|
||||
site_name: data.site_name || '',
|
||||
site_logo: data.site_logo || '',
|
||||
site_favicon: data.site_favicon || '',
|
||||
site_footer: data.site_footer || '',
|
||||
}
|
||||
securityForm.value = {
|
||||
enable_captcha: data.enable_captcha ?? true,
|
||||
login_fail_lock_count: data.login_fail_lock_count || 5,
|
||||
login_fail_lock_minutes: data.login_fail_lock_minutes || 30,
|
||||
password_min_length: data.password_min_length || 6,
|
||||
session_timeout: data.session_timeout || 24,
|
||||
}
|
||||
backupForm.value = {
|
||||
enable_backup: data.enable_backup ?? false,
|
||||
backup_interval: data.backup_interval || 24,
|
||||
backup_retention: data.backup_retention || 7,
|
||||
backup_storage_type: data.backup_storage_type || 'local',
|
||||
}
|
||||
featureForm.value = {
|
||||
enable_ticket_system: data.enable_ticket_system ?? true,
|
||||
default_theme: data.default_theme || 'system',
|
||||
enable_multi_lang: data.enable_multi_lang ?? false,
|
||||
}
|
||||
notificationForm.value = {
|
||||
enable_notification: data.enable_notification ?? false,
|
||||
admin_notify_email: data.admin_notify_email || '',
|
||||
notify_on_login: data.notify_on_login ?? false,
|
||||
notify_on_recharge: data.notify_on_recharge ?? true,
|
||||
notify_on_ticket: data.notify_on_ticket ?? true,
|
||||
}
|
||||
const settings = {
|
||||
site_name: data.site_name || '',
|
||||
site_logo: data.site_logo || '',
|
||||
site_favicon: data.site_favicon || '',
|
||||
}
|
||||
localStorage.setItem('systemSettings', JSON.stringify(settings))
|
||||
window.dispatchEvent(new CustomEvent('system-settings-changed', { detail: settings }))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('加载设置失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...basicForm.value,
|
||||
...securityForm.value,
|
||||
...backupForm.value,
|
||||
...featureForm.value,
|
||||
...notificationForm.value,
|
||||
}
|
||||
await api.put('/dev/system-settings', payload)
|
||||
toast.success('保存成功')
|
||||
updateGlobalSettings()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateGlobalSettings() {
|
||||
const settings = {
|
||||
site_name: basicForm.value.site_name,
|
||||
site_logo: basicForm.value.site_logo,
|
||||
site_favicon: basicForm.value.site_favicon,
|
||||
}
|
||||
localStorage.setItem('systemSettings', JSON.stringify(settings))
|
||||
window.dispatchEvent(new CustomEvent('system-settings-changed', { detail: settings }))
|
||||
}
|
||||
|
||||
function triggerLogoUpload() {
|
||||
logoInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleLogoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
uploadingLogo.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'logo')
|
||||
|
||||
const data = await api.postFormData('/dev/system-settings/upload', formData)
|
||||
basicForm.value.site_logo = data.url
|
||||
toast.success('Logo上传成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploadingLogo.value = false
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFaviconUpload() {
|
||||
faviconInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFaviconUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
uploadingFavicon.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'favicon')
|
||||
|
||||
const data = await api.postFormData('/dev/system-settings/upload', formData)
|
||||
basicForm.value.site_favicon = data.url
|
||||
toast.success('图标上传成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploadingFavicon.value = false
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basic', label: '基本设置', icon: 'lucide:settings' },
|
||||
{ id: 'security', label: '安全设置', icon: 'lucide:shield' },
|
||||
{ id: 'backup', label: '备份设置', icon: 'lucide:database' },
|
||||
{ id: 'feature', label: '功能设置', icon: 'lucide:toggle-left' },
|
||||
{ id: 'notification', label: '通知设置', icon: 'lucide:bell' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="系统设置" description="配置系统各项设置">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="flex border-b">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="activeTab === tab.id
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<Icon :icon="tab.icon" class="h-4 w-4" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UiCard v-show="activeTab === 'basic'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>基本设置</UiCardTitle>
|
||||
<UiCardDescription>配置网站基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="site_name">网站名称</UiLabel>
|
||||
<UiInput id="site_name" v-model="basicForm.site_name" placeholder="请输入网站名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>网站Logo</UiLabel>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" :disabled="uploadingLogo" @click="triggerLogoUpload">
|
||||
<Icon v-if="uploadingLogo" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:upload" class="mr-2 h-4 w-4" />
|
||||
上传Logo
|
||||
</UiButton>
|
||||
<span class="text-xs text-muted-foreground">支持 JPG、PNG、SVG 格式</span>
|
||||
</div>
|
||||
<input
|
||||
ref="logoInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
:disabled="uploadingLogo"
|
||||
@change="handleLogoUpload"
|
||||
>
|
||||
<div v-if="basicForm.site_logo" class="mt-3 flex items-center gap-3">
|
||||
<img
|
||||
:src="basicForm.site_logo"
|
||||
alt="Logo预览"
|
||||
class="h-12 w-auto rounded border object-contain p-1"
|
||||
>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
@click="basicForm.site_logo = ''"
|
||||
>
|
||||
移除
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>网站图标 (Favicon)</UiLabel>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" :disabled="uploadingFavicon" @click="triggerFaviconUpload">
|
||||
<Icon v-if="uploadingFavicon" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:upload" class="mr-2 h-4 w-4" />
|
||||
上传图标
|
||||
</UiButton>
|
||||
<span class="text-xs text-muted-foreground">推荐 32x32 或 64x64 像素的 ICO/PNG</span>
|
||||
</div>
|
||||
<input
|
||||
ref="faviconInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
:disabled="uploadingFavicon"
|
||||
@change="handleFaviconUpload"
|
||||
>
|
||||
<div v-if="basicForm.site_favicon" class="mt-3 flex items-center gap-3">
|
||||
<img
|
||||
:src="basicForm.site_favicon"
|
||||
alt="Favicon预览"
|
||||
class="h-8 w-8 rounded border object-contain p-1"
|
||||
>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
@click="basicForm.site_favicon = ''"
|
||||
>
|
||||
移除
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="site_footer">页脚信息</UiLabel>
|
||||
<UiInput id="site_footer" v-model="basicForm.site_footer" placeholder="请输入页脚信息" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-show="activeTab === 'security'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>安全设置</UiCardTitle>
|
||||
<UiCardDescription>配置系统安全相关选项</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>登录验证码</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后登录时需要输入验证码
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="securityForm.enable_captcha" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="login_fail_lock_count">登录失败锁定次数</UiLabel>
|
||||
<UiInput
|
||||
id="login_fail_lock_count"
|
||||
v-model.number="securityForm.login_fail_lock_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
连续失败多少次后锁定账户
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="login_fail_lock_minutes">锁定时长(分钟)</UiLabel>
|
||||
<UiInput
|
||||
id="login_fail_lock_minutes"
|
||||
v-model.number="securityForm.login_fail_lock_minutes"
|
||||
type="number"
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
账户锁定持续时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="password_min_length">密码最小长度</UiLabel>
|
||||
<UiInput
|
||||
id="password_min_length"
|
||||
v-model.number="securityForm.password_min_length"
|
||||
type="number"
|
||||
min="6"
|
||||
max="32"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户密码最小字符数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="session_timeout">会话超时(小时)</UiLabel>
|
||||
<UiInput
|
||||
id="session_timeout"
|
||||
v-model.number="securityForm.session_timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="720"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户登录会话有效期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-show="activeTab === 'backup'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>备份设置</UiCardTitle>
|
||||
<UiCardDescription>配置数据库自动备份</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用自动备份</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
定时自动备份数据库
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="backupForm.enable_backup" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="backup_interval">备份间隔(小时)</UiLabel>
|
||||
<UiInput
|
||||
id="backup_interval"
|
||||
v-model.number="backupForm.backup_interval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每隔多少小时备份一次
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="backup_retention">保留天数</UiLabel>
|
||||
<UiInput
|
||||
id="backup_retention"
|
||||
v-model.number="backupForm.backup_retention"
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
备份文件保留多少天
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="backup_storage_type">存储位置</UiLabel>
|
||||
<UiSelect v-model="backupForm.backup_storage_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择存储位置" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="local">
|
||||
本地存储
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="s3">
|
||||
S3存储
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="webdav">
|
||||
WebDAV
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="ftp">
|
||||
FTP
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="sftp">
|
||||
SFTP
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
备份文件存储位置,可在存储管理中配置
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-show="activeTab === 'feature'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>功能设置</UiCardTitle>
|
||||
<UiCardDescription>配置系统功能开关</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>工单系统</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后用户可以提交工单
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="featureForm.enable_ticket_system" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>多语言支持</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后用户可以切换语言
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="featureForm.enable_multi_lang" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="default_theme">默认颜色模式</UiLabel>
|
||||
<UiSelect v-model="featureForm.default_theme">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择默认颜色模式" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="system">
|
||||
跟随系统
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="light">
|
||||
浅色模式
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="dark">
|
||||
深色模式
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户默认的颜色模式
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-show="activeTab === 'notification'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>通知设置</UiCardTitle>
|
||||
<UiCardDescription>配置系统通知选项</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用邮件通知</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后系统将发送邮件通知
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.enable_notification" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_notify_email">管理员通知邮箱</UiLabel>
|
||||
<UiInput
|
||||
id="admin_notify_email"
|
||||
v-model="notificationForm.admin_notify_email"
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
接收系统通知的管理员邮箱
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UiLabel>通知事件</UiLabel>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
异常登录通知
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
检测到异常登录时发送通知
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.notify_on_login" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
充值通知
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户充值成功时发送通知
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.notify_on_recharge" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
工单通知
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新工单提交时发送通知
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.notify_on_ticket" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="saveSettings">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -344,6 +344,66 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/profile/index.vue'),
|
||||
meta: { title: '个人中心 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'system-settings',
|
||||
name: 'AdminSystemSettings',
|
||||
component: () => import('@/pages/admin/system-settings/index.vue'),
|
||||
meta: { title: '系统设置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'payment-channels',
|
||||
name: 'AdminPaymentChannels',
|
||||
component: () => import('@/pages/admin/payment-channels/index.vue'),
|
||||
meta: { title: '支付渠道 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'payment-channels/create',
|
||||
name: 'AdminPaymentChannelCreate',
|
||||
component: () => import('@/pages/admin/payment-channels/create.vue'),
|
||||
meta: { title: '添加支付渠道 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'payment-channels/:id',
|
||||
name: 'AdminPaymentChannelEdit',
|
||||
component: () => import('@/pages/admin/payment-channels/[id].vue'),
|
||||
meta: { title: '编辑支付渠道 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'email-settings',
|
||||
name: 'AdminEmailSettings',
|
||||
component: () => import('@/pages/admin/email-settings/index.vue'),
|
||||
meta: { title: '邮箱配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'email-settings/create',
|
||||
name: 'AdminEmailSettingCreate',
|
||||
component: () => import('@/pages/admin/email-settings/create.vue'),
|
||||
meta: { title: '添加邮箱配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'email-settings/:id',
|
||||
name: 'AdminEmailSettingEdit',
|
||||
component: () => import('@/pages/admin/email-settings/[id].vue'),
|
||||
meta: { title: '编辑邮箱配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'storage-configs',
|
||||
name: 'AdminStorageConfigs',
|
||||
component: () => import('@/pages/admin/storage-configs/index.vue'),
|
||||
meta: { title: '存储管理 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'storage-configs/create',
|
||||
name: 'AdminStorageConfigCreate',
|
||||
component: () => import('@/pages/admin/storage-configs/create.vue'),
|
||||
meta: { title: '添加存储配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'storage-configs/:id',
|
||||
name: 'AdminStorageConfigEdit',
|
||||
component: () => import('@/pages/admin/storage-configs/[id].vue'),
|
||||
meta: { title: '编辑存储配置 - 管理后台' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+130
@@ -287,6 +287,27 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/email-settings/': RouteRecordInfo<
|
||||
'/admin/email-settings/',
|
||||
'/admin/email-settings',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/email-settings/[id]': RouteRecordInfo<
|
||||
'/admin/email-settings/[id]',
|
||||
'/admin/email-settings/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/admin/email-settings/create': RouteRecordInfo<
|
||||
'/admin/email-settings/create',
|
||||
'/admin/email-settings/create',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/extension/': RouteRecordInfo<
|
||||
'/admin/extension/',
|
||||
'/admin/extension',
|
||||
@@ -329,6 +350,27 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/payment-channels/': RouteRecordInfo<
|
||||
'/admin/payment-channels/',
|
||||
'/admin/payment-channels',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/payment-channels/[id]': RouteRecordInfo<
|
||||
'/admin/payment-channels/[id]',
|
||||
'/admin/payment-channels/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/admin/payment-channels/create': RouteRecordInfo<
|
||||
'/admin/payment-channels/create',
|
||||
'/admin/payment-channels/create',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/risk-control/': RouteRecordInfo<
|
||||
'/admin/risk-control/',
|
||||
'/admin/risk-control',
|
||||
@@ -357,6 +399,34 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/storage-configs/': RouteRecordInfo<
|
||||
'/admin/storage-configs/',
|
||||
'/admin/storage-configs',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/storage-configs/[id]': RouteRecordInfo<
|
||||
'/admin/storage-configs/[id]',
|
||||
'/admin/storage-configs/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/admin/storage-configs/create': RouteRecordInfo<
|
||||
'/admin/storage-configs/create',
|
||||
'/admin/storage-configs/create',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/system-settings/': RouteRecordInfo<
|
||||
'/admin/system-settings/',
|
||||
'/admin/system-settings',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/tickets': RouteRecordInfo<
|
||||
'/admin/tickets',
|
||||
'/admin/tickets',
|
||||
@@ -782,6 +852,24 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/email-settings/index.vue': {
|
||||
routes:
|
||||
| '/admin/email-settings/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/email-settings/[id].vue': {
|
||||
routes:
|
||||
| '/admin/email-settings/[id]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/email-settings/create.vue': {
|
||||
routes:
|
||||
| '/admin/email-settings/create'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/extension/index.vue': {
|
||||
routes:
|
||||
| '/admin/extension/'
|
||||
@@ -819,6 +907,24 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/payment-channels/index.vue': {
|
||||
routes:
|
||||
| '/admin/payment-channels/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/payment-channels/[id].vue': {
|
||||
routes:
|
||||
| '/admin/payment-channels/[id]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/payment-channels/create.vue': {
|
||||
routes:
|
||||
| '/admin/payment-channels/create'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/risk-control/index.vue': {
|
||||
routes:
|
||||
| '/admin/risk-control/'
|
||||
@@ -843,6 +949,30 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/storage-configs/index.vue': {
|
||||
routes:
|
||||
| '/admin/storage-configs/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/storage-configs/[id].vue': {
|
||||
routes:
|
||||
| '/admin/storage-configs/[id]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/storage-configs/create.vue': {
|
||||
routes:
|
||||
| '/admin/storage-configs/create'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/system-settings/index.vue': {
|
||||
routes:
|
||||
| '/admin/system-settings/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/tickets.vue': {
|
||||
routes:
|
||||
| '/admin/tickets'
|
||||
|
||||
Reference in New Issue
Block a user