feat: 分离密码重置和修改密码功能
- 密码重置:用户忘记密码时通过邮箱/短信验证(需开启开关) - 修改密码:已登录用户通过旧密码修改(固定功能,无需配置) - 添加 EnablePasswordReset 开关控制密码重置功能 - PasswordResetMethod 只支持 email/sms 两种验证方式 - 添加短信配置管理功能 - 移除独立的应用邮箱设置页面
This commit is contained in:
@@ -166,6 +166,7 @@ func initMySQL() {
|
||||
&model.DynamicCode{},
|
||||
&model.PaymentChannel{},
|
||||
&model.EmailConfig{},
|
||||
&model.SmsConfig{},
|
||||
&model.StorageConfig{},
|
||||
&model.Captcha{},
|
||||
&model.ApiUsage{},
|
||||
@@ -174,6 +175,7 @@ func initMySQL() {
|
||||
&model.Notification{},
|
||||
&model.RiskControlRule{},
|
||||
&model.EmailVerifyCode{},
|
||||
&model.SmsVerifyCode{},
|
||||
&model.AppSMTPConfig{},
|
||||
&model.EmailTemplate{},
|
||||
&model.VersionFile{},
|
||||
@@ -384,6 +386,22 @@ func initData() {
|
||||
DB.Model(&model.StorageConfig{}).Where("type = ? AND (status = '' OR status IS NULL)", "local").Update("status", "active")
|
||||
}
|
||||
|
||||
var bepusdtCount int64
|
||||
DB.Model(&model.PaymentChannel{}).Where("type = ?", "bepusdt").Count(&bepusdtCount)
|
||||
if bepusdtCount == 0 {
|
||||
log.Println("Creating BEPUSDT example payment channel...")
|
||||
bepusdtChannel := model.PaymentChannel{
|
||||
Name: "USDT支付",
|
||||
Type: "bepusdt",
|
||||
Icon: "",
|
||||
Config: `{"api_url": "http://your-bepusdt-server:8080", "token": "your-api-token-here"}`,
|
||||
Sort: 1,
|
||||
Status: "inactive",
|
||||
Remark: "BEPUSDT示例配置,请修改api_url和token后启用",
|
||||
}
|
||||
DB.Create(&bepusdtChannel)
|
||||
}
|
||||
|
||||
initDocData()
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ func AppCrypto() gin.HandlerFunc {
|
||||
encryptType = crypto.EncryptTypeNone
|
||||
}
|
||||
|
||||
cryptoManager := crypto.NewCryptoManager(encryptType, app.SecretKey)
|
||||
cryptoManager := crypto.NewCryptoManager(encryptType, app.EncryptKey)
|
||||
shouldEncrypt := encryptType != crypto.EncryptTypeNone
|
||||
|
||||
fmt.Printf("[AppCrypto] AppKey: %s, EncryptType: %s, SecretKey: %s, ShouldEncrypt: %v\n",
|
||||
appKey, app.EncryptType, app.SecretKey, shouldEncrypt)
|
||||
fmt.Printf("[AppCrypto] AppKey: %s, EncryptType: %s, EncryptKey: %s, ShouldEncrypt: %v\n",
|
||||
appKey, app.EncryptType, app.EncryptKey, shouldEncrypt)
|
||||
|
||||
c.Set("should_encrypt_response", shouldEncrypt)
|
||||
c.Set("crypto_manager", cryptoManager)
|
||||
|
||||
@@ -119,7 +119,7 @@ func (cm *CryptoMiddleware) ProcessResponse() gin.HandlerFunc {
|
||||
default:
|
||||
encryptType = crypto.EncryptTypeNone
|
||||
}
|
||||
cryptoManager = crypto.NewCryptoManager(encryptType, app.SecretKey)
|
||||
cryptoManager = crypto.NewCryptoManager(encryptType, app.EncryptKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,18 @@ type EmailConfig struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type SmsConfig 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"`
|
||||
Config string `gorm:"type:text" json:"config"`
|
||||
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"`
|
||||
@@ -148,53 +160,57 @@ type UserLevel struct {
|
||||
|
||||
// Application 应用模型
|
||||
type Application struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `gorm:"size:100" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
IconURL string `gorm:"size:255" json:"icon_url"`
|
||||
AppKey string `gorm:"uniqueIndex;size:50" json:"app_key"`
|
||||
BillingType string `gorm:"size:20" json:"billing_type"`
|
||||
LoginPolicy string `gorm:"size:20;default:loose" json:"login_policy"`
|
||||
EncryptType string `gorm:"size:20" json:"encrypt_type"`
|
||||
SecretKey string `gorm:"size:255" json:"secret_key"`
|
||||
BindType string `gorm:"size:20" json:"bind_type"`
|
||||
MaxDevices int `gorm:"default:1" json:"max_devices"`
|
||||
ChangeLimit int `gorm:"default:3" json:"change_limit"`
|
||||
ChangeInterval int `gorm:"default:7" json:"change_interval"`
|
||||
ChangeExceedAction string `gorm:"size:20;default:deny" json:"change_exceed_action"`
|
||||
ChangeDeductAmount float64 `gorm:"default:1" json:"change_deduct_amount"`
|
||||
MultiOpenMode string `gorm:"size:20;default:forbidden" json:"multi_open_mode"`
|
||||
MaxInstances int `gorm:"default:1" json:"max_instances"`
|
||||
MultiOpen bool `gorm:"default:false" json:"multi_open"`
|
||||
EnableTrial bool `gorm:"default:false" json:"enable_trial"`
|
||||
TrialBalance float64 `gorm:"default:0" json:"trial_balance"`
|
||||
TrialDays int `gorm:"default:0" json:"trial_days"`
|
||||
EnableFreePeriod bool `gorm:"default:false" json:"enable_free_period"`
|
||||
FreePeriodType string `gorm:"size:20;default:range" json:"free_period_type"`
|
||||
FreePeriodStart string `gorm:"size:50" json:"free_period_start"`
|
||||
FreePeriodEnd string `gorm:"size:50" json:"free_period_end"`
|
||||
FreePeriodWeekdays string `gorm:"size:50" json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `gorm:"size:10" json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `gorm:"size:10" json:"free_period_end_time"`
|
||||
HeartbeatInterval int `gorm:"default:60" json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `gorm:"default:300" json:"heartbeat_timeout"`
|
||||
MaxAttempts int `gorm:"default:5" json:"max_attempts"`
|
||||
LockDuration int `gorm:"default:30" json:"lock_duration"`
|
||||
Status string `gorm:"size:20;default:active" json:"status"`
|
||||
DeductionMode string `gorm:"size:20;default:auto" json:"deduction_mode"`
|
||||
DeductionType string `gorm:"size:20;default:login" json:"deduction_type"`
|
||||
DeductionInterval int `gorm:"default:1" json:"deduction_interval"`
|
||||
DeductionUnit string `gorm:"size:20;default:minute" json:"deduction_unit"`
|
||||
DeductionAmount float64 `gorm:"default:1" json:"deduction_amount"`
|
||||
AllowRegister bool `gorm:"default:true" json:"allow_register"`
|
||||
RegisterMethods string `gorm:"size:100;default:'[\"username\"]'" json:"register_methods"`
|
||||
EnableEmailVerify bool `gorm:"default:false" json:"enable_email_verify"`
|
||||
RequireEmailVerify bool `gorm:"default:false" json:"require_email_verify"`
|
||||
EnablePasswordReset bool `gorm:"default:false" json:"enable_password_reset"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `gorm:"size:100" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
IconURL string `gorm:"size:255" json:"icon_url"`
|
||||
AppKey string `gorm:"uniqueIndex;size:50" json:"app_key"`
|
||||
BillingType string `gorm:"size:20" json:"billing_type"`
|
||||
LoginPolicy string `gorm:"size:20;default:loose" json:"login_policy"`
|
||||
EncryptType string `gorm:"size:20" json:"encrypt_type"`
|
||||
EncryptKey string `gorm:"size:255" json:"encrypt_key"`
|
||||
BindType string `gorm:"size:20" json:"bind_type"`
|
||||
MaxDevices int `gorm:"default:1" json:"max_devices"`
|
||||
ChangeLimit int `gorm:"default:3" json:"change_limit"`
|
||||
ChangeInterval int `gorm:"default:7" json:"change_interval"`
|
||||
ChangeExceedAction string `gorm:"size:20;default:deny" json:"change_exceed_action"`
|
||||
ChangeDeductAmount float64 `gorm:"default:1" json:"change_deduct_amount"`
|
||||
MultiOpenMode string `gorm:"size:20;default:forbidden" json:"multi_open_mode"`
|
||||
MaxInstances int `gorm:"default:1" json:"max_instances"`
|
||||
EnableTrial bool `gorm:"default:false" json:"enable_trial"`
|
||||
TrialBalance float64 `gorm:"default:0" json:"trial_balance"`
|
||||
TrialDays int `gorm:"default:0" json:"trial_days"`
|
||||
EnableFreePeriod bool `gorm:"default:false" json:"enable_free_period"`
|
||||
FreePeriodType string `gorm:"size:20;default:range" json:"free_period_type"`
|
||||
FreePeriodStart string `gorm:"size:50" json:"free_period_start"`
|
||||
FreePeriodEnd string `gorm:"size:50" json:"free_period_end"`
|
||||
FreePeriodWeekdays string `gorm:"size:50" json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `gorm:"size:10" json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `gorm:"size:10" json:"free_period_end_time"`
|
||||
HeartbeatInterval int `gorm:"default:60" json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `gorm:"default:300" json:"heartbeat_timeout"`
|
||||
MaxAttempts int `gorm:"default:5" json:"max_attempts"`
|
||||
LockDuration int `gorm:"default:30" json:"lock_duration"`
|
||||
Status string `gorm:"size:20;default:active" json:"status"`
|
||||
DeductionMode string `gorm:"size:20;default:auto" json:"deduction_mode"`
|
||||
DeductionType string `gorm:"size:20;default:login" json:"deduction_type"`
|
||||
DeductionInterval int `gorm:"default:1" json:"deduction_interval"`
|
||||
DeductionUnit string `gorm:"size:20;default:minute" json:"deduction_unit"`
|
||||
DeductionAmount float64 `gorm:"default:1" json:"deduction_amount"`
|
||||
AllowRegister bool `gorm:"default:true" json:"allow_register"`
|
||||
EnableLoginVerify bool `gorm:"default:false" json:"enable_login_verify"`
|
||||
EnableRegisterVerify bool `gorm:"default:false" json:"enable_register_verify"`
|
||||
VerifyMethod string `gorm:"size:20;default:email" json:"verify_method"`
|
||||
EmailConfigID *uint `json:"email_config_id"`
|
||||
SmsConfigID *uint `json:"sms_config_id"`
|
||||
EnablePasswordReset bool `gorm:"default:false" json:"enable_password_reset"`
|
||||
PasswordResetMethod string `gorm:"size:20;default:email" json:"password_reset_method"`
|
||||
PasswordResetEmailID *uint `json:"password_reset_email_id"`
|
||||
PasswordResetSmsID *uint `json:"password_reset_sms_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
@@ -726,6 +742,17 @@ type EmailVerifyCode struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type SmsVerifyCode struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ApplicationID uint `gorm:"index" json:"application_id"`
|
||||
Phone string `gorm:"size:20;index" json:"phone"`
|
||||
Code string `gorm:"size:10" json:"code"`
|
||||
Purpose string `gorm:"size:20;default:register" json:"purpose"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Used bool `gorm:"default:false" json:"used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AppSMTPConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ApplicationID uint `gorm:"uniqueIndex" json:"application_id"`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -87,50 +87,60 @@ func handleCreateApplication(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Description string `form:"description" json:"description"`
|
||||
BillingType string `form:"billing_type" json:"billing_type"`
|
||||
LoginPolicy string `form:"login_policy" json:"login_policy"`
|
||||
EncryptType string `form:"encrypt_type" json:"encrypt_type"`
|
||||
SecretKey string `form:"secret_key" json:"secret_key"`
|
||||
BindType string `form:"bind_type" json:"bind_type"`
|
||||
MaxDevices string `form:"max_devices" json:"max_devices"`
|
||||
ChangeLimit string `form:"change_limit" json:"change_limit"`
|
||||
ChangeInterval string `form:"change_interval" json:"change_interval"`
|
||||
ChangeExceedAction string `form:"change_exceed_action" json:"change_exceed_action"`
|
||||
ChangeDeductAmount string `form:"change_deduct_amount" json:"change_deduct_amount"`
|
||||
MultiOpenMode string `form:"multi_open_mode" json:"multi_open_mode"`
|
||||
MaxInstances string `form:"max_instances" json:"max_instances"`
|
||||
MultiOpen string `form:"multi_open" json:"multi_open"`
|
||||
EnableTrial string `form:"enable_trial" json:"enable_trial"`
|
||||
TrialBalance string `form:"trial_balance" json:"trial_balance"`
|
||||
TrialDays string `form:"trial_days" json:"trial_days"`
|
||||
EnableFreePeriod string `form:"enable_free_period" json:"enable_free_period"`
|
||||
FreePeriodType string `form:"free_period_type" json:"free_period_type"`
|
||||
FreePeriodStart string `form:"free_period_start" json:"free_period_start"`
|
||||
FreePeriodEnd string `form:"free_period_end" json:"free_period_end"`
|
||||
FreePeriodWeekdays string `form:"free_period_weekdays" json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `form:"free_period_start_time" json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `form:"free_period_end_time" json:"free_period_end_time"`
|
||||
HeartbeatInterval string `form:"heartbeat_interval" json:"heartbeat_interval"`
|
||||
HeartbeatTimeout string `form:"heartbeat_timeout" json:"heartbeat_timeout"`
|
||||
MaxAttempts string `form:"max_attempts" json:"max_attempts"`
|
||||
LockDuration string `form:"lock_duration" json:"lock_duration"`
|
||||
DeductionMode string `form:"deduction_mode" json:"deduction_mode"`
|
||||
DeductionType string `form:"deduction_type" json:"deduction_type"`
|
||||
DeductionInterval string `form:"deduction_interval" json:"deduction_interval"`
|
||||
DeductionUnit string `form:"deduction_unit" json:"deduction_unit"`
|
||||
DeductionAmount string `form:"deduction_amount" json:"deduction_amount"`
|
||||
DeductionCycle string `form:"deduction_cycle" json:"deduction_cycle"`
|
||||
Name string `form:"name" json:"name"`
|
||||
Description string `form:"description" json:"description"`
|
||||
BillingType string `form:"billing_type" json:"billing_type"`
|
||||
LoginPolicy string `form:"login_policy" json:"login_policy"`
|
||||
EncryptType string `form:"encrypt_type" json:"encrypt_type"`
|
||||
EncryptKey string `form:"encrypt_key" json:"encrypt_key"`
|
||||
BindType string `form:"bind_type" json:"bind_type"`
|
||||
MaxDevices string `form:"max_devices" json:"max_devices"`
|
||||
ChangeLimit string `form:"change_limit" json:"change_limit"`
|
||||
ChangeInterval string `form:"change_interval" json:"change_interval"`
|
||||
ChangeExceedAction string `form:"change_exceed_action" json:"change_exceed_action"`
|
||||
ChangeDeductAmount string `form:"change_deduct_amount" json:"change_deduct_amount"`
|
||||
MultiOpenMode string `form:"multi_open_mode" json:"multi_open_mode"`
|
||||
MaxInstances string `form:"max_instances" json:"max_instances"`
|
||||
EnableTrial string `form:"enable_trial" json:"enable_trial"`
|
||||
TrialBalance string `form:"trial_balance" json:"trial_balance"`
|
||||
TrialDays string `form:"trial_days" json:"trial_days"`
|
||||
EnableFreePeriod string `form:"enable_free_period" json:"enable_free_period"`
|
||||
FreePeriodType string `form:"free_period_type" json:"free_period_type"`
|
||||
FreePeriodStart string `form:"free_period_start" json:"free_period_start"`
|
||||
FreePeriodEnd string `form:"free_period_end" json:"free_period_end"`
|
||||
FreePeriodWeekdays string `form:"free_period_weekdays" json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `form:"free_period_start_time" json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `form:"free_period_end_time" json:"free_period_end_time"`
|
||||
HeartbeatInterval string `form:"heartbeat_interval" json:"heartbeat_interval"`
|
||||
HeartbeatTimeout string `form:"heartbeat_timeout" json:"heartbeat_timeout"`
|
||||
MaxAttempts string `form:"max_attempts" json:"max_attempts"`
|
||||
LockDuration string `form:"lock_duration" json:"lock_duration"`
|
||||
DeductionMode string `form:"deduction_mode" json:"deduction_mode"`
|
||||
DeductionType string `form:"deduction_type" json:"deduction_type"`
|
||||
DeductionInterval string `form:"deduction_interval" json:"deduction_interval"`
|
||||
DeductionUnit string `form:"deduction_unit" json:"deduction_unit"`
|
||||
DeductionAmount string `form:"deduction_amount" json:"deduction_amount"`
|
||||
DeductionCycle string `form:"deduction_cycle" json:"deduction_cycle"`
|
||||
AllowRegister string `form:"allow_register" json:"allow_register"`
|
||||
EnableLoginVerify string `form:"enable_login_verify" json:"enable_login_verify"`
|
||||
EnableRegisterVerify string `form:"enable_register_verify" json:"enable_register_verify"`
|
||||
EnablePasswordReset string `form:"enable_password_reset" json:"enable_password_reset"`
|
||||
PasswordResetMethod string `form:"password_reset_method" json:"password_reset_method"`
|
||||
PasswordResetEmailID uint `form:"password_reset_email_id" json:"password_reset_email_id"`
|
||||
PasswordResetSmsID uint `form:"password_reset_sms_id" json:"password_reset_sms_id"`
|
||||
Status string `form:"status" json:"status"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.Error(c, 400, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
multiOpen := req.MultiOpen == "true"
|
||||
enableTrial := req.EnableTrial == "true"
|
||||
enableFreePeriod := req.EnableFreePeriod == "true"
|
||||
allowRegister := req.AllowRegister != "false"
|
||||
enableLoginVerify := req.EnableLoginVerify == "true"
|
||||
enableRegisterVerify := req.EnableRegisterVerify == "true"
|
||||
enablePasswordReset := req.EnablePasswordReset == "true"
|
||||
|
||||
maxDevices := parseIntWithDefault(req.MaxDevices, 1)
|
||||
changeLimit := parseIntWithDefault(req.ChangeLimit, 3)
|
||||
@@ -147,9 +157,9 @@ func handleCreateApplication(c *gin.Context) {
|
||||
deductionAmount := parseFloatWithDefault(req.DeductionAmount, 1)
|
||||
|
||||
appKey := generateAppKey()
|
||||
secretKey := req.SecretKey
|
||||
if secretKey == "" {
|
||||
secretKey = generateSecretKey()
|
||||
encryptKey := req.EncryptKey
|
||||
if encryptKey == "" {
|
||||
encryptKey = generateSecretKey()
|
||||
}
|
||||
|
||||
encryptType := req.EncryptType
|
||||
@@ -177,44 +187,65 @@ func handleCreateApplication(c *gin.Context) {
|
||||
deductionUnit = req.DeductionCycle
|
||||
}
|
||||
|
||||
passwordResetMethod := req.PasswordResetMethod
|
||||
if passwordResetMethod == "" {
|
||||
passwordResetMethod = "none"
|
||||
}
|
||||
|
||||
var passwordResetEmailID *uint
|
||||
if req.PasswordResetEmailID > 0 {
|
||||
passwordResetEmailID = &req.PasswordResetEmailID
|
||||
}
|
||||
|
||||
var passwordResetSmsID *uint
|
||||
if req.PasswordResetSmsID > 0 {
|
||||
passwordResetSmsID = &req.PasswordResetSmsID
|
||||
}
|
||||
|
||||
app := model.Application{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
AppKey: appKey,
|
||||
SecretKey: secretKey,
|
||||
BillingType: req.BillingType,
|
||||
LoginPolicy: loginPolicy,
|
||||
EncryptType: encryptType,
|
||||
BindType: req.BindType,
|
||||
MaxDevices: maxDevices,
|
||||
ChangeLimit: changeLimit,
|
||||
ChangeInterval: changeInterval,
|
||||
ChangeExceedAction: changeExceedAction,
|
||||
ChangeDeductAmount: changeDeductAmount,
|
||||
MultiOpenMode: multiOpenMode,
|
||||
MaxInstances: maxInstances,
|
||||
MultiOpen: multiOpen,
|
||||
EnableTrial: enableTrial,
|
||||
TrialBalance: trialBalance,
|
||||
TrialDays: trialDays,
|
||||
EnableFreePeriod: enableFreePeriod,
|
||||
FreePeriodType: req.FreePeriodType,
|
||||
FreePeriodStart: req.FreePeriodStart,
|
||||
FreePeriodEnd: req.FreePeriodEnd,
|
||||
FreePeriodWeekdays: req.FreePeriodWeekdays,
|
||||
FreePeriodStartTime: req.FreePeriodStartTime,
|
||||
FreePeriodEndTime: req.FreePeriodEndTime,
|
||||
HeartbeatInterval: heartbeatInterval,
|
||||
HeartbeatTimeout: heartbeatTimeout,
|
||||
MaxAttempts: maxAttempts,
|
||||
LockDuration: lockDuration,
|
||||
DeductionMode: req.DeductionMode,
|
||||
DeductionType: req.DeductionType,
|
||||
DeductionInterval: deductionInterval,
|
||||
DeductionUnit: deductionUnit,
|
||||
DeductionAmount: deductionAmount,
|
||||
Status: "active",
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
AppKey: appKey,
|
||||
EncryptKey: encryptKey,
|
||||
BillingType: req.BillingType,
|
||||
LoginPolicy: loginPolicy,
|
||||
EncryptType: encryptType,
|
||||
BindType: req.BindType,
|
||||
MaxDevices: maxDevices,
|
||||
ChangeLimit: changeLimit,
|
||||
ChangeInterval: changeInterval,
|
||||
ChangeExceedAction: changeExceedAction,
|
||||
ChangeDeductAmount: changeDeductAmount,
|
||||
MultiOpenMode: multiOpenMode,
|
||||
MaxInstances: maxInstances,
|
||||
EnableTrial: enableTrial,
|
||||
TrialBalance: trialBalance,
|
||||
TrialDays: trialDays,
|
||||
EnableFreePeriod: enableFreePeriod,
|
||||
FreePeriodType: req.FreePeriodType,
|
||||
FreePeriodStart: req.FreePeriodStart,
|
||||
FreePeriodEnd: req.FreePeriodEnd,
|
||||
FreePeriodWeekdays: req.FreePeriodWeekdays,
|
||||
FreePeriodStartTime: req.FreePeriodStartTime,
|
||||
FreePeriodEndTime: req.FreePeriodEndTime,
|
||||
HeartbeatInterval: heartbeatInterval,
|
||||
HeartbeatTimeout: heartbeatTimeout,
|
||||
MaxAttempts: maxAttempts,
|
||||
LockDuration: lockDuration,
|
||||
DeductionMode: req.DeductionMode,
|
||||
DeductionType: req.DeductionType,
|
||||
DeductionInterval: deductionInterval,
|
||||
DeductionUnit: deductionUnit,
|
||||
DeductionAmount: deductionAmount,
|
||||
AllowRegister: allowRegister,
|
||||
EnableLoginVerify: enableLoginVerify,
|
||||
EnableRegisterVerify: enableRegisterVerify,
|
||||
EnablePasswordReset: enablePasswordReset,
|
||||
PasswordResetMethod: passwordResetMethod,
|
||||
PasswordResetEmailID: passwordResetEmailID,
|
||||
PasswordResetSmsID: passwordResetSmsID,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&app).Error; err != nil {
|
||||
@@ -264,43 +295,50 @@ func generateSecretKey() string {
|
||||
func handleUpdateApplication(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var req struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Description string `form:"description" json:"description"`
|
||||
BillingType string `form:"billing_type" json:"billing_type"`
|
||||
LoginPolicy string `form:"login_policy" json:"login_policy"`
|
||||
AllowRegister bool `form:"allow_register" json:"allow_register"`
|
||||
RegisterMethods string `form:"register_methods" json:"register_methods"`
|
||||
EncryptType string `form:"encrypt_type" json:"encrypt_type"`
|
||||
SecretKey string `form:"secret_key" json:"secret_key"`
|
||||
BindType string `form:"bind_type" json:"bind_type"`
|
||||
MaxDevices int `form:"max_devices" json:"max_devices"`
|
||||
ChangeLimit int `form:"change_limit" json:"change_limit"`
|
||||
ChangeInterval int `form:"change_interval" json:"change_interval"`
|
||||
ChangeExceedAction string `form:"change_exceed_action" json:"change_exceed_action"`
|
||||
ChangeDeductAmount float64 `form:"change_deduct_amount" json:"change_deduct_amount"`
|
||||
MultiOpenMode string `form:"multi_open_mode" json:"multi_open_mode"`
|
||||
MaxInstances int `form:"max_instances" json:"max_instances"`
|
||||
MultiOpen bool `form:"multi_open" json:"multi_open"`
|
||||
EnableTrial bool `form:"enable_trial" json:"enable_trial"`
|
||||
TrialBalance float64 `form:"trial_balance" json:"trial_balance"`
|
||||
TrialDays int `form:"trial_days" json:"trial_days"`
|
||||
EnableFreePeriod bool `form:"enable_free_period" json:"enable_free_period"`
|
||||
FreePeriodType string `form:"free_period_type" json:"free_period_type"`
|
||||
FreePeriodStart string `form:"free_period_start" json:"free_period_start"`
|
||||
FreePeriodEnd string `form:"free_period_end" json:"free_period_end"`
|
||||
FreePeriodWeekdays string `form:"free_period_weekdays" json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `form:"free_period_start_time" json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `form:"free_period_end_time" json:"free_period_end_time"`
|
||||
HeartbeatInterval int `form:"heartbeat_interval" json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `form:"heartbeat_timeout" json:"heartbeat_timeout"`
|
||||
MaxAttempts int `form:"max_attempts" json:"max_attempts"`
|
||||
LockDuration int `form:"lock_duration" json:"lock_duration"`
|
||||
Status string `form:"status" json:"status"`
|
||||
DeductionMode string `form:"deduction_mode" json:"deduction_mode"`
|
||||
DeductionType string `form:"deduction_type" json:"deduction_type"`
|
||||
DeductionInterval int `form:"deduction_interval" json:"deduction_interval"`
|
||||
DeductionUnit string `form:"deduction_unit" json:"deduction_unit"`
|
||||
DeductionAmount float64 `form:"deduction_amount" json:"deduction_amount"`
|
||||
Name string `form:"name" json:"name"`
|
||||
Description string `form:"description" json:"description"`
|
||||
BillingType string `form:"billing_type" json:"billing_type"`
|
||||
LoginPolicy string `form:"login_policy" json:"login_policy"`
|
||||
AllowRegister bool `form:"allow_register" json:"allow_register"`
|
||||
EnableLoginVerify bool `form:"enable_login_verify" json:"enable_login_verify"`
|
||||
EnableRegisterVerify bool `form:"enable_register_verify" json:"enable_register_verify"`
|
||||
VerifyMethod string `form:"verify_method" json:"verify_method"`
|
||||
EmailConfigID *uint `form:"email_config_id" json:"email_config_id"`
|
||||
SmsConfigID *uint `form:"sms_config_id" json:"sms_config_id"`
|
||||
EncryptType string `form:"encrypt_type" json:"encrypt_type"`
|
||||
EncryptKey string `form:"encrypt_key" json:"encrypt_key"`
|
||||
BindType string `form:"bind_type" json:"bind_type"`
|
||||
MaxDevices int `form:"max_devices" json:"max_devices"`
|
||||
ChangeLimit int `form:"change_limit" json:"change_limit"`
|
||||
ChangeInterval int `form:"change_interval" json:"change_interval"`
|
||||
ChangeExceedAction string `form:"change_exceed_action" json:"change_exceed_action"`
|
||||
ChangeDeductAmount float64 `form:"change_deduct_amount" json:"change_deduct_amount"`
|
||||
MultiOpenMode string `form:"multi_open_mode" json:"multi_open_mode"`
|
||||
MaxInstances int `form:"max_instances" json:"max_instances"`
|
||||
EnableTrial bool `form:"enable_trial" json:"enable_trial"`
|
||||
TrialBalance float64 `form:"trial_balance" json:"trial_balance"`
|
||||
TrialDays int `form:"trial_days" json:"trial_days"`
|
||||
EnableFreePeriod bool `form:"enable_free_period" json:"enable_free_period"`
|
||||
FreePeriodType string `form:"free_period_type" json:"free_period_type"`
|
||||
FreePeriodStart string `form:"free_period_start" json:"free_period_start"`
|
||||
FreePeriodEnd string `form:"free_period_end" json:"free_period_end"`
|
||||
FreePeriodWeekdays string `form:"free_period_weekdays" json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `form:"free_period_start_time" json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `form:"free_period_end_time" json:"free_period_end_time"`
|
||||
HeartbeatInterval int `form:"heartbeat_interval" json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `form:"heartbeat_timeout" json:"heartbeat_timeout"`
|
||||
MaxAttempts int `form:"max_attempts" json:"max_attempts"`
|
||||
LockDuration int `form:"lock_duration" json:"lock_duration"`
|
||||
Status string `form:"status" json:"status"`
|
||||
DeductionMode string `form:"deduction_mode" json:"deduction_mode"`
|
||||
DeductionType string `form:"deduction_type" json:"deduction_type"`
|
||||
DeductionInterval int `form:"deduction_interval" json:"deduction_interval"`
|
||||
DeductionUnit string `form:"deduction_unit" json:"deduction_unit"`
|
||||
DeductionAmount float64 `form:"deduction_amount" json:"deduction_amount"`
|
||||
EnablePasswordReset bool `form:"enable_password_reset" json:"enable_password_reset"`
|
||||
PasswordResetMethod string `form:"password_reset_method" json:"password_reset_method"`
|
||||
PasswordResetEmailID *uint `form:"password_reset_email_id" json:"password_reset_email_id"`
|
||||
PasswordResetSmsID *uint `form:"password_reset_sms_id" json:"password_reset_sms_id"`
|
||||
}
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
@@ -315,7 +353,7 @@ func handleUpdateApplication(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("更新应用请求: EncryptType=%s, SecretKey=%s\n", req.EncryptType, req.SecretKey)
|
||||
fmt.Printf("更新应用请求: EncryptType=%s, EncryptKey=%s\n", req.EncryptType, req.EncryptKey)
|
||||
|
||||
id := c.Param("id")
|
||||
var app model.Application
|
||||
@@ -324,17 +362,21 @@ func handleUpdateApplication(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("更新应用前: ID=%d, EncryptType=%s, SecretKey=%s\n", app.ID, app.EncryptType, app.SecretKey)
|
||||
fmt.Printf("更新应用前: ID=%d, EncryptType=%s, EncryptKey=%s\n", app.ID, app.EncryptType, app.EncryptKey)
|
||||
|
||||
app.Name = req.Name
|
||||
app.Description = req.Description
|
||||
app.BillingType = req.BillingType
|
||||
app.LoginPolicy = req.LoginPolicy
|
||||
app.AllowRegister = req.AllowRegister
|
||||
app.RegisterMethods = req.RegisterMethods
|
||||
app.EnableLoginVerify = req.EnableLoginVerify
|
||||
app.EnableRegisterVerify = req.EnableRegisterVerify
|
||||
app.VerifyMethod = req.VerifyMethod
|
||||
app.EmailConfigID = req.EmailConfigID
|
||||
app.SmsConfigID = req.SmsConfigID
|
||||
app.EncryptType = req.EncryptType
|
||||
if req.SecretKey != "" {
|
||||
app.SecretKey = req.SecretKey
|
||||
if req.EncryptKey != "" {
|
||||
app.EncryptKey = req.EncryptKey
|
||||
}
|
||||
app.BindType = req.BindType
|
||||
app.MaxDevices = req.MaxDevices
|
||||
@@ -344,7 +386,6 @@ func handleUpdateApplication(c *gin.Context) {
|
||||
app.ChangeDeductAmount = req.ChangeDeductAmount
|
||||
app.MultiOpenMode = req.MultiOpenMode
|
||||
app.MaxInstances = req.MaxInstances
|
||||
app.MultiOpen = req.MultiOpen
|
||||
app.EnableTrial = req.EnableTrial
|
||||
app.TrialBalance = req.TrialBalance
|
||||
app.TrialDays = req.TrialDays
|
||||
@@ -373,6 +414,12 @@ func handleUpdateApplication(c *gin.Context) {
|
||||
app.DeductionInterval = req.DeductionInterval
|
||||
app.DeductionUnit = req.DeductionUnit
|
||||
app.DeductionAmount = req.DeductionAmount
|
||||
app.EnablePasswordReset = req.EnablePasswordReset
|
||||
if req.PasswordResetMethod != "" {
|
||||
app.PasswordResetMethod = req.PasswordResetMethod
|
||||
}
|
||||
app.PasswordResetEmailID = req.PasswordResetEmailID
|
||||
app.PasswordResetSmsID = req.PasswordResetSmsID
|
||||
|
||||
file, header, err := c.Request.FormFile("icon")
|
||||
if err == nil {
|
||||
@@ -398,7 +445,7 @@ func handleUpdateApplication(c *gin.Context) {
|
||||
app.IconURL = "/uploads/icons/" + filename
|
||||
}
|
||||
|
||||
fmt.Printf("更新应用: ID=%d, EncryptType=%s, SecretKey=%s\n", app.ID, app.EncryptType, app.SecretKey)
|
||||
fmt.Printf("更新应用: ID=%d, EncryptType=%s, EncryptKey=%s\n", app.ID, app.EncryptType, app.EncryptKey)
|
||||
|
||||
if err := database.DB.Save(&app).Error; err != nil {
|
||||
response.Error(c, 500, "更新应用失败")
|
||||
|
||||
@@ -26,6 +26,7 @@ func SetupRoutes(r *gin.RouterGroup) {
|
||||
SetupEmailRoutes(r)
|
||||
SetupSystemSettingsRoutes(r)
|
||||
SetupEmailConfigRoutes(r)
|
||||
SetupSmsConfigRoutes(r)
|
||||
SetupStorageConfigRoutes(r)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -46,9 +46,10 @@ func handleGetEmailConfig(c *gin.Context) {
|
||||
database.DB.Where("application_id = ?", app.ID).First(&smtpConfig)
|
||||
|
||||
result := gin.H{
|
||||
"enable_email_verify": app.EnableEmailVerify,
|
||||
"require_email_verify": app.RequireEmailVerify,
|
||||
"enable_password_reset": app.EnablePasswordReset,
|
||||
"enable_login_verify": app.EnableLoginVerify,
|
||||
"enable_register_verify": app.EnableRegisterVerify,
|
||||
"enable_password_reset": app.EnablePasswordReset,
|
||||
"password_reset_method": app.PasswordResetMethod,
|
||||
"permission": gin.H{
|
||||
"allow_email": permission.AllowEmail,
|
||||
},
|
||||
@@ -90,10 +91,11 @@ func handleUpdateEmailConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
RequireEmailVerify bool `json:"require_email_verify"`
|
||||
EnablePasswordReset bool `json:"enable_password_reset"`
|
||||
SMTPConfig *struct {
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
RequireEmailVerify bool `json:"require_email_verify"`
|
||||
EnablePasswordReset bool `json:"enable_password_reset"`
|
||||
PasswordResetMethod string `json:"password_reset_method"`
|
||||
SMTPConfig *struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
@@ -110,9 +112,10 @@ func handleUpdateEmailConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"enable_email_verify": req.EnableEmailVerify,
|
||||
"require_email_verify": req.RequireEmailVerify,
|
||||
"enable_password_reset": req.EnablePasswordReset,
|
||||
"enable_email_verify": req.EnableEmailVerify,
|
||||
"require_email_verify": req.RequireEmailVerify,
|
||||
"enable_password_reset": req.EnablePasswordReset,
|
||||
"password_reset_method": req.PasswordResetMethod,
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&app).Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"verification-platform-backend/internal/database"
|
||||
"verification-platform-backend/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupSmsConfigRoutes(r *gin.RouterGroup) {
|
||||
smsConfigs := r.Group("/sms-configs")
|
||||
{
|
||||
smsConfigs.GET("", handleGetSmsConfigs)
|
||||
smsConfigs.POST("", handleCreateSmsConfig)
|
||||
smsConfigs.GET("/:id", handleGetSmsConfig)
|
||||
smsConfigs.PUT("/:id", handleUpdateSmsConfig)
|
||||
smsConfigs.DELETE("/:id", handleDeleteSmsConfig)
|
||||
smsConfigs.PUT("/:id/status", handleUpdateSmsConfigStatus)
|
||||
smsConfigs.POST("/:id/test", handleTestSmsConfig)
|
||||
smsConfigs.PUT("/batch/status", handleBatchUpdateSmsConfigStatus)
|
||||
smsConfigs.DELETE("/batch", handleBatchDeleteSmsConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetSmsConfigs(c *gin.Context) {
|
||||
var configs []model.SmsConfig
|
||||
database.DB.Order("id desc").Find(&configs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"sms_configs": configs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type CreateSmsConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Config string `json:"config"`
|
||||
Status string `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleCreateSmsConfig(c *gin.Context) {
|
||||
var req CreateSmsConfigRequest
|
||||
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
|
||||
}
|
||||
|
||||
config := model.SmsConfig{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Config: req.Config,
|
||||
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 handleGetSmsConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.SmsConfig
|
||||
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 UpdateSmsConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config string `json:"config"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func handleUpdateSmsConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.SmsConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "短信配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSmsConfigRequest
|
||||
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.Type != "" {
|
||||
config.Type = req.Type
|
||||
}
|
||||
if req.Config != "" {
|
||||
config.Config = req.Config
|
||||
}
|
||||
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 handleDeleteSmsConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.SmsConfig
|
||||
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 UpdateSmsConfigStatusRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func handleUpdateSmsConfigStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.SmsConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "短信配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSmsConfigStatusRequest
|
||||
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 TestSmsConfigRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
}
|
||||
|
||||
func handleTestSmsConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var config model.SmsConfig
|
||||
if err := database.DB.First(&config, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "短信配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req TestSmsConfigRequest
|
||||
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": "测试短信已发送",
|
||||
})
|
||||
}
|
||||
|
||||
type BatchUpdateSmsConfigStatusRequest struct {
|
||||
IDs []uint `json:"ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func handleBatchUpdateSmsConfigStatus(c *gin.Context) {
|
||||
var req BatchUpdateSmsConfigStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "active" && req.Status != "inactive" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "无效的状态",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "请选择要更新的短信配置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&model.SmsConfig{}).Where("id IN ?", req.IDs).Update("status", req.Status).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "批量更新失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "批量更新成功",
|
||||
})
|
||||
}
|
||||
|
||||
type BatchDeleteSmsConfigsRequest struct {
|
||||
IDs []uint `json:"ids"`
|
||||
}
|
||||
|
||||
func handleBatchDeleteSmsConfigs(c *gin.Context) {
|
||||
var req BatchDeleteSmsConfigsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "请选择要删除的短信配置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Where("id IN ?", req.IDs).Delete(&model.SmsConfig{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "批量删除失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "批量删除成功",
|
||||
})
|
||||
}
|
||||
@@ -42,11 +42,11 @@ func AppCryptoMiddleware() gin.HandlerFunc {
|
||||
|
||||
shouldEncrypt := encryptType != crypto.EncryptTypeNone
|
||||
|
||||
fmt.Printf("[AppCrypto] AppKey: %s, EncryptType: %s, SecretKey: %s, ShouldEncrypt: %v\n",
|
||||
appKey, app.EncryptType, app.SecretKey, shouldEncrypt)
|
||||
fmt.Printf("[AppCrypto] AppKey: %s, EncryptType: %s, EncryptKey: %s, ShouldEncrypt: %v\n",
|
||||
appKey, app.EncryptType, app.EncryptKey, shouldEncrypt)
|
||||
|
||||
c.Set("should_encrypt_response", shouldEncrypt)
|
||||
c.Set("crypto_manager", crypto.NewCryptoManager(encryptType, app.SecretKey))
|
||||
c.Set("crypto_manager", crypto.NewCryptoManager(encryptType, app.EncryptKey))
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func getAppCryptoManager(appKey string) (*crypto.CryptoManager, error) {
|
||||
encryptType = crypto.EncryptTypeNone
|
||||
}
|
||||
|
||||
return crypto.NewCryptoManager(encryptType, app.SecretKey), nil
|
||||
return crypto.NewCryptoManager(encryptType, app.EncryptKey), nil
|
||||
}
|
||||
|
||||
func decryptRequest(c *gin.Context, appKey string) ([]byte, error) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
@@ -96,8 +95,8 @@ func handleAppSendEmailCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
if req.Purpose == "register" {
|
||||
if !app.EnableEmailVerify {
|
||||
response.Error(c, 400, "该应用未启用邮箱验证")
|
||||
if !app.EnableRegisterVerify {
|
||||
response.Error(c, 400, "该应用未启用注册验证")
|
||||
return
|
||||
}
|
||||
} else if req.Purpose == "reset_password" {
|
||||
@@ -203,6 +202,8 @@ func handleAppRegister(c *gin.Context) {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailCode string `json:"email_code"`
|
||||
Phone string `json:"phone"`
|
||||
SmsCode string `json:"sms_code"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
@@ -229,59 +230,60 @@ func handleAppRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var allowedMethods []string
|
||||
if appModel.RegisterMethods != "" {
|
||||
if err := json.Unmarshal([]byte(appModel.RegisterMethods), &allowedMethods); err != nil {
|
||||
log.Printf("[DEBUG] Failed to parse register methods: %v", err)
|
||||
allowedMethods = []string{"username"}
|
||||
if appModel.EnableRegisterVerify {
|
||||
if appModel.VerifyMethod == "email" {
|
||||
if req.Email == "" {
|
||||
response.Error(c, 400, "请输入邮箱地址")
|
||||
return
|
||||
}
|
||||
if req.EmailCode == "" {
|
||||
response.Error(c, 400, "请输入邮箱验证码")
|
||||
return
|
||||
}
|
||||
|
||||
var verifyCode model.EmailVerifyCode
|
||||
if err := database.DB.Where(
|
||||
"application_id = ? AND email = ? AND code = ? AND purpose = ? AND used = ?",
|
||||
appModel.ID, req.Email, req.EmailCode, "register", false,
|
||||
).First(&verifyCode).Error; err != nil {
|
||||
response.Error(c, 400, "验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
if verifyCode.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(c, 400, "验证码已过期")
|
||||
return
|
||||
}
|
||||
|
||||
verifyCode.Used = true
|
||||
database.DB.Save(&verifyCode)
|
||||
} else if appModel.VerifyMethod == "sms" {
|
||||
if req.Phone == "" {
|
||||
response.Error(c, 400, "请输入手机号")
|
||||
return
|
||||
}
|
||||
if req.SmsCode == "" {
|
||||
response.Error(c, 400, "请输入短信验证码")
|
||||
return
|
||||
}
|
||||
|
||||
var verifyCode model.SmsVerifyCode
|
||||
if err := database.DB.Where(
|
||||
"application_id = ? AND phone = ? AND code = ? AND purpose = ? AND used = ?",
|
||||
appModel.ID, req.Phone, req.SmsCode, "register", false,
|
||||
).First(&verifyCode).Error; err != nil {
|
||||
response.Error(c, 400, "验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
if verifyCode.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(c, 400, "验证码已过期")
|
||||
return
|
||||
}
|
||||
|
||||
verifyCode.Used = true
|
||||
database.DB.Save(&verifyCode)
|
||||
}
|
||||
} else {
|
||||
allowedMethods = []string{"username"}
|
||||
}
|
||||
|
||||
isEmailRegister := req.Email != "" && req.Username == ""
|
||||
isUsernameRegister := req.Username != "" && req.Email == ""
|
||||
isPhoneRegister := false
|
||||
|
||||
if isEmailRegister && !containsString(allowedMethods, "email") {
|
||||
response.Error(c, 403, "邮箱注册方式未开放")
|
||||
return
|
||||
}
|
||||
if isUsernameRegister && !containsString(allowedMethods, "username") {
|
||||
response.Error(c, 403, "用户名注册方式未开放")
|
||||
return
|
||||
}
|
||||
if isPhoneRegister && !containsString(allowedMethods, "phone") {
|
||||
response.Error(c, 403, "手机号注册方式未开放")
|
||||
return
|
||||
}
|
||||
|
||||
if appModel.EnableEmailVerify && appModel.RequireEmailVerify {
|
||||
if req.Email == "" {
|
||||
response.Error(c, 400, "请输入邮箱地址")
|
||||
return
|
||||
}
|
||||
if req.EmailCode == "" {
|
||||
response.Error(c, 400, "请输入邮箱验证码")
|
||||
return
|
||||
}
|
||||
|
||||
var verifyCode model.EmailVerifyCode
|
||||
if err := database.DB.Where(
|
||||
"application_id = ? AND email = ? AND code = ? AND purpose = ? AND used = ?",
|
||||
appModel.ID, req.Email, req.EmailCode, "register", false,
|
||||
).First(&verifyCode).Error; err != nil {
|
||||
response.Error(c, 400, "验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
if verifyCode.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(c, 400, "验证码已过期")
|
||||
return
|
||||
}
|
||||
|
||||
verifyCode.Used = true
|
||||
database.DB.Save(&verifyCode)
|
||||
}
|
||||
|
||||
var user model.AppUser
|
||||
@@ -609,40 +611,38 @@ func handleAppLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
if device.ID > 0 {
|
||||
if appModel.MultiOpen && appModel.MaxInstances > 0 {
|
||||
if appModel.MultiOpenMode != "unlimited" && appModel.MaxInstances > 0 {
|
||||
var sessionCount int64
|
||||
database.DB.Model(&model.DeviceSession{}).
|
||||
Where("device_id = ? AND instance_id != ? AND last_heartbeat > ?", device.ID, instanceID, timeoutThreshold).
|
||||
Count(&sessionCount)
|
||||
|
||||
if sessionCount >= int64(appModel.MaxInstances) {
|
||||
if appModel.MultiOpenMode == "forbidden" {
|
||||
log.Printf("[DEBUG] Multi-instance limit exceeded for user %d device %s: %d/%d", user.ID, req.DeviceID, sessionCount, appModel.MaxInstances)
|
||||
|
||||
var sessions []model.DeviceSession
|
||||
database.DB.Where("device_id = ? AND instance_id != ?", device.ID, instanceID).Order("last_heartbeat DESC").Find(&sessions)
|
||||
|
||||
sessionList := make([]gin.H, 0)
|
||||
for _, s := range sessions {
|
||||
isOnline := s.LastHeartbeat != nil && s.LastHeartbeat.After(timeoutThreshold)
|
||||
sessionList = append(sessionList, gin.H{
|
||||
"id": s.ID,
|
||||
"instance_id": s.InstanceID,
|
||||
"is_online": isOnline,
|
||||
"last_heartbeat": s.LastHeartbeat,
|
||||
"created_at": s.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
response.ErrorWithData(c, 403, "多开数量已达上限", gin.H{
|
||||
"error_code": "MULTI_INSTANCE_LIMIT_EXCEEDED",
|
||||
"max_instances": appModel.MaxInstances,
|
||||
"instance_count": sessionCount,
|
||||
"instances": sessionList,
|
||||
log.Printf("[DEBUG] Multi-instance limit exceeded for user %d device %s: %d/%d", user.ID, req.DeviceID, sessionCount, appModel.MaxInstances)
|
||||
|
||||
var sessions []model.DeviceSession
|
||||
database.DB.Where("device_id = ? AND instance_id != ?", device.ID, instanceID).Order("last_heartbeat DESC").Find(&sessions)
|
||||
|
||||
sessionList := make([]gin.H, 0)
|
||||
for _, s := range sessions {
|
||||
isOnline := s.LastHeartbeat != nil && s.LastHeartbeat.After(timeoutThreshold)
|
||||
sessionList = append(sessionList, gin.H{
|
||||
"id": s.ID,
|
||||
"instance_id": s.InstanceID,
|
||||
"is_online": isOnline,
|
||||
"last_heartbeat": s.LastHeartbeat,
|
||||
"created_at": s.CreatedAt,
|
||||
})
|
||||
service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: 多开数量已达上限 - "+req.Username, req.DeviceID, fmt.Errorf("多开数量已达上限"))
|
||||
return
|
||||
}
|
||||
|
||||
response.ErrorWithData(c, 403, "多开数量已达上限", gin.H{
|
||||
"error_code": "MULTI_INSTANCE_LIMIT_EXCEEDED",
|
||||
"max_instances": appModel.MaxInstances,
|
||||
"instance_count": sessionCount,
|
||||
"instances": sessionList,
|
||||
})
|
||||
service.LogVerification(c, &appModel.ID, &user.ID, "login_failed", "登录失败: 多开数量已达上限 - "+req.Username, req.DeviceID, fmt.Errorf("多开数量已达上限"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ func handleExecuteDynamicCode(c *gin.Context) {
|
||||
"free_period_end_time": app.FreePeriodEndTime,
|
||||
"max_devices": app.MaxDevices,
|
||||
"bind_type": app.BindType,
|
||||
"multi_open": app.MultiOpen,
|
||||
"multi_open_mode": app.MultiOpenMode,
|
||||
"max_instances": app.MaxInstances,
|
||||
"login_policy": app.LoginPolicy,
|
||||
|
||||
@@ -37,7 +37,6 @@ func handleAppGetInfo(c *gin.Context) {
|
||||
"billing_type": app.BillingType,
|
||||
"login_policy": app.LoginPolicy,
|
||||
"max_devices": app.MaxDevices,
|
||||
"multi_open": app.MultiOpen,
|
||||
"multi_open_mode": app.MultiOpenMode,
|
||||
"max_instances": app.MaxInstances,
|
||||
"enable_trial": app.EnableTrial,
|
||||
|
||||
@@ -713,7 +713,7 @@ func handleGetAppInfo(c *gin.Context) {
|
||||
"encrypt_type": app.EncryptType,
|
||||
"bind_type": app.BindType,
|
||||
"max_devices": app.MaxDevices,
|
||||
"multi_open": app.MultiOpen,
|
||||
"multi_open_mode": app.MultiOpenMode,
|
||||
"enable_trial": app.EnableTrial,
|
||||
"trial_balance": app.TrialBalance,
|
||||
"status": app.Status,
|
||||
|
||||
@@ -67,43 +67,45 @@ func GetApplicationDisabledStatus(appID uint) bool {
|
||||
|
||||
type ApplicationWithStatus struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AppKey string `json:"app_key"`
|
||||
IconURL string `json:"icon_url"`
|
||||
Status string `json:"status"`
|
||||
BillingType string `json:"billing_type"`
|
||||
LoginPolicy string `json:"login_policy"`
|
||||
MaxDevices int `json:"max_devices"`
|
||||
MultiOpen bool `json:"multi_open"`
|
||||
MultiOpenMode string `json:"multi_open_mode"`
|
||||
EnableTrial bool `json:"enable_trial"`
|
||||
TrialDays int `json:"trial_days"`
|
||||
EnableFreePeriod bool `json:"enable_free_period"`
|
||||
FreePeriodType string `json:"free_period_type"`
|
||||
FreePeriodStart string `json:"free_period_start"`
|
||||
FreePeriodEnd string `json:"free_period_end"`
|
||||
FreePeriodWeekdays string `json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `json:"free_period_end_time"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `json:"heartbeat_timeout"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
LockDuration int `json:"lock_duration"`
|
||||
DeductionMode string `json:"deduction_mode"`
|
||||
DeductionType string `json:"deduction_type"`
|
||||
DeductionInterval int `json:"deduction_interval"`
|
||||
DeductionUnit string `json:"deduction_unit"`
|
||||
DeductionAmount float64 `json:"deduction_amount"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
RegisterMethods string `json:"register_methods"`
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
RequireEmailVerify bool `json:"require_email_verify"`
|
||||
EnablePasswordReset bool `json:"enable_password_reset"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
DisabledByPackage bool `json:"disabled_by_package"`
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AppKey string `json:"app_key"`
|
||||
IconURL string `json:"icon_url"`
|
||||
Status string `json:"status"`
|
||||
BillingType string `json:"billing_type"`
|
||||
LoginPolicy string `json:"login_policy"`
|
||||
MaxDevices int `json:"max_devices"`
|
||||
MultiOpenMode string `json:"multi_open_mode"`
|
||||
EnableTrial bool `json:"enable_trial"`
|
||||
TrialDays int `json:"trial_days"`
|
||||
EnableFreePeriod bool `json:"enable_free_period"`
|
||||
FreePeriodType string `json:"free_period_type"`
|
||||
FreePeriodStart string `json:"free_period_start"`
|
||||
FreePeriodEnd string `json:"free_period_end"`
|
||||
FreePeriodWeekdays string `json:"free_period_weekdays"`
|
||||
FreePeriodStartTime string `json:"free_period_start_time"`
|
||||
FreePeriodEndTime string `json:"free_period_end_time"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
HeartbeatTimeout int `json:"heartbeat_timeout"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
LockDuration int `json:"lock_duration"`
|
||||
DeductionMode string `json:"deduction_mode"`
|
||||
DeductionType string `json:"deduction_type"`
|
||||
DeductionInterval int `json:"deduction_interval"`
|
||||
DeductionUnit string `json:"deduction_unit"`
|
||||
DeductionAmount float64 `json:"deduction_amount"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
EnableLoginVerify bool `json:"enable_login_verify"`
|
||||
EnableRegisterVerify bool `json:"enable_register_verify"`
|
||||
VerifyMethod string `json:"verify_method"`
|
||||
EnablePasswordReset bool `json:"enable_password_reset"`
|
||||
PasswordResetMethod string `json:"password_reset_method"`
|
||||
PasswordResetEmailID *uint `json:"password_reset_email_id"`
|
||||
PasswordResetSmsID *uint `json:"password_reset_sms_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
DisabledByPackage bool `json:"disabled_by_package"`
|
||||
}
|
||||
|
||||
func GetApplicationsWithDisabledStatus(userID uint) ([]ApplicationWithStatus, error) {
|
||||
@@ -130,7 +132,6 @@ func GetApplicationsWithDisabledStatus(userID uint) ([]ApplicationWithStatus, er
|
||||
BillingType: app.BillingType,
|
||||
LoginPolicy: app.LoginPolicy,
|
||||
MaxDevices: app.MaxDevices,
|
||||
MultiOpen: app.MultiOpen,
|
||||
MultiOpenMode: app.MultiOpenMode,
|
||||
EnableTrial: app.EnableTrial,
|
||||
TrialDays: app.TrialDays,
|
||||
@@ -151,10 +152,13 @@ func GetApplicationsWithDisabledStatus(userID uint) ([]ApplicationWithStatus, er
|
||||
DeductionUnit: app.DeductionUnit,
|
||||
DeductionAmount: app.DeductionAmount,
|
||||
AllowRegister: app.AllowRegister,
|
||||
RegisterMethods: app.RegisterMethods,
|
||||
EnableEmailVerify: app.EnableEmailVerify,
|
||||
RequireEmailVerify: app.RequireEmailVerify,
|
||||
EnableLoginVerify: app.EnableLoginVerify,
|
||||
EnableRegisterVerify: app.EnableRegisterVerify,
|
||||
VerifyMethod: app.VerifyMethod,
|
||||
EnablePasswordReset: app.EnablePasswordReset,
|
||||
PasswordResetMethod: app.PasswordResetMethod,
|
||||
PasswordResetEmailID: app.PasswordResetEmailID,
|
||||
PasswordResetSmsID: app.PasswordResetSmsID,
|
||||
CreatedAt: app.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: app.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
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 { Boxes, Code, CreditCard, DollarSign, FileLock, Gauge, GitBranch, HardDrive, Hash, Key, Mail, Megaphone, MessageSquare, Network, Plug, ScrollText, Settings, Shield, Smartphone, Users, Variable } from 'lucide-vue-next'
|
||||
import { onMounted, onUnmounted, reactive } from 'vue'
|
||||
|
||||
import NavTeam from '@/components/app-sidebar/nav-team.vue'
|
||||
@@ -193,6 +193,11 @@ const navMain = [
|
||||
url: '/admin/email-settings',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
title: '短信配置',
|
||||
url: '/admin/sms-settings',
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
title: '存储管理',
|
||||
url: '/admin/storage-configs',
|
||||
|
||||
@@ -38,10 +38,10 @@ const breadcrumbs = computed(() => {
|
||||
'system-settings': '系统设置',
|
||||
'payment-channels': '支付渠道',
|
||||
'email-settings': '邮箱配置',
|
||||
'sms-settings': '短信配置',
|
||||
'storage-configs': '存储管理',
|
||||
settings: '基本设置',
|
||||
security: '安全设置',
|
||||
email: '邮箱设置',
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
recharge: '充值',
|
||||
|
||||
@@ -95,10 +95,6 @@ function goToSecurity(app: App) {
|
||||
router.push(`/admin/applications/${app.id}/security`)
|
||||
}
|
||||
|
||||
function goToEmail(app: App) {
|
||||
router.push(`/admin/applications/${app.id}/email`)
|
||||
}
|
||||
|
||||
async function toggleStatus(app: App) {
|
||||
try {
|
||||
const newStatus = app.status === 'active' ? 'inactive' : 'active'
|
||||
@@ -221,7 +217,6 @@ onMounted(() => {
|
||||
@refresh="fetchApps"
|
||||
@go-to-settings="goToSettings"
|
||||
@go-to-security="goToSecurity"
|
||||
@go-to-email="goToEmail"
|
||||
@toggle-status="toggleStatus"
|
||||
@delete="confirmDeleteApp"
|
||||
@update:search-filter="searchFilter = $event"
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
|
||||
interface Permission {
|
||||
allow_email_verify: boolean
|
||||
allow_password_reset: boolean
|
||||
allow_custom_smtp: boolean
|
||||
allow_custom_template: boolean
|
||||
}
|
||||
|
||||
interface SMTPConfig {
|
||||
id: number
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
from_name: string
|
||||
from_email: string
|
||||
use_ssl: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface EmailConfig {
|
||||
enable_email_verify: boolean
|
||||
require_email_verify: boolean
|
||||
enable_password_reset: boolean
|
||||
permission: Permission
|
||||
smtp_config: SMTPConfig | null
|
||||
}
|
||||
|
||||
interface EmailTemplate {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
subject: string
|
||||
content: string
|
||||
is_default: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
const config = ref<EmailConfig | null>(null)
|
||||
const templates = ref<EmailTemplate[]>([])
|
||||
|
||||
const form = ref({
|
||||
enable_email_verify: false,
|
||||
require_email_verify: false,
|
||||
enable_password_reset: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
smtp_from_name: '',
|
||||
smtp_from_email: '',
|
||||
smtp_use_ssl: true,
|
||||
})
|
||||
|
||||
const testEmail = ref('')
|
||||
const sendCodeEmail = ref('')
|
||||
const sendCodePurpose = ref('register')
|
||||
|
||||
const _canUseEmailVerify = computed(() => config.value?.permission?.allow_email_verify)
|
||||
const canUsePasswordReset = computed(() => config.value?.permission?.allow_password_reset)
|
||||
const _canUseCustomSMTP = computed(() => config.value?.permission?.allow_custom_smtp)
|
||||
const canUseCustomTemplate = computed(() => config.value?.permission?.allow_custom_template)
|
||||
|
||||
async function fetchConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
config.value = data.data
|
||||
form.value.enable_email_verify = data.data.enable_email_verify || false
|
||||
form.value.require_email_verify = data.data.require_email_verify || false
|
||||
form.value.enable_password_reset = data.data.enable_password_reset || false
|
||||
|
||||
if (data.data.smtp_config) {
|
||||
form.value.smtp_host = data.data.smtp_config.host || ''
|
||||
form.value.smtp_port = data.data.smtp_config.port || 465
|
||||
form.value.smtp_user = data.data.smtp_config.user || ''
|
||||
form.value.smtp_from_name = data.data.smtp_config.from_name || ''
|
||||
form.value.smtp_from_email = data.data.smtp_config.from_email || ''
|
||||
form.value.smtp_use_ssl = data.data.smtp_config.use_ssl ?? true
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '获取配置失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
toast.error('获取配置失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-templates`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
templates.value = data.data || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取模板失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enable_email_verify: form.value.enable_email_verify,
|
||||
require_email_verify: form.value.require_email_verify,
|
||||
enable_password_reset: form.value.enable_password_reset,
|
||||
smtp_config: {
|
||||
host: form.value.smtp_host,
|
||||
port: form.value.smtp_port,
|
||||
user: form.value.smtp_user,
|
||||
password: form.value.smtp_password,
|
||||
from_name: form.value.smtp_from_name,
|
||||
from_email: form.value.smtp_from_email,
|
||||
use_ssl: form.value.smtp_use_ssl,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchConfig()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestEmail() {
|
||||
if (!testEmail.value) {
|
||||
toast.error('请输入测试邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: testEmail.value,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('测试邮件已发送,请检查收件箱')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
toast.error('发送失败')
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendVerifyCode() {
|
||||
if (!sendCodeEmail.value) {
|
||||
toast.error('请输入邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/send-verify-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: sendCodeEmail.value,
|
||||
purpose: sendCodePurpose.value,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
toast.error('发送失败')
|
||||
}
|
||||
finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="邮箱设置"
|
||||
description="配置应用的邮箱验证相关设置"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/admin/applications' },
|
||||
{ title: '邮箱设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>邮箱验证设置</UiCardTitle>
|
||||
<UiCardDescription>配置应用的邮箱验证功能</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<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:checked="form.enable_email_verify" />
|
||||
</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:checked="form.require_email_verify" :disabled="!form.enable_email_verify" />
|
||||
</div>
|
||||
|
||||
<div v-if="canUsePasswordReset" 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:checked="form.enable_password_reset" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>SMTP 配置</UiCardTitle>
|
||||
<UiCardDescription>配置邮件发送服务器</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_host">
|
||||
SMTP 服务器
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_host" v-model="form.smtp_host" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_port">
|
||||
端口
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_port" v-model.number="form.smtp_port" type="number" placeholder="465" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_user">
|
||||
用户名
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_user" v-model="form.smtp_user" placeholder="your@email.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_password">
|
||||
密码/授权码
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_password" v-model="form.smtp_password" type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_name">
|
||||
发件人名称
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_from_name" v-model="form.smtp_from_name" placeholder="应用名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_email">
|
||||
发件人邮箱
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_from_email" v-model="form.smtp_from_email" placeholder="noreply@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>使用 SSL</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
推荐开启,端口 465 通常需要 SSL
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.smtp_use_ssl" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4 pt-4 border-t">
|
||||
<div class="flex-1 space-y-2">
|
||||
<UiLabel for="test_email">
|
||||
测试邮箱
|
||||
</UiLabel>
|
||||
<UiInput id="test_email" v-model="testEmail" type="email" placeholder="test@example.com" />
|
||||
</div>
|
||||
<UiButton :disabled="testing" @click="handleTestEmail">
|
||||
<Icon v-if="testing" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
发送测试邮件
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>发送验证码测试</UiCardTitle>
|
||||
<UiCardDescription>测试验证码发送功能</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="send_code_email">
|
||||
邮箱地址
|
||||
</UiLabel>
|
||||
<UiInput id="send_code_email" v-model="sendCodeEmail" type="email" placeholder="user@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="send_code_purpose">
|
||||
用途
|
||||
</UiLabel>
|
||||
<UiSelect v-model="sendCodePurpose">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择用途" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="register">
|
||||
注册验证
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="reset_password">
|
||||
重置密码
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="change_email">
|
||||
更换邮箱
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<UiButton :disabled="sendingCode" @click="handleSendVerifyCode">
|
||||
<Icon v-if="sendingCode" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
发送验证码
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="canUseCustomTemplate">
|
||||
<UiCardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<UiCardTitle>邮件模板</UiCardTitle>
|
||||
<UiCardDescription>自定义邮件模板</UiCardDescription>
|
||||
</div>
|
||||
<UiButton size="sm">
|
||||
<Icon icon="lucide:plus" class="mr-2 size-4" />
|
||||
新建模板
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="templates.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:mail" class="size-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无自定义模板,将使用系统默认模板</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="flex items-center justify-between p-4 rounded-lg border"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ template.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ template.subject }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiBadge v-if="template.is_default" variant="secondary">
|
||||
默认
|
||||
</UiBadge>
|
||||
<UiButton variant="ghost" size="icon">
|
||||
<Icon icon="lucide:pencil" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -16,7 +16,7 @@ const saving = ref(false)
|
||||
interface AppData {
|
||||
id: number
|
||||
encrypt_type: string
|
||||
secret_key: string
|
||||
encrypt_key: string
|
||||
bind_type: string
|
||||
max_devices: number
|
||||
change_limit: number
|
||||
@@ -35,7 +35,7 @@ const app = ref<AppData | null>(null)
|
||||
|
||||
const form = ref({
|
||||
encrypt_type: 'none',
|
||||
secret_key: '',
|
||||
encrypt_key: '',
|
||||
bind_type: 'none',
|
||||
max_devices: 1,
|
||||
change_limit: 3,
|
||||
@@ -66,7 +66,7 @@ async function fetchApp() {
|
||||
app.value = appData
|
||||
form.value = {
|
||||
encrypt_type: appData.encrypt_type || 'none',
|
||||
secret_key: appData.secret_key || '',
|
||||
encrypt_key: appData.encrypt_key || '',
|
||||
bind_type: appData.bind_type || 'none',
|
||||
max_devices: appData.max_devices || 1,
|
||||
change_limit: appData.change_limit || 3,
|
||||
@@ -91,13 +91,13 @@ async function fetchApp() {
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecretKey() {
|
||||
function generateEncryptKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
form.value.secret_key = result
|
||||
form.value.encrypt_key = result
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -211,15 +211,15 @@ onMounted(() => {
|
||||
|
||||
<div v-if="form.encrypt_type !== 'none'" class="space-y-2 pt-4 border-t">
|
||||
<div class="flex items-center justify-between">
|
||||
<UiLabel for="secret_key">
|
||||
<UiLabel for="encrypt_key">
|
||||
加密密钥
|
||||
</UiLabel>
|
||||
<UiButton variant="outline" size="sm" @click="generateSecretKey">
|
||||
<UiButton variant="outline" size="sm" @click="generateEncryptKey">
|
||||
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
|
||||
自动生成
|
||||
</UiButton>
|
||||
</div>
|
||||
<UiInput id="secret_key" v-model="form.secret_key" placeholder="请输入加密密钥" />
|
||||
<UiInput id="encrypt_key" v-model="form.encrypt_key" placeholder="请输入加密密钥" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
请妥善保管密钥,丢失后无法恢复
|
||||
</p>
|
||||
|
||||
@@ -13,6 +13,18 @@ const API_BASE = 'http://localhost:8080/api/v1'
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
interface EmailConfig {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SmsConfig {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AppData {
|
||||
id: number
|
||||
name: string
|
||||
@@ -21,7 +33,11 @@ interface AppData {
|
||||
billing_type: string
|
||||
login_policy: string
|
||||
allow_register: boolean
|
||||
register_methods: string
|
||||
enable_login_verify: boolean
|
||||
enable_register_verify: boolean
|
||||
verify_method: string
|
||||
email_config_id: number | null
|
||||
sms_config_id: number | null
|
||||
enable_trial: boolean
|
||||
trial_balance: number
|
||||
enable_free_period: boolean
|
||||
@@ -37,6 +53,8 @@ interface AppData {
|
||||
}
|
||||
|
||||
const app = ref<AppData | null>(null)
|
||||
const emailConfigs = ref<EmailConfig[]>([])
|
||||
const smsConfigs = ref<SmsConfig[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
@@ -47,7 +65,15 @@ const form = ref({
|
||||
billing_type: 'free',
|
||||
login_policy: 'loose',
|
||||
allow_register: true,
|
||||
register_methods: [] as string[],
|
||||
enable_login_verify: false,
|
||||
enable_register_verify: false,
|
||||
verify_method: 'email',
|
||||
email_config_id: null as number | null,
|
||||
sms_config_id: null as number | null,
|
||||
enable_password_reset: false,
|
||||
password_reset_method: 'email',
|
||||
password_reset_email_id: null as number | null,
|
||||
password_reset_sms_id: null as number | null,
|
||||
enable_trial: false,
|
||||
trial_balance: 0,
|
||||
trial_days: 0,
|
||||
@@ -82,22 +108,6 @@ function weekdaysToString(arr: number[]): string {
|
||||
return JSON.stringify(arr)
|
||||
}
|
||||
|
||||
function parseRegisterMethods(str: string): string[] {
|
||||
if (!str)
|
||||
return ['username']
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return Array.isArray(parsed) ? parsed : ['username']
|
||||
}
|
||||
catch {
|
||||
return ['username']
|
||||
}
|
||||
}
|
||||
|
||||
function registerMethodsToString(arr: string[]): string {
|
||||
return JSON.stringify(arr)
|
||||
}
|
||||
|
||||
function getIconUrl(url: string): string {
|
||||
if (!url)
|
||||
return ''
|
||||
@@ -121,6 +131,42 @@ function clearIcon() {
|
||||
form.value.icon_url = ''
|
||||
}
|
||||
|
||||
async function fetchEmailConfigs() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/email-configs`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
emailConfigs.value = data.data?.email_configs?.filter((c: EmailConfig) => c.status === 'active') || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取邮箱配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSmsConfigs() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/sms-configs`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
smsConfigs.value = data.data?.sms_configs?.filter((c: SmsConfig) => c.status === 'active') || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取短信配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -144,7 +190,15 @@ async function fetchApp() {
|
||||
billing_type: appData.billing_type || 'free',
|
||||
login_policy: appData.login_policy || 'loose',
|
||||
allow_register: appData.allow_register ?? true,
|
||||
register_methods: parseRegisterMethods(appData.register_methods),
|
||||
enable_login_verify: appData.enable_login_verify || false,
|
||||
enable_register_verify: appData.enable_register_verify || false,
|
||||
verify_method: appData.verify_method || 'email',
|
||||
email_config_id: appData.email_config_id || null,
|
||||
sms_config_id: appData.sms_config_id || null,
|
||||
enable_password_reset: appData.enable_password_reset || false,
|
||||
password_reset_method: appData.password_reset_method || 'email',
|
||||
password_reset_email_id: appData.password_reset_email_id || null,
|
||||
password_reset_sms_id: appData.password_reset_sms_id || null,
|
||||
enable_trial: appData.enable_trial || false,
|
||||
trial_balance: appData.trial_balance || 0,
|
||||
trial_days: appData.trial_days || 0,
|
||||
@@ -183,7 +237,15 @@ async function handleSave() {
|
||||
formDataToSend.append('billing_type', form.value.billing_type)
|
||||
formDataToSend.append('login_policy', form.value.login_policy)
|
||||
formDataToSend.append('allow_register', form.value.allow_register.toString())
|
||||
formDataToSend.append('register_methods', registerMethodsToString(form.value.register_methods))
|
||||
formDataToSend.append('enable_login_verify', form.value.enable_login_verify.toString())
|
||||
formDataToSend.append('enable_register_verify', form.value.enable_register_verify.toString())
|
||||
formDataToSend.append('verify_method', form.value.verify_method)
|
||||
if (form.value.email_config_id) {
|
||||
formDataToSend.append('email_config_id', form.value.email_config_id.toString())
|
||||
}
|
||||
if (form.value.sms_config_id) {
|
||||
formDataToSend.append('sms_config_id', form.value.sms_config_id.toString())
|
||||
}
|
||||
formDataToSend.append('enable_trial', form.value.enable_trial.toString())
|
||||
formDataToSend.append('trial_balance', form.value.trial_balance.toString())
|
||||
formDataToSend.append('trial_days', form.value.trial_days.toString())
|
||||
@@ -232,6 +294,8 @@ async function handleSave() {
|
||||
|
||||
onMounted(() => {
|
||||
fetchApp()
|
||||
fetchEmailConfigs()
|
||||
fetchSmsConfigs()
|
||||
})
|
||||
|
||||
function toggleWeekday(index: number) {
|
||||
@@ -442,7 +506,7 @@ function toggleWeekday(index: number) {
|
||||
<UiRadioGroupItem value="auto" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
⚡ 自动扣费
|
||||
自动扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
系统自动触发扣费
|
||||
@@ -458,7 +522,7 @@ function toggleWeekday(index: number) {
|
||||
<UiRadioGroupItem value="manual" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
🔧 手动扣费
|
||||
手动扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
通过 API 自行控制扣费
|
||||
@@ -629,7 +693,7 @@ function toggleWeekday(index: number) {
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>注册设置</UiCardTitle>
|
||||
<UiCardDescription>配置用户注册方式和权限</UiCardDescription>
|
||||
<UiCardDescription>配置用户注册权限</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -643,69 +707,216 @@ function toggleWeekday(index: number) {
|
||||
</div>
|
||||
<UiSwitch v-model="form.allow_register" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div v-if="form.allow_register" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>注册方式</UiLabel>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
选择允许用户使用的注册方式
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>验证设置</UiCardTitle>
|
||||
<UiCardDescription>配置登录和注册的验证方式</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
登录验证
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-username"
|
||||
:checked="form.register_methods.includes('username')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('username')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'username')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-username" class="font-normal cursor-pointer">
|
||||
用户名注册
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户登录时需要进行验证
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_login_verify" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
注册验证
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户注册时需要进行验证
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_register_verify" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.enable_login_verify || form.enable_register_verify" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>验证方式</UiLabel>
|
||||
<UiRadioGroup v-model="form.verify_method" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.verify_method === 'email' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.verify_method = 'email'"
|
||||
>
|
||||
<UiRadioGroupItem value="email" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
邮箱验证
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
发送验证码到邮箱
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-email"
|
||||
:checked="form.register_methods.includes('email')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('email')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'email')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-email" class="font-normal cursor-pointer">
|
||||
邮箱注册
|
||||
</UiLabel>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.verify_method === 'sms' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.verify_method = 'sms'"
|
||||
>
|
||||
<UiRadioGroupItem value="sms" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
短信验证
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
发送验证码到手机
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-phone"
|
||||
:checked="form.register_methods.includes('phone')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('phone')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'phone')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-phone" class="font-normal cursor-pointer">
|
||||
手机号注册
|
||||
</UiLabel>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.verify_method === 'email'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>邮箱配置</UiLabel>
|
||||
<UiSelect v-model="form.email_config_id" @update:model-value="(val: number | null) => form.email_config_id = val">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择邮箱配置" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="config in emailConfigs" :key="config.id" :value="config.id">
|
||||
{{ config.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择用于发送验证码的邮箱配置
|
||||
</p>
|
||||
<div v-if="emailConfigs.length === 0" class="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
暂无可用的邮箱配置,请先在「系统设置 - 邮箱配置」中添加并启用邮箱配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.verify_method === 'sms'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>短信配置</UiLabel>
|
||||
<UiSelect v-model="form.sms_config_id" @update:model-value="(val: number | null) => form.sms_config_id = val">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择短信配置" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="config in smsConfigs" :key="config.id" :value="config.id">
|
||||
{{ config.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择用于发送验证码的短信配置
|
||||
</p>
|
||||
<div v-if="smsConfigs.length === 0" class="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
暂无可用的短信配置,请先在「系统设置 - 短信配置」中添加并启用短信配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
密码重置
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户忘记密码时通过邮箱或短信重置
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_password_reset" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.enable_password_reset" class="space-y-4 pt-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>验证方式</UiLabel>
|
||||
<UiRadioGroup v-model="form.password_reset_method" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.password_reset_method === 'email' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.password_reset_method = 'email'"
|
||||
>
|
||||
<UiRadioGroupItem value="email" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
邮箱验证
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
发送验证码到邮箱
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.password_reset_method === 'sms' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.password_reset_method = 'sms'"
|
||||
>
|
||||
<UiRadioGroupItem value="sms" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
短信验证
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
发送验证码到手机
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.password_reset_method === 'email'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>邮箱配置</UiLabel>
|
||||
<UiSelect v-model="form.password_reset_email_id" @update:model-value="(val: number | null) => form.password_reset_email_id = val">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择邮箱配置" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="config in emailConfigs" :key="config.id" :value="config.id">
|
||||
{{ config.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择用于发送密码重置邮件的邮箱配置
|
||||
</p>
|
||||
<div v-if="emailConfigs.length === 0" class="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
暂无可用的邮箱配置,请先在「系统设置 - 邮箱配置」中添加并启用邮箱配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.password_reset_method === 'sms'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>短信配置</UiLabel>
|
||||
<UiSelect v-model="form.password_reset_sms_id" @update:model-value="(val: number | null) => form.password_reset_sms_id = val">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择短信配置" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="config in smsConfigs" :key="config.id" :value="config.id">
|
||||
{{ config.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择用于发送密码重置短信的短信配置
|
||||
</p>
|
||||
<div v-if="smsConfigs.length === 0" class="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
暂无可用的短信配置,请先在「系统设置 - 短信配置」中添加并启用短信配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
至少选择一种注册方式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Composer } from 'vue-i18n'
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
export function getColumns(actions: {
|
||||
onGoToSettings: (app: App) => void
|
||||
onGoToSecurity: (app: App) => void
|
||||
onGoToEmail: (app: App) => void
|
||||
onToggleStatus: (app: App) => void
|
||||
onDelete: (app: App) => void
|
||||
}, t: Composer['t']): ColumnDef<App>[] {
|
||||
@@ -181,10 +179,6 @@ export function getColumns(actions: {
|
||||
h(Shield, { class: 'mr-2 h-4 w-4' }),
|
||||
'安全设置',
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onGoToEmail(app) }, () => [
|
||||
h(Mail, { class: 'mr-2 h-4 w-4' }),
|
||||
'邮箱设置',
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(app) }, () => [
|
||||
isActive ? h(Ban, { class: 'mr-2 h-4 w-4' }) : h(CheckCircle, { class: 'mr-2 h-4 w-4' }),
|
||||
|
||||
@@ -21,7 +21,6 @@ const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'goToSettings': [app: App]
|
||||
'goToSecurity': [app: App]
|
||||
'goToEmail': [app: App]
|
||||
'toggleStatus': [app: App]
|
||||
'delete': [app: App]
|
||||
'update:searchFilter': [value: string]
|
||||
@@ -34,7 +33,6 @@ const columns = computed(() => [
|
||||
...getColumns({
|
||||
onGoToSettings: (app: App) => emit('goToSettings', app),
|
||||
onGoToSecurity: (app: App) => emit('goToSecurity', app),
|
||||
onGoToEmail: (app: App) => emit('goToEmail', app),
|
||||
onToggleStatus: (app: App) => emit('toggleStatus', app),
|
||||
onDelete: (app: App) => emit('delete', app),
|
||||
}, t),
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
const loading = ref(false)
|
||||
const showSuccessDialog = ref(false)
|
||||
const createdAppId = ref<number | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
billing_type: 'free' as 'free' | 'subscription' | 'time' | 'count',
|
||||
login_policy: 'loose' as 'loose' | 'strict' | 'hybrid',
|
||||
deduction_cycle: 'per_use' as 'per_use' | 'per_minute' | 'per_hour' | 'per_day',
|
||||
deduction_amount: 1,
|
||||
billing_type: 'free' as 'free' | 'subscription' | 'balance',
|
||||
iconFile: null as File | null,
|
||||
iconPreview: '',
|
||||
encrypt_type: 'none' as 'none' | 'aes' | 'rsa' | 'rc4',
|
||||
secret_key: '',
|
||||
bind_type: 'device' as 'device' | 'account' | 'none',
|
||||
max_devices: 1,
|
||||
multi_open: false,
|
||||
enable_trial: false,
|
||||
trial_balance: 0,
|
||||
enable_free_period: false,
|
||||
free_period_type: 'range' as 'range' | 'weekly',
|
||||
free_period_start: '',
|
||||
free_period_end: '',
|
||||
free_period_weekdays: [] as number[],
|
||||
free_period_start_time: '',
|
||||
free_period_end_time: '',
|
||||
})
|
||||
|
||||
const billingOptions = [
|
||||
{ value: 'free', label: '免费模式', desc: '用户无需付费', icon: 'lucide:gift' },
|
||||
{ value: 'subscription', label: '订阅模式', desc: '按周期付费订阅', icon: 'lucide:repeat' },
|
||||
{ value: 'balance', label: '余额模式', desc: '按使用量计费', icon: 'lucide:coins' },
|
||||
]
|
||||
|
||||
const selectedBilling = computed(() => {
|
||||
return billingOptions.find(b => b.value === formData.value.billing_type)
|
||||
})
|
||||
|
||||
function handleIconUpload(event: Event) {
|
||||
@@ -52,29 +45,9 @@ function clearIcon() {
|
||||
formData.value.iconPreview = ''
|
||||
}
|
||||
|
||||
function generateSecretKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
formData.value.secret_key = result
|
||||
}
|
||||
|
||||
function toggleWeekday(index: number) {
|
||||
const idx = formData.value.free_period_weekdays.indexOf(index)
|
||||
if (idx === -1) {
|
||||
formData.value.free_period_weekdays.push(index)
|
||||
formData.value.free_period_weekdays.sort()
|
||||
}
|
||||
else {
|
||||
formData.value.free_period_weekdays.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formData.value.name.trim()) {
|
||||
toast.error(t('admin.applications.appNameRequired'))
|
||||
toast.error('请输入应用名称')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,26 +58,23 @@ async function handleCreate() {
|
||||
formDataToSend.append('name', formData.value.name)
|
||||
formDataToSend.append('description', formData.value.description)
|
||||
formDataToSend.append('billing_type', formData.value.billing_type)
|
||||
formDataToSend.append('login_policy', formData.value.login_policy)
|
||||
formDataToSend.append('encrypt_type', formData.value.encrypt_type)
|
||||
formDataToSend.append('secret_key', formData.value.secret_key)
|
||||
formDataToSend.append('bind_type', formData.value.bind_type)
|
||||
formDataToSend.append('max_devices', formData.value.max_devices.toString())
|
||||
formDataToSend.append('multi_open', formData.value.multi_open.toString())
|
||||
formDataToSend.append('enable_trial', formData.value.enable_trial.toString())
|
||||
formDataToSend.append('trial_balance', formData.value.trial_balance.toString())
|
||||
formDataToSend.append('enable_free_period', formData.value.enable_free_period.toString())
|
||||
formDataToSend.append('free_period_type', formData.value.free_period_type)
|
||||
formDataToSend.append('free_period_start', formData.value.free_period_start)
|
||||
formDataToSend.append('free_period_end', formData.value.free_period_end)
|
||||
formDataToSend.append('free_period_weekdays', JSON.stringify(formData.value.free_period_weekdays))
|
||||
formDataToSend.append('free_period_start_time', formData.value.free_period_start_time)
|
||||
formDataToSend.append('free_period_end_time', formData.value.free_period_end_time)
|
||||
|
||||
if (formData.value.billing_type !== 'free') {
|
||||
formDataToSend.append('deduction_cycle', formData.value.deduction_cycle)
|
||||
formDataToSend.append('deduction_amount', formData.value.deduction_amount.toString())
|
||||
}
|
||||
formDataToSend.append('login_policy', 'loose')
|
||||
formDataToSend.append('encrypt_type', 'none')
|
||||
formDataToSend.append('bind_type', 'none')
|
||||
formDataToSend.append('max_devices', '1')
|
||||
formDataToSend.append('multi_open_mode', 'forbidden')
|
||||
formDataToSend.append('max_instances', '1')
|
||||
formDataToSend.append('heartbeat_interval', '60')
|
||||
formDataToSend.append('heartbeat_timeout', '300')
|
||||
formDataToSend.append('max_attempts', '5')
|
||||
formDataToSend.append('lock_duration', '30')
|
||||
formDataToSend.append('allow_register', 'true')
|
||||
formDataToSend.append('enable_login_verify', 'false')
|
||||
formDataToSend.append('enable_register_verify', 'false')
|
||||
formDataToSend.append('enable_trial', 'false')
|
||||
formDataToSend.append('trial_balance', '0')
|
||||
formDataToSend.append('enable_free_period', 'false')
|
||||
formDataToSend.append('status', 'active')
|
||||
|
||||
if (formData.value.iconFile) {
|
||||
formDataToSend.append('icon', formData.value.iconFile)
|
||||
@@ -120,533 +90,303 @@ async function handleCreate() {
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('admin.applications.createSuccess'))
|
||||
router.push('/admin/applications')
|
||||
createdAppId.value = data.data?.application?.id || data.data?.id
|
||||
showSuccessDialog.value = true
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('admin.applications.createFailed'))
|
||||
toast.error(data.message || '创建失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('创建应用失败:', error)
|
||||
toast.error(t('admin.applications.createFailed'))
|
||||
toast.error('创建失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToSettings() {
|
||||
showSuccessDialog.value = false
|
||||
router.push(`/admin/applications/${createdAppId.value}/settings`)
|
||||
}
|
||||
|
||||
function goToAppList() {
|
||||
showSuccessDialog.value = false
|
||||
router.push('/admin/applications')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.applications.createApp')"
|
||||
:description="t('admin.applications.createAppDesc')"
|
||||
title="创建应用"
|
||||
description="创建一个新的应用,快速开始您的业务"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.applications.title'), href: '/admin/applications' },
|
||||
{ title: t('admin.applications.createApp') },
|
||||
{ title: '应用管理', href: '/admin/applications' },
|
||||
{ title: '创建应用' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.basicInfo') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.applications.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('admin.applications.appName') }} *
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="formData.name" :placeholder="t('admin.applications.appNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.appIcon') }}</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<div v-if="formData.iconPreview" class="relative group">
|
||||
<img
|
||||
:src="formData.iconPreview"
|
||||
alt="应用图标"
|
||||
class="w-12 h-12 rounded-lg object-cover border"
|
||||
>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="clearIcon"
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-3 w-3" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<label
|
||||
for="appIcon"
|
||||
class="flex items-center justify-center h-12 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">{{ formData.iconPreview ? t('admin.applications.change') : t('admin.applications.upload') }}</span>
|
||||
<input
|
||||
id="appIcon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('admin.applications.appDescription') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="formData.description" :placeholder="t('admin.applications.appDescriptionPlaceholder')" rows="3" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.operationMode') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.applications.operationModeDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="formData.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'free'"
|
||||
>
|
||||
<UiRadioGroupItem value="free" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.freeMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.freeModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'subscription'"
|
||||
>
|
||||
<UiRadioGroupItem value="subscription" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.subscriptionMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.subscriptionModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'time'"
|
||||
>
|
||||
<UiRadioGroupItem value="time" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.timeMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.timeModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'count' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'count'"
|
||||
>
|
||||
<UiRadioGroupItem value="count" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.countMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.countModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="formData.billing_type !== 'free'" class="mt-4 pt-4 border-t space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.deductionCycle') }}</UiLabel>
|
||||
<UiSelect v-model="formData.deduction_cycle">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('admin.applications.selectDeductionCycle')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="per_use">
|
||||
{{ t('admin.applications.perUse') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_minute">
|
||||
{{ t('admin.applications.perMinute') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_hour">
|
||||
{{ t('admin.applications.perHour') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_day">
|
||||
{{ t('admin.applications.perDay') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.deductionAmount') }}</UiLabel>
|
||||
<UiNumberField v-model="formData.deduction_amount" :min="0" :step="0.01">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.loginPolicy') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.applications.loginPolicyDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="formData.login_policy" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'loose'"
|
||||
>
|
||||
<UiRadioGroupItem value="loose" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.looseMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.looseModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'strict'"
|
||||
>
|
||||
<UiRadioGroupItem value="strict" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.strictMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.strictModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'hybrid' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'hybrid'"
|
||||
>
|
||||
<UiRadioGroupItem value="hybrid" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.hybridMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.applications.hybridModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.trialSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.applications.trialSettings') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.enableTrial') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
新用户注册后自动获得试用权益
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.enable_trial" />
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_trial" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.trialBalance') }}</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="formData.trial_balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后获得的余额
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.freePeriodSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.applications.freePeriodSettings') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.enableFreePeriod') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
在设定的时间段内,所有用户可免费使用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.enable_free_period" />
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_free_period" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.periodType') }}</UiLabel>
|
||||
<UiRadioGroup v-model="formData.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.free_period_type = 'range'"
|
||||
>
|
||||
<UiRadioGroupItem value="range" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.dateRange') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
指定开始和结束日期时间
|
||||
</p>
|
||||
</div>
|
||||
<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:box" 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="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.free_period_type = 'weekly'"
|
||||
>
|
||||
<UiRadioGroupItem value="weekly" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.weekly') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
每周固定日期和时间段
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.startTime') }}</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="formData.free_period_start" :placeholder="t('admin.applications.startTime')" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.endTime') }}</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="formData.free_period_end" :placeholder="t('admin.applications.endTime')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.free_period_type === 'weekly'" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.weekdays') }}</UiLabel>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="(day, index) in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="index">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="formData.free_period_weekdays.includes(index) ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||
@click="toggleWeekday(index)"
|
||||
<div class="space-y-2">
|
||||
<UiLabel>应用图标</UiLabel>
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="formData.iconPreview" class="relative group">
|
||||
<img
|
||||
:src="formData.iconPreview"
|
||||
alt="应用图标"
|
||||
class="w-12 h-12 rounded-lg object-cover border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="clearIcon"
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
for="appIcon"
|
||||
class="flex items-center justify-center h-12 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
{{ day }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择每周哪些日期免费
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.startTime') }}</UiLabel>
|
||||
<UiDatePickerTimePicker v-model="formData.free_period_start_time" :placeholder="t('admin.applications.startTime')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天免费开始时间
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.endTime') }}</UiLabel>
|
||||
<UiDatePickerTimePicker v-model="formData.free_period_end_time" :placeholder="t('admin.applications.endTime')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天免费结束时间
|
||||
</p>
|
||||
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formData.iconPreview ? '更换' : '上传' }}
|
||||
</span>
|
||||
<input
|
||||
id="appIcon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.securitySettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.applications.securitySettingsDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.encryptionType') }}</UiLabel>
|
||||
<UiSelect v-model="formData.encrypt_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('admin.applications.encryptionType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="none">
|
||||
{{ t('admin.applications.noEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="aes">
|
||||
{{ t('admin.applications.aesEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="rsa">
|
||||
{{ t('admin.applications.rsaEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="rc4">
|
||||
{{ t('admin.applications.rc4Encryption') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span v-if="formData.encrypt_type === 'none'">数据明文传输,仅用于测试环境</span>
|
||||
<span v-else-if="formData.encrypt_type === 'aes'">使用AES-GCM算法加密,安全性高</span>
|
||||
<span v-else-if="formData.encrypt_type === 'rsa'">非对称加密,安全性最高</span>
|
||||
<span v-else-if="formData.encrypt_type === 'rc4'">使用RC4流加密,性能较好</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.applications.secretKey') }}</UiLabel>
|
||||
<div class="relative">
|
||||
<UiInput
|
||||
v-model="formData.secret_key"
|
||||
type="password"
|
||||
placeholder="留空则自动生成"
|
||||
class="pr-10"
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">应用描述</UiLabel>
|
||||
<UiTextarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="请输入应用描述(可选)"
|
||||
rows="3"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
@click="generateSecretKey"
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:credit-card" class="size-5" />
|
||||
运营模式
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>选择应用的计费方式</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div
|
||||
v-for="option in billingOptions"
|
||||
:key="option.value"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === option.value ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = option.value as any"
|
||||
>
|
||||
<Icon icon="lucide:refresh-cw" class="h-4 w-4" />
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 rounded-lg shrink-0"
|
||||
:class="formData.billing_type === option.value ? 'bg-primary text-primary-foreground' : 'bg-muted'"
|
||||
>
|
||||
<Icon :icon="option.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<Icon
|
||||
v-if="formData.billing_type === option.value"
|
||||
icon="lucide:check"
|
||||
class="size-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ option.desc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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="flex items-center gap-3 pb-4 border-b">
|
||||
<div
|
||||
class="flex items-center justify-center w-12 h-12 rounded-lg bg-muted text-muted-foreground overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="formData.iconPreview"
|
||||
:src="formData.iconPreview"
|
||||
alt="图标"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<Icon v-else icon="lucide:box" class="size-6" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">
|
||||
{{ formData.name || '未命名应用' }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ selectedBilling?.label || '免费模式' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span>{{ selectedBilling?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">应用描述</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.description || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="loading || !formData.name"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<Icon v-if="loading" 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>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用于加密通信数据的密钥,留空则自动生成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('admin.applications.deviceBind') }}</UiCardTitle>
|
||||
<UiCardDescription>控制用户登录时是否需要绑定设备</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.deviceBind') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后,用户登录时需要绑定设备,可限制同时登录的设备数量
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:model-value="formData.bind_type === 'device'"
|
||||
@update:model-value="(val: boolean) => formData.bind_type = val ? 'device' : 'none'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.bind_type === 'device'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>{{ t('admin.applications.maxDevices') }}</UiLabel>
|
||||
<UiNumberField v-model="formData.max_devices" :min="1" :max="10">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每个账号最多绑定的设备数量
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('admin.applications.allowMultiOpen') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许同一账号在同一设备上同时运行多个实例
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.multi_open" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<UiButton variant="outline" @click="router.push('/admin/applications')">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="loading" @click="handleCreate">
|
||||
<Icon v-if="loading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t('admin.applications.createApp') }}
|
||||
</UiButton>
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2 text-sm">
|
||||
<Icon icon="lucide:info" class="size-4" />
|
||||
提示
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<ul class="text-sm text-muted-foreground space-y-2">
|
||||
<li class="flex items-start gap-2">
|
||||
<Icon icon="lucide:check" class="size-4 text-green-500 mt-0.5 shrink-0" />
|
||||
创建后可在设置页面配置更多选项
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<Icon icon="lucide:check" class="size-4 text-green-500 mt-0.5 shrink-0" />
|
||||
支持配置验证方式、安全策略等
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<Icon icon="lucide:check" class="size-4 text-green-500 mt-0.5 shrink-0" />
|
||||
可随时修改应用信息和设置
|
||||
</li>
|
||||
</ul>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiDialog v-model:open="showSuccessDialog">
|
||||
<UiDialogContent class="sm:max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:check-circle" class="h-5 w-5 text-green-500" />
|
||||
应用创建成功
|
||||
</UiDialogTitle>
|
||||
<UiDialogDescription>
|
||||
您的应用已创建成功,接下来您可以配置更多设置。
|
||||
</UiDialogDescription>
|
||||
</UiDialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="p-4 rounded-lg bg-muted/50 space-y-3">
|
||||
<p class="text-sm font-medium">
|
||||
推荐配置:
|
||||
</p>
|
||||
<ul class="text-sm text-muted-foreground space-y-2">
|
||||
<li class="flex items-center gap-2">
|
||||
<Icon icon="lucide:check" class="h-4 w-4 text-green-500" />
|
||||
配置验证方式(邮箱/短信验证)
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<Icon icon="lucide:check" class="h-4 w-4 text-green-500" />
|
||||
设置安全策略(加密、绑定)
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<Icon icon="lucide:check" class="h-4 w-4 text-green-500" />
|
||||
上传应用版本文件
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter class="flex-col sm:flex-row gap-2">
|
||||
<UiButton variant="outline" @click="goToAppList">
|
||||
稍后配置
|
||||
</UiButton>
|
||||
<UiButton @click="goToSettings">
|
||||
前往设置
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<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: 'aliyun' as 'aliyun' | 'tencent' | 'other',
|
||||
config: '',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'aliyun', label: '阿里云', desc: '阿里云短信服务', icon: 'lucide:cloud' },
|
||||
{ value: 'tencent', label: '腾讯云', desc: '腾讯云短信服务', icon: 'lucide:cloud' },
|
||||
{ value: 'other', label: '其他', desc: '其他短信服务商', icon: 'lucide:settings' },
|
||||
]
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
const configPlaceholder = computed(() => {
|
||||
switch (formData.value.type) {
|
||||
case 'aliyun':
|
||||
return `{
|
||||
"access_key_id": "您的AccessKeyID",
|
||||
"access_key_secret": "您的AccessKeySecret",
|
||||
"sign_name": "签名名称",
|
||||
"template_code": "模板CODE"
|
||||
}`
|
||||
case 'tencent':
|
||||
return `{
|
||||
"secret_id": "您的SecretId",
|
||||
"secret_key": "您的SecretKey",
|
||||
"sdk_app_id": "应用ID",
|
||||
"sign_name": "签名名称",
|
||||
"template_id": "模板ID"
|
||||
}`
|
||||
default:
|
||||
return `{
|
||||
"api_url": "短信服务商API地址",
|
||||
"api_key": "API密钥",
|
||||
"其他参数": "根据服务商文档配置"
|
||||
}`
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchSmsConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/sms-configs/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
config: data.config || '',
|
||||
status: data.status,
|
||||
remark: data.remark || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('加载短信配置失败:', error)
|
||||
toast.error(error.message || '加载失败')
|
||||
router.push('/admin/sms-settings')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name) {
|
||||
toast.error('请输入配置名称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/sms-configs/${route.params.id}`, formData.value)
|
||||
toast.success('更新成功')
|
||||
router.push('/admin/sms-settings')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新短信配置失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSmsConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑短信配置"
|
||||
description="修改短信服务配置"
|
||||
:breadcrumbs="[
|
||||
{ title: '短信配置', href: '/admin/sms-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:smartphone" 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="size-4" />
|
||||
<span>{{ type.label }}</span>
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="config">配置信息</UiLabel>
|
||||
<textarea
|
||||
id="config"
|
||||
v-model="formData.config"
|
||||
class="flex min-h-[150px] 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 font-mono"
|
||||
:placeholder="configPlaceholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
JSON格式的配置信息,根据所选短信服务商填写对应参数
|
||||
</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">短信类型</span>
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">备注</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.remark || '-' }}</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,113 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { SmsConfig } from '@/pages/admin/sms-settings/data/schema'
|
||||
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
interface ColumnOptions {
|
||||
onToggleStatus: (row: SmsConfig) => void
|
||||
onEdit: (row: SmsConfig) => void
|
||||
onDelete: (row: SmsConfig) => void
|
||||
onTest: (row: SmsConfig) => void
|
||||
}
|
||||
|
||||
export function getColumns(options: ColumnOptions, t: (key: string) => string): ColumnDef<SmsConfig>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => '配置名称',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(Icon, { icon: 'lucide:smartphone', class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'font-medium' }, row.original.name),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => '类型',
|
||||
cell: ({ row }) => {
|
||||
const typeLabels: Record<string, string> = {
|
||||
aliyun: '阿里云',
|
||||
tencent: '腾讯云',
|
||||
other: '其他',
|
||||
}
|
||||
const typeIcons: Record<string, string> = {
|
||||
aliyun: 'lucide:cloud',
|
||||
tencent: 'lucide:cloud',
|
||||
other: 'lucide:settings',
|
||||
}
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(Icon, { icon: typeIcons[row.original.type] || 'lucide:settings', class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'text-sm' }, typeLabels[row.original.type] || row.original.type),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
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' }, 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',
|
||||
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,86 @@
|
||||
<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 { SmsConfig } from '@/pages/admin/sms-settings/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
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/sms-settings/components/columns'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<SmsConfig>, 'columns'> & {
|
||||
onToggleStatus: (row: SmsConfig) => void
|
||||
onEdit: (row: SmsConfig) => void
|
||||
onDelete: (row: SmsConfig) => void
|
||||
onTest: (row: SmsConfig) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'batchEnable': []
|
||||
'batchDisable': []
|
||||
'batchDelete': []
|
||||
}>()
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const columns = computed<ColumnDef<SmsConfig>[]>(() => [
|
||||
SelectColumn as ColumnDef<SmsConfig>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
onTest: props.onTest,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<SmsConfig>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: '选择',
|
||||
name: '配置名称',
|
||||
type: '类型',
|
||||
status: '状态',
|
||||
remark: '备注',
|
||||
created_at: '创建时间',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<BulkActions :table="table" entity-name="sms-configs">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
批量启用
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
批量禁用
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
批量删除
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,239 @@
|
||||
<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: 'aliyun' as 'aliyun' | 'tencent' | 'other',
|
||||
config: '',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'aliyun', label: '阿里云', desc: '阿里云短信服务', icon: 'lucide:cloud' },
|
||||
{ value: 'tencent', label: '腾讯云', desc: '腾讯云短信服务', icon: 'lucide:cloud' },
|
||||
{ value: 'other', label: '其他', desc: '其他短信服务商', icon: 'lucide:settings' },
|
||||
]
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
const configPlaceholder = computed(() => {
|
||||
switch (formData.value.type) {
|
||||
case 'aliyun':
|
||||
return `{
|
||||
"access_key_id": "您的AccessKeyID",
|
||||
"access_key_secret": "您的AccessKeySecret",
|
||||
"sign_name": "签名名称",
|
||||
"template_code": "模板CODE"
|
||||
}`
|
||||
case 'tencent':
|
||||
return `{
|
||||
"secret_id": "您的SecretId",
|
||||
"secret_key": "您的SecretKey",
|
||||
"sdk_app_id": "应用ID",
|
||||
"sign_name": "签名名称",
|
||||
"template_id": "模板ID"
|
||||
}`
|
||||
default:
|
||||
return `{
|
||||
"api_url": "短信服务商API地址",
|
||||
"api_key": "API密钥",
|
||||
"其他参数": "根据服务商文档配置"
|
||||
}`
|
||||
}
|
||||
})
|
||||
|
||||
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/sms-configs', formData.value)
|
||||
toast.success('创建成功')
|
||||
router.push('/admin/sms-settings')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建短信配置失败:', error)
|
||||
toast.error(error.message || '创建失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="添加短信配置"
|
||||
description="配置新的短信服务"
|
||||
:breadcrumbs="[
|
||||
{ title: '短信配置', href: '/admin/sms-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:smartphone" 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="size-4" />
|
||||
<span>{{ type.label }}</span>
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="config">配置信息</UiLabel>
|
||||
<textarea
|
||||
id="config"
|
||||
v-model="formData.config"
|
||||
class="flex min-h-[150px] 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 font-mono"
|
||||
:placeholder="configPlaceholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
JSON格式的配置信息,根据所选短信服务商填写对应参数
|
||||
</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">短信类型</span>
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">备注</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.remark || '-' }}</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,18 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const smsConfigStatusSchema = z.enum(['active', 'inactive'])
|
||||
|
||||
export const smsConfigTypeSchema = z.enum(['aliyun', 'tencent', 'other'])
|
||||
|
||||
export const smsConfigSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
type: smsConfigTypeSchema,
|
||||
config: z.string(),
|
||||
status: smsConfigStatusSchema,
|
||||
remark: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export type SmsConfig = z.infer<typeof smsConfigSchema>
|
||||
@@ -0,0 +1,308 @@
|
||||
<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 { SmsConfig } from '@/pages/admin/sms-settings/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/sms-settings/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const smsConfigs = ref<SmsConfig[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<SmsConfig | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<number[]>([])
|
||||
|
||||
const testDialogOpen = ref(false)
|
||||
const testTarget = ref<SmsConfig | null>(null)
|
||||
const testPhone = ref('')
|
||||
const testSending = ref(false)
|
||||
|
||||
const activeCount = computed(() => smsConfigs.value.filter(c => c.status === 'active').length)
|
||||
const inactiveCount = computed(() => smsConfigs.value.filter(c => c.status === 'inactive').length)
|
||||
|
||||
async function fetchSmsConfigs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ sms_configs: SmsConfig[] }>('/dev/sms-configs')
|
||||
smsConfigs.value = data?.sms_configs || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载短信配置失败:', error)
|
||||
smsConfigs.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/sms-settings/create')
|
||||
}
|
||||
|
||||
function goToEdit(config: SmsConfig) {
|
||||
router.push(`/admin/sms-settings/${config.id}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(config: SmsConfig) {
|
||||
const newStatus = config.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await api.put(`/dev/sms-configs/${config.id}/status`, { status: newStatus })
|
||||
toast.success('状态更新成功')
|
||||
fetchSmsConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新状态失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(config: SmsConfig) {
|
||||
deleteTarget.value = config
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/sms-configs/${deleteTarget.value.id}`)
|
||||
toast.success('删除成功')
|
||||
fetchSmsConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除短信配置失败:', error)
|
||||
toast.error(error.message || '删除失败')
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: number[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/sms-configs/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success('批量删除成功')
|
||||
fetchSmsConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || '批量删除失败')
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: number[], status: string) {
|
||||
try {
|
||||
await api.put('/dev/sms-configs/batch/status', { ids, status })
|
||||
toast.success('批量更新成功')
|
||||
fetchSmsConfigs()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(error.message || '批量更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openTestDialog(config: SmsConfig) {
|
||||
testTarget.value = config
|
||||
testPhone.value = ''
|
||||
testDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!testTarget.value || !testPhone.value)
|
||||
return
|
||||
|
||||
testSending.value = true
|
||||
try {
|
||||
await api.post(`/dev/sms-configs/${testTarget.value.id}/test`, { phone: testPhone.value })
|
||||
toast.success('测试短信已发送')
|
||||
testDialogOpen.value = false
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('发送测试短信失败:', error)
|
||||
toast.error(error.message || '发送失败')
|
||||
}
|
||||
finally {
|
||||
testSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSmsConfigs()
|
||||
})
|
||||
</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-2 lg:grid-cols-4">
|
||||
<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:smartphone" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ smsConfigs.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>
|
||||
|
||||
<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:percent" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ smsConfigs.length ? Math.round(activeCount / smsConfigs.length * 100) : 0 }}%
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="smsConfigs"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-edit="goToEdit"
|
||||
:on-delete="confirmDelete"
|
||||
:on-test="openTestDialog"
|
||||
@refresh="fetchSmsConfigs"
|
||||
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
批量删除短信配置
|
||||
</template>
|
||||
<template #description>
|
||||
确定要删除选中的 {{ batchDeleteIds.length }} 个短信配置吗?此操作不可撤销。
|
||||
</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-phone">收件人手机号</UiLabel>
|
||||
<UiInput
|
||||
id="test-phone"
|
||||
v-model="testPhone"
|
||||
type="tel"
|
||||
placeholder="请输入收件人手机号"
|
||||
:disabled="testSending"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="testDialogOpen = false">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="!testPhone || 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>
|
||||
@@ -62,12 +62,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/admin/applications/[id]/security.vue'),
|
||||
meta: { title: '安全设置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'applications/:id/email',
|
||||
name: 'AdminApplicationEmail',
|
||||
component: () => import('@/pages/admin/applications/[id]/email.vue'),
|
||||
meta: { title: '邮箱设置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'cards',
|
||||
name: 'AdminCards',
|
||||
@@ -386,6 +380,24 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/admin/email-settings/[id].vue'),
|
||||
meta: { title: '编辑邮箱配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'sms-settings',
|
||||
name: 'AdminSmsSettings',
|
||||
component: () => import('@/pages/admin/sms-settings/index.vue'),
|
||||
meta: { title: '短信配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'sms-settings/create',
|
||||
name: 'AdminSmsSettingCreate',
|
||||
component: () => import('@/pages/admin/sms-settings/create.vue'),
|
||||
meta: { title: '添加短信配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'sms-settings/:id',
|
||||
name: 'AdminSmsSettingEdit',
|
||||
component: () => import('@/pages/admin/sms-settings/[id].vue'),
|
||||
meta: { title: '编辑短信配置 - 管理后台' },
|
||||
},
|
||||
{
|
||||
path: 'storage-configs',
|
||||
name: 'AdminStorageConfigs',
|
||||
|
||||
Vendored
+39
-15
@@ -133,18 +133,10 @@ declare module 'vue-router/auto-routes' {
|
||||
'/admin/applications',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| '/admin/applications/[id]/email'
|
||||
| '/admin/applications/[id]/security'
|
||||
| '/admin/applications/[id]/settings'
|
||||
| '/admin/applications/create'
|
||||
>,
|
||||
'/admin/applications/[id]/email': RouteRecordInfo<
|
||||
'/admin/applications/[id]/email',
|
||||
'/admin/applications/:id/email',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/admin/applications/[id]/security': RouteRecordInfo<
|
||||
'/admin/applications/[id]/security',
|
||||
'/admin/applications/:id/security',
|
||||
@@ -399,6 +391,27 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/sms-settings/': RouteRecordInfo<
|
||||
'/admin/sms-settings/',
|
||||
'/admin/sms-settings',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/sms-settings/[id]': RouteRecordInfo<
|
||||
'/admin/sms-settings/[id]',
|
||||
'/admin/sms-settings/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/admin/sms-settings/create': RouteRecordInfo<
|
||||
'/admin/sms-settings/create',
|
||||
'/admin/sms-settings/create',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/admin/storage-configs/': RouteRecordInfo<
|
||||
'/admin/storage-configs/',
|
||||
'/admin/storage-configs',
|
||||
@@ -715,19 +728,12 @@ declare module 'vue-router/auto-routes' {
|
||||
'src/pages/admin/applications.vue': {
|
||||
routes:
|
||||
| '/admin/applications'
|
||||
| '/admin/applications/[id]/email'
|
||||
| '/admin/applications/[id]/security'
|
||||
| '/admin/applications/[id]/settings'
|
||||
| '/admin/applications/create'
|
||||
views:
|
||||
| 'default'
|
||||
}
|
||||
'src/pages/admin/applications/[id]/email.vue': {
|
||||
routes:
|
||||
| '/admin/applications/[id]/email'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/applications/[id]/security.vue': {
|
||||
routes:
|
||||
| '/admin/applications/[id]/security'
|
||||
@@ -949,6 +955,24 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/sms-settings/index.vue': {
|
||||
routes:
|
||||
| '/admin/sms-settings/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/sms-settings/[id].vue': {
|
||||
routes:
|
||||
| '/admin/sms-settings/[id]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/sms-settings/create.vue': {
|
||||
routes:
|
||||
| '/admin/sms-settings/create'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/admin/storage-configs/index.vue': {
|
||||
routes:
|
||||
| '/admin/storage-configs/'
|
||||
|
||||
Reference in New Issue
Block a user