修复登录及验证码功能
This commit is contained in:
@@ -37,19 +37,19 @@ func main() {
|
|||||||
jm := jwt.New(cfg.JWTSecret, cfg.JWTExpireHrs)
|
jm := jwt.New(cfg.JWTSecret, cfg.JWTExpireHrs)
|
||||||
|
|
||||||
// 4. 初始化 Service
|
// 4. 初始化 Service
|
||||||
authSvc := service.NewAuthService(database.DB, jm)
|
|
||||||
userSvc := service.NewUserService(database.DB)
|
|
||||||
regionSvc := service.NewRegionService(database.DB)
|
|
||||||
schoolSvc := service.NewSchoolService(database.DB)
|
|
||||||
classSvc := service.NewClassService(database.DB)
|
|
||||||
adminSvc := service.NewAdminService(database.DB)
|
|
||||||
configSvc := service.NewConfigService(database.DB)
|
configSvc := service.NewConfigService(database.DB)
|
||||||
menuSvc := service.NewMenuService(database.DB)
|
|
||||||
|
|
||||||
// 初始化系统默认配置
|
// 初始化系统默认配置
|
||||||
if err := configSvc.InitDefaults(); err != nil {
|
if err := configSvc.InitDefaults(); err != nil {
|
||||||
log.Printf("[config] init defaults failed: %v", err)
|
log.Printf("[config] init defaults failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authSvc := service.NewAuthService(database.DB, jm, configSvc)
|
||||||
|
userSvc := service.NewUserService(database.DB)
|
||||||
|
regionSvc := service.NewRegionService(database.DB)
|
||||||
|
schoolSvc := service.NewSchoolService(database.DB)
|
||||||
|
classSvc := service.NewClassService(database.DB, configSvc)
|
||||||
|
adminSvc := service.NewAdminService(database.DB)
|
||||||
|
menuSvc := service.NewMenuService(database.DB)
|
||||||
// 初始化默认菜单
|
// 初始化默认菜单
|
||||||
if err := menuSvc.InitDefaults(); err != nil {
|
if err := menuSvc.InitDefaults(); err != nil {
|
||||||
log.Printf("[menu] init defaults failed: %v", err)
|
log.Printf("[menu] init defaults failed: %v", err)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 全局配置,优先从环境变量读取,提供默认值以便开箱即用。
|
// Config 全局基础配置(仅保留服务启动及基础设施参数)
|
||||||
|
// 业务参数(极验、短信、微信、审核时限等)已统一移入 MySQL 数据库 yz_pw_system_configs 表管理
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
@@ -23,24 +24,6 @@ type Config struct {
|
|||||||
// 平台管理员账号(首次启动自动创建)
|
// 平台管理员账号(首次启动自动创建)
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
// 班级审核补齐期限(天),逾期自动删除
|
|
||||||
AuditGraceDays int
|
|
||||||
|
|
||||||
// 极验行为验证(参数后期补,为空则跳过极验校验)
|
|
||||||
GeeTestID string
|
|
||||||
GeeTestKey string
|
|
||||||
|
|
||||||
// 短信验证码(参数后期补,为空则短信接口返回配置缺失)
|
|
||||||
SMSProvider string // aliyun / tencent
|
|
||||||
SMSAccessKey string
|
|
||||||
SMSSecretKey string
|
|
||||||
SMSSignName string
|
|
||||||
SMSTemplateCode string
|
|
||||||
|
|
||||||
// 微信扫码登录(参数后期补,为空则微信接口返回配置缺失)
|
|
||||||
WechatAppID string
|
|
||||||
WechatAppSecret string
|
|
||||||
WechatRedirectURI string
|
|
||||||
|
|
||||||
// Redis(缓存/验证码/限流,连接失败自动降级内存)
|
// Redis(缓存/验证码/限流,连接失败自动降级内存)
|
||||||
RedisHost string
|
RedisHost string
|
||||||
@@ -57,28 +40,16 @@ func Load() *Config {
|
|||||||
JWTSecret: getEnv("JWT_SECRET", "photowall-dev-secret-change-in-prod"),
|
JWTSecret: getEnv("JWT_SECRET", "photowall-dev-secret-change-in-prod"),
|
||||||
JWTExpireHrs: getEnvInt("JWT_EXPIRE_HOURS", 3),
|
JWTExpireHrs: getEnvInt("JWT_EXPIRE_HOURS", 3),
|
||||||
UploadDir: getEnv("UPLOAD_DIR", "uploads"),
|
UploadDir: getEnv("UPLOAD_DIR", "uploads"),
|
||||||
DBHost: getEnv("DB_HOST", "10.31.100.3"),
|
DBHost: getEnv("DB_HOST", "212.64.112.158"),
|
||||||
DBPort: getEnv("DB_PORT", "3306"),
|
DBPort: getEnv("DB_PORT", "3388"),
|
||||||
|
// DBHost: getEnv("DB_HOST", "10.31.100.3"),
|
||||||
|
// DBPort: getEnv("DB_PORT", "3306"),
|
||||||
DBUser: getEnv("DB_USER", "photowall"),
|
DBUser: getEnv("DB_USER", "photowall"),
|
||||||
DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"),
|
DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"),
|
||||||
DBName: getEnv("DB_NAME", "photowall"),
|
DBName: getEnv("DB_NAME", "photowall"),
|
||||||
DBCharset: getEnv("DB_CHARSET", "utf8mb4"),
|
DBCharset: getEnv("DB_CHARSET", "utf8mb4"),
|
||||||
AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"),
|
AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", "920103"),
|
AdminPassword: getEnv("ADMIN_PASSWORD", "920103"),
|
||||||
AuditGraceDays: getEnvInt("AUDIT_GRACE_DAYS", 30),
|
|
||||||
// 极验(后期补参数)
|
|
||||||
GeeTestID: getEnv("GEETEST_ID", ""),
|
|
||||||
GeeTestKey: getEnv("GEETEST_KEY", ""),
|
|
||||||
// 短信(后期补参数)
|
|
||||||
SMSProvider: getEnv("SMS_PROVIDER", "aliyun"),
|
|
||||||
SMSAccessKey: getEnv("SMS_ACCESS_KEY", ""),
|
|
||||||
SMSSecretKey: getEnv("SMS_SECRET_KEY", ""),
|
|
||||||
SMSSignName: getEnv("SMS_SIGN_NAME", ""),
|
|
||||||
SMSTemplateCode: getEnv("SMS_TEMPLATE_CODE", ""),
|
|
||||||
// 微信扫码登录(后期补参数)
|
|
||||||
WechatAppID: getEnv("WECHAT_APP_ID", ""),
|
|
||||||
WechatAppSecret: getEnv("WECHAT_APP_SECRET", ""),
|
|
||||||
WechatRedirectURI: getEnv("WECHAT_REDIRECT_URI", ""),
|
|
||||||
// Redis
|
// Redis
|
||||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"photowall/internal/config"
|
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
"photowall/pkg/captcha"
|
"photowall/pkg/captcha"
|
||||||
"photowall/pkg/hash"
|
"photowall/pkg/hash"
|
||||||
@@ -17,10 +16,11 @@ import (
|
|||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jm *jwt.Manager
|
jm *jwt.Manager
|
||||||
|
configSvc *ConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService {
|
func NewAuthService(db *gorm.DB, jm *jwt.Manager, configSvc *ConfigService) *AuthService {
|
||||||
return &AuthService{db: db, jm: jm}
|
return &AuthService{db: db, jm: jm, configSvc: configSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 请求结构 ============
|
// ============ 请求结构 ============
|
||||||
@@ -223,9 +223,12 @@ func (s *AuthService) ResetPassword(req *ResetPasswordReq) error {
|
|||||||
// ============ 发送短信验证码(预留) ============
|
// ============ 发送短信验证码(预留) ============
|
||||||
|
|
||||||
func (s *AuthService) SendSmsCode(req *SendSmsReq) (string, error) {
|
func (s *AuthService) SendSmsCode(req *SendSmsReq) (string, error) {
|
||||||
cfg := config.C
|
var accessKey, secretKey string
|
||||||
|
if s.configSvc != nil {
|
||||||
|
_, accessKey, secretKey, _, _ = s.configSvc.GetSmsConfig()
|
||||||
|
}
|
||||||
// 参数未配置时返回提示(开发阶段直接返回验证码)
|
// 参数未配置时返回提示(开发阶段直接返回验证码)
|
||||||
if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" {
|
if accessKey == "" || secretKey == "" {
|
||||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||||
sms := &model.SmsCode{
|
sms := &model.SmsCode{
|
||||||
Phone: req.Phone,
|
Phone: req.Phone,
|
||||||
@@ -256,8 +259,11 @@ type WechatLoginReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) WechatLogin(req *WechatLoginReq) (*LoginResp, error) {
|
func (s *AuthService) WechatLogin(req *WechatLoginReq) (*LoginResp, error) {
|
||||||
cfg := config.C
|
var appID, appSecret string
|
||||||
if cfg.WechatAppID == "" || cfg.WechatAppSecret == "" {
|
if s.configSvc != nil {
|
||||||
|
appID, appSecret, _ = s.configSvc.GetWechatConfig()
|
||||||
|
}
|
||||||
|
if appID == "" || appSecret == "" {
|
||||||
return nil, errors.New("微信登录未配置,请联系管理员")
|
return nil, errors.New("微信登录未配置,请联系管理员")
|
||||||
}
|
}
|
||||||
// TODO: 接入微信开放平台API
|
// TODO: 接入微信开放平台API
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"photowall/internal/config"
|
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
"photowall/pkg/sensitive"
|
"photowall/pkg/sensitive"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,10 +14,11 @@ import (
|
|||||||
|
|
||||||
type ClassService struct {
|
type ClassService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
configSvc *ConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClassService(db *gorm.DB) *ClassService {
|
func NewClassService(db *gorm.DB, configSvc *ConfigService) *ClassService {
|
||||||
return &ClassService{db: db}
|
return &ClassService{db: db, configSvc: configSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateClassReq 创建班级请求
|
// CreateClassReq 创建班级请求
|
||||||
@@ -104,6 +104,10 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
teachers := cleanNames(req.TeacherNames)
|
teachers := cleanNames(req.TeacherNames)
|
||||||
|
|
||||||
// 6. 构建班级
|
// 6. 构建班级
|
||||||
|
graceDays := 30
|
||||||
|
if s.configSvc != nil {
|
||||||
|
graceDays = s.configSvc.GetInt("audit_grace_days", 30)
|
||||||
|
}
|
||||||
class := &model.Class{
|
class := &model.Class{
|
||||||
SchoolID: req.SchoolID,
|
SchoolID: req.SchoolID,
|
||||||
CollegeID: req.CollegeID,
|
CollegeID: req.CollegeID,
|
||||||
@@ -113,7 +117,7 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
GraduateYear: req.GraduateYear,
|
GraduateYear: req.GraduateYear,
|
||||||
AdminUserID: userID,
|
AdminUserID: userID,
|
||||||
GraduationPhoto: req.GraduationPhoto,
|
GraduationPhoto: req.GraduationPhoto,
|
||||||
AuditDeadline: time.Now().AddDate(0, 0, config.C.AuditGraceDays),
|
AuditDeadline: time.Now().AddDate(0, 0, graceDays),
|
||||||
}
|
}
|
||||||
class.SetStudentNames(students)
|
class.SetStudentNames(students)
|
||||||
class.SetTeacherNames(teachers)
|
class.SetTeacherNames(teachers)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigService struct {
|
type ConfigService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
cache map[string]string
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigService(db *gorm.DB) *ConfigService {
|
func NewConfigService(db *gorm.DB) *ConfigService {
|
||||||
return &ConfigService{db: db}
|
return &ConfigService{
|
||||||
|
db: db,
|
||||||
|
cache: make(map[string]string),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultConfigs 所有可配置项及说明
|
// defaultConfigs 所有可配置项及说明
|
||||||
@@ -23,8 +32,8 @@ var defaultConfigs = []model.SystemConfig{
|
|||||||
{Key: "audit_grace_days", Value: "30", Label: "审核补齐期限(天)", Description: "班级被打回后,超过该天数未补齐则自动删除", Category: "basic", Placeholder: "30", Sort: 4},
|
{Key: "audit_grace_days", Value: "30", Label: "审核补齐期限(天)", Description: "班级被打回后,超过该天数未补齐则自动删除", Category: "basic", Placeholder: "30", Sort: 4},
|
||||||
|
|
||||||
// ===== 极验行为验证 =====
|
// ===== 极验行为验证 =====
|
||||||
{Key: "geetest_id", Value: "", Label: "极验 ID", Description: "极验行为验证的Captcha ID,用于防机器人和暴力破解", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 1},
|
{Key: "geetest_id", Value: "662e68b7ce24be211fa97a53975c5d96", Label: "极验 ID", Description: "极验行为验证的Captcha ID,用于防机器人和暴力破解", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 1},
|
||||||
{Key: "geetest_key", Value: "", Label: "极验 Key", Description: "极验行为验证的私钥,与ID配对使用", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 2},
|
{Key: "geetest_key", Value: "2789075bc2b25a04c0f7c5420bfa3d6f", Label: "极验 Key", Description: "极验行为验证的私钥,与ID配对使用", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 2},
|
||||||
|
|
||||||
// ===== 短信验证码 =====
|
// ===== 短信验证码 =====
|
||||||
{Key: "sms_provider", Value: "aliyun", Label: "短信服务商", Description: "选择短信服务商,目前支持阿里云和腾讯云", Category: "sms", Placeholder: "aliyun 或 tencent", DocLink: "阿里云: https://dysms.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/sms", Sort: 1},
|
{Key: "sms_provider", Value: "aliyun", Label: "短信服务商", Description: "选择短信服务商,目前支持阿里云和腾讯云", Category: "sms", Placeholder: "aliyun 或 tencent", DocLink: "阿里云: https://dysms.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/sms", Sort: 1},
|
||||||
@@ -43,17 +52,29 @@ var defaultConfigs = []model.SystemConfig{
|
|||||||
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
|
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDefaults 初始化默认配置(不存在则创建)
|
// InitDefaults 初始化默认配置(不存在则创建,已存在但为空且默认有初始值则补齐)
|
||||||
func (s *ConfigService) InitDefaults() error {
|
func (s *ConfigService) InitDefaults() error {
|
||||||
for _, c := range defaultConfigs {
|
for _, c := range defaultConfigs {
|
||||||
var count int64
|
var existing model.SystemConfig
|
||||||
s.db.Model(&model.SystemConfig{}).Where("`key` = ?", c.Key).Count(&count)
|
err := s.db.Where("`key` = ?", c.Key).First(&existing).Error
|
||||||
if count == 0 {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
if err := s.db.Create(&c).Error; err != nil {
|
if err := s.db.Create(&c).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if err == nil && existing.Value == "" && c.Value != "" {
|
||||||
|
s.db.Model(&existing).Update("value", c.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预热内存缓存
|
||||||
|
var list []model.SystemConfig
|
||||||
|
if err := s.db.Find(&list).Error; err == nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
for _, item := range list {
|
||||||
|
s.cache[item.Key] = item.Value
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +92,49 @@ func (s *ConfigService) GetByCategory(category string) ([]model.SystemConfig, er
|
|||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateReq 更新配置请求
|
// UpdateConfigReq 更新配置请求(支持 map 与 key-value 列表格式)
|
||||||
type UpdateConfigReq struct {
|
type UpdateConfigReq struct {
|
||||||
Configs map[string]string `json:"configs" binding:"required"`
|
Configs map[string]string `json:"configs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UpdateConfigReq) UnmarshalJSON(data []byte) error {
|
||||||
|
// 格式1: {"configs": {"k1": "v1", "k2": "v2"}}
|
||||||
|
var auxMap struct {
|
||||||
|
Configs map[string]string `json:"configs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &auxMap); err == nil && auxMap.Configs != nil {
|
||||||
|
r.Configs = auxMap.Configs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式2: {"configs": [{"key": "k1", "value": "v1"}]}
|
||||||
|
var auxList struct {
|
||||||
|
Configs []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"configs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &auxList); err == nil && auxList.Configs != nil {
|
||||||
|
r.Configs = make(map[string]string, len(auxList.Configs))
|
||||||
|
for _, item := range auxList.Configs {
|
||||||
|
r.Configs[item.Key] = item.Value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式3: 直接传入 map: {"k1": "v1"}
|
||||||
|
var directMap map[string]string
|
||||||
|
if err := json.Unmarshal(data, &directMap); err == nil {
|
||||||
|
r.Configs = directMap
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("invalid configs format")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 批量更新配置
|
// Update 批量更新配置
|
||||||
func (s *ConfigService) Update(req *UpdateConfigReq) error {
|
func (s *ConfigService) Update(req *UpdateConfigReq) error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
for key, value := range req.Configs {
|
for key, value := range req.Configs {
|
||||||
if err := tx.Model(&model.SystemConfig{}).Where("`key` = ?", key).Update("value", value).Error; err != nil {
|
if err := tx.Model(&model.SystemConfig{}).Where("`key` = ?", key).Update("value", value).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -86,13 +142,66 @@ func (s *ConfigService) Update(req *UpdateConfigReq) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err == nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
for key, value := range req.Configs {
|
||||||
|
s.cache[key] = value
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValue 获取单个配置值
|
// GetValue 获取单个配置值(优先走内存缓存)
|
||||||
func (s *ConfigService) GetValue(key string) string {
|
func (s *ConfigService) GetValue(key string) string {
|
||||||
|
s.mu.RLock()
|
||||||
|
val, ok := s.cache[key]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
var c model.SystemConfig
|
var c model.SystemConfig
|
||||||
if err := s.db.Where("`key` = ?", key).First(&c).Error; err != nil {
|
if err := s.db.Where("`key` = ?", key).First(&c).Error; err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[key] = c.Value
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
return c.Value
|
return c.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInt 获取整数配置值,解析失败或不存在则返回 def
|
||||||
|
func (s *ConfigService) GetInt(key string, def int) int {
|
||||||
|
val := s.GetValue(key)
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGeetestConfig 获取极验验证配置
|
||||||
|
func (s *ConfigService) GetGeetestConfig() (id, key string) {
|
||||||
|
return s.GetValue("geetest_id"), s.GetValue("geetest_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSmsConfig 获取短信配置
|
||||||
|
func (s *ConfigService) GetSmsConfig() (provider, accessKey, secretKey, signName, templateCode string) {
|
||||||
|
return s.GetValue("sms_provider"),
|
||||||
|
s.GetValue("sms_access_key"),
|
||||||
|
s.GetValue("sms_secret_key"),
|
||||||
|
s.GetValue("sms_sign_name"),
|
||||||
|
s.GetValue("sms_template_code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWechatConfig 获取微信登录配置
|
||||||
|
func (s *ConfigService) GetWechatConfig() (appID, appSecret, redirectURI string) {
|
||||||
|
return s.GetValue("wechat_app_id"),
|
||||||
|
s.GetValue("wechat_app_secret"),
|
||||||
|
s.GetValue("wechat_redirect_uri")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package captcha
|
package captcha
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -69,20 +70,23 @@ func drawImage(code string) image.Image {
|
|||||||
bg := color.RGBA{240, 245, 255, 255}
|
bg := color.RGBA{240, 245, 255, 255}
|
||||||
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
x1, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
x1, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||||
y1, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
y1, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||||
x2, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
x2, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||||
y2, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
y2, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||||
drawLine(img, int(x1.Int64()), int(y1.Int64()), int(x2.Int64()), int(y2.Int64()), randomColor())
|
drawLine(img, int(x1.Int64()), int(y1.Int64()), int(x2.Int64()), int(y2.Int64()), randomColor())
|
||||||
}
|
}
|
||||||
for i := 0; i < 30; i++ {
|
for i := 0; i < 25; i++ {
|
||||||
x, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
x, _ := rand.Int(rand.Reader, big.NewInt(int64(width)))
|
||||||
y, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
y, _ := rand.Int(rand.Reader, big.NewInt(int64(height)))
|
||||||
img.Set(int(x.Int64()), int(y.Int64()), randomColor())
|
img.Set(int(x.Int64()), int(y.Int64()), randomColor())
|
||||||
}
|
}
|
||||||
|
// 字符居中绘制:字符高 21px (7*3),画布高 40px,垂直居中起始 y 约 9px;4 字符总宽 90px,水平居中起始 x 为 15px
|
||||||
for i, ch := range code {
|
for i, ch := range code {
|
||||||
drawChar(img, 15+i*25, 28, string(ch), randomDarkColor())
|
offsetY, _ := rand.Int(rand.Reader, big.NewInt(3))
|
||||||
|
charY := 8 + int(offsetY.Int64()) // 8..10px,垂直完美居中
|
||||||
|
drawChar(img, 15+i*25, charY, string(ch), randomDarkColor())
|
||||||
}
|
}
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
@@ -134,7 +138,7 @@ func drawChar(img *image.RGBA, x, y int, ch string, c color.Color) {
|
|||||||
if px == '1' {
|
if px == '1' {
|
||||||
for dy := 0; dy < scale; dy++ {
|
for dy := 0; dy < scale; dy++ {
|
||||||
for dx := 0; dx < scale; dx++ {
|
for dx := 0; dx < scale; dx++ {
|
||||||
img.Set(x+col*scale+dx, y-row*scale-7*scale+dy, c)
|
img.Set(x+col*scale+dx, y+row*scale+dy, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +196,9 @@ func randomDarkColor() color.RGBA {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func encodeToBase64(img image.Image) string {
|
func encodeToBase64(img image.Image) string {
|
||||||
var buf strings.Builder
|
var buf bytes.Buffer
|
||||||
png.Encode(base64.NewEncoder(base64.StdEncoding, &buf), img)
|
enc := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||||
|
_ = png.Encode(enc, img)
|
||||||
|
_ = enc.Close()
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"image/png"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDrawImageValidPNG(t *testing.T) {
|
||||||
|
img := drawImage("ABCD")
|
||||||
|
b64 := encodeToBase64(img)
|
||||||
|
data, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64 decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := png.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("png decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := decoded.Bounds()
|
||||||
|
if bounds.Dx() != 120 || bounds.Dy() != 40 {
|
||||||
|
t.Fatalf("expected 120x40, got %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all characters in chars set can be drawn without panic
|
||||||
|
for _, ch := range chars {
|
||||||
|
_ = drawImage(strings.Repeat(string(ch), 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package geetest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"photowall/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 极验行为验证封装
|
// 极验行为验证封装
|
||||||
@@ -10,14 +9,18 @@ import (
|
|||||||
|
|
||||||
var ErrNotConfigured = errors.New("极验未配置,请联系管理员")
|
var ErrNotConfigured = errors.New("极验未配置,请联系管理员")
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
CaptchaID string
|
||||||
|
CaptchaKey string
|
||||||
|
}
|
||||||
|
|
||||||
// Validate 校验极验二次验证结果
|
// Validate 校验极验二次验证结果
|
||||||
// lotNumber: 极验返回的 lot_number
|
// lotNumber: 极验返回的 lot_number
|
||||||
// captchaOutput: 极验返回的 captcha_output
|
// captchaOutput: 极验返回的 captcha_output
|
||||||
// passToken: 极验返回的 pass_token
|
// passToken: 极验返回的 pass_token
|
||||||
// genTime: 极验返回的 gen_time
|
// genTime: 极验返回的 gen_time
|
||||||
func Validate(lotNumber, captchaOutput, passToken, genTime string) error {
|
func Validate(cfg Config, lotNumber, captchaOutput, passToken, genTime string) error {
|
||||||
cfg := config.C
|
if cfg.CaptchaID == "" || cfg.CaptchaKey == "" {
|
||||||
if cfg.GeeTestID == "" || cfg.GeeTestKey == "" {
|
|
||||||
return ErrNotConfigured
|
return ErrNotConfigured
|
||||||
}
|
}
|
||||||
// TODO: 接入极验官方 SDK 进行二次验证
|
// TODO: 接入极验官方 SDK 进行二次验证
|
||||||
|
|||||||
+12
-4
@@ -24,26 +24,34 @@ type Config struct {
|
|||||||
DB int
|
DB int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init 初始化 Redis 连接,失败则降级为内存模式
|
// Init 初始化 Redis 连接,未开启或连接失败则降级为内存模式
|
||||||
func Init(cfg Config) {
|
func Init(cfg Config) {
|
||||||
|
if cfg.Host == "" {
|
||||||
|
log.Printf("[redis] Redis 未配置,已降级为内存模式运行")
|
||||||
|
Enabled = false
|
||||||
|
Client = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Client = redis.NewClient(&redis.Options{
|
Client = redis.NewClient(&redis.Options{
|
||||||
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
||||||
Password: cfg.Password,
|
Password: cfg.Password,
|
||||||
DB: cfg.DB,
|
DB: cfg.DB,
|
||||||
|
DialTimeout: 2 * time.Second,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := Client.Ping(ctx).Err(); err != nil {
|
if err := Client.Ping(ctx).Err(); err != nil {
|
||||||
log.Printf("[redis] connection failed (%v), fallback to memory mode", err)
|
log.Printf("[redis] Redis 没开或连接失败 (%v),已降级为内存模式运行", err)
|
||||||
Enabled = false
|
Enabled = false
|
||||||
Client = nil
|
Client = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Enabled = true
|
Enabled = true
|
||||||
log.Printf("[redis] connected to %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB)
|
log.Printf("[redis] Redis 连接成功: %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set 设置键值(带过期时间,0=不过期)
|
// Set 设置键值(带过期时间,0=不过期)
|
||||||
|
|||||||
+10
-4
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"photowall/internal/config"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -15,6 +14,14 @@ import (
|
|||||||
|
|
||||||
var ErrNotConfigured = errors.New("短信服务未配置,请联系管理员")
|
var ErrNotConfigured = errors.New("短信服务未配置,请联系管理员")
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Provider string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
SignName string
|
||||||
|
TemplateCode string
|
||||||
|
}
|
||||||
|
|
||||||
type codeItem struct {
|
type codeItem struct {
|
||||||
code string
|
code string
|
||||||
expireAt time.Time
|
expireAt time.Time
|
||||||
@@ -26,11 +33,10 @@ var (
|
|||||||
|
|
||||||
// SendCode 发送短信验证码
|
// SendCode 发送短信验证码
|
||||||
// 开发阶段(未配置短信服务商):验证码输出到日志,方便测试
|
// 开发阶段(未配置短信服务商):验证码输出到日志,方便测试
|
||||||
func SendCode(phone string) (string, error) {
|
func SendCode(cfg Config, phone string) (string, error) {
|
||||||
cfg := config.C
|
|
||||||
code := generateCode()
|
code := generateCode()
|
||||||
|
|
||||||
if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" || cfg.SMSSignName == "" || cfg.SMSTemplateCode == "" {
|
if cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.SignName == "" || cfg.TemplateCode == "" {
|
||||||
// 未配置短信服务商,开发模式:日志输出验证码
|
// 未配置短信服务商,开发模式:日志输出验证码
|
||||||
fmt.Printf("[SMS-DEV] 手机号 %s 的验证码: %s (5分钟有效)\n", phone, code)
|
fmt.Printf("[SMS-DEV] 手机号 %s 的验证码: %s (5分钟有效)\n", phone, code)
|
||||||
store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)})
|
store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)})
|
||||||
|
|||||||
@@ -317,10 +317,14 @@ a {
|
|||||||
|
|
||||||
// 验证码图片
|
// 验证码图片
|
||||||
.captcha-img {
|
.captcha-img {
|
||||||
|
width: 120px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid @border-color;
|
border: 1px solid @border-color;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ H5 响应式 ============
|
// ============ H5 响应式 ============
|
||||||
|
|||||||
+57
-16
@@ -1,11 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuth } from '../composables/useAuth'
|
import { useAuth } from '../composables/useAuth'
|
||||||
|
|
||||||
// 静态路由(不需要登录的页面)
|
// 静态路由(不需要登录的公开页面)
|
||||||
|
// 注意:千万不要在 staticRoutes 中定义没有目标组件的通配重定向(如 redirect: '/'),否则在动态路由未挂载时会引发递归死循环
|
||||||
const staticRoutes = [
|
const staticRoutes = [
|
||||||
{ path: '/login', name: 'Login', component: () => import('../views/auth/login/index.vue'), meta: { public: true } },
|
{ path: '/login', name: 'Login', component: () => import('../views/auth/login/index.vue'), meta: { public: true } },
|
||||||
{ path: '/forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/forgot/index.vue'), meta: { public: true } },
|
{ path: '/forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/forgot/index.vue'), meta: { public: true } }
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -21,20 +21,18 @@ const componentMap = import.meta.glob('../views/**/index.vue')
|
|||||||
|
|
||||||
// 根据菜单数据动态注册路由
|
// 根据菜单数据动态注册路由
|
||||||
export function loadDynamicRoutes(menus) {
|
export function loadDynamicRoutes(menus) {
|
||||||
// 先移除旧的动态路由
|
// 先重置旧的动态路由
|
||||||
if (dynamicRoutesLoaded) {
|
resetDynamicRoutes()
|
||||||
router.getRoutes().forEach(r => {
|
|
||||||
if (!staticRoutes.find(s => s.name === r.name)) {
|
|
||||||
router.removeRoute(r.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerMenu(menu) {
|
function registerMenu(menu) {
|
||||||
if (menu.component && menu.component !== '') {
|
if (menu.component && menu.component.trim() !== '') {
|
||||||
const compPath = `../views/${menu.component}.vue`
|
const cleanComp = menu.component.replace(/\.vue$/, '').trim()
|
||||||
|
const compPath = `../views/${cleanComp}.vue`
|
||||||
const component = componentMap[compPath]
|
const component = componentMap[compPath]
|
||||||
if (component) {
|
if (component) {
|
||||||
|
if (router.hasRoute(menu.name)) {
|
||||||
|
router.removeRoute(menu.name)
|
||||||
|
}
|
||||||
router.addRoute({
|
router.addRoute({
|
||||||
path: menu.path,
|
path: menu.path,
|
||||||
name: menu.name,
|
name: menu.name,
|
||||||
@@ -48,6 +46,21 @@ export function loadDynamicRoutes(menus) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (menu.redirect && menu.redirect.trim() !== '') {
|
||||||
|
if (router.hasRoute(menu.name)) {
|
||||||
|
router.removeRoute(menu.name)
|
||||||
|
}
|
||||||
|
router.addRoute({
|
||||||
|
path: menu.path,
|
||||||
|
name: menu.name,
|
||||||
|
redirect: menu.redirect,
|
||||||
|
meta: {
|
||||||
|
title: menu.title,
|
||||||
|
icon: menu.icon,
|
||||||
|
requireAuth: menu.require_auth,
|
||||||
|
requireAdmin: menu.require_admin
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (menu.children && menu.children.length > 0) {
|
if (menu.children && menu.children.length > 0) {
|
||||||
menu.children.forEach(registerMenu)
|
menu.children.forEach(registerMenu)
|
||||||
@@ -55,14 +68,26 @@ export function loadDynamicRoutes(menus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
menus.forEach(registerMenu)
|
menus.forEach(registerMenu)
|
||||||
|
|
||||||
|
// 动态路由挂载完毕后,在末尾注册通配重定向到首页
|
||||||
|
if (router.hasRoute('NotFound')) {
|
||||||
|
router.removeRoute('NotFound')
|
||||||
|
}
|
||||||
|
router.addRoute({
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
redirect: '/'
|
||||||
|
})
|
||||||
|
|
||||||
dynamicRoutesLoaded = true
|
dynamicRoutesLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置动态路由(登出时调用)
|
// 重置动态路由(登出时调用)
|
||||||
export function resetDynamicRoutes() {
|
export function resetDynamicRoutes() {
|
||||||
dynamicRoutesLoaded = false
|
dynamicRoutesLoaded = false
|
||||||
|
const staticNames = staticRoutes.map(s => s.name)
|
||||||
router.getRoutes().forEach(r => {
|
router.getRoutes().forEach(r => {
|
||||||
if (!staticRoutes.find(s => s.name === r.name)) {
|
if (r.name && !staticNames.includes(r.name)) {
|
||||||
router.removeRoute(r.name)
|
router.removeRoute(r.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -76,7 +101,23 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
if (isLogin.value && expireAt && Date.now() > parseInt(expireAt)) {
|
if (isLogin.value && expireAt && Date.now() > parseInt(expireAt)) {
|
||||||
logout()
|
logout()
|
||||||
resetDynamicRoutes()
|
resetDynamicRoutes()
|
||||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
next({ path: '/login', query: to.path !== '/' ? { redirect: to.fullPath } : undefined })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录状态下访问登录页,直接跳转首页
|
||||||
|
if (to.path === '/login' && isLogin.value) {
|
||||||
|
if (!dynamicRoutesLoaded) {
|
||||||
|
try {
|
||||||
|
await loadMenus()
|
||||||
|
} catch (e) {
|
||||||
|
logout()
|
||||||
|
resetDynamicRoutes()
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next('/')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +129,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
|
|
||||||
// 未登录跳登录
|
// 未登录跳登录
|
||||||
if (!isLogin.value) {
|
if (!isLogin.value) {
|
||||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
next({ path: '/login', query: to.path !== '/' ? { redirect: to.fullPath } : undefined })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="LockIcon" />
|
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="LockIcon" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="captcha_code">
|
<el-form-item prop="captcha_code">
|
||||||
<div style="display:flex;gap:10px;width:100%">
|
<div style="display:flex;gap:10px;width:100%;align-items:center">
|
||||||
<el-input v-model="loginForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
<el-input v-model="loginForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
||||||
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<el-input v-model="regForm.email" placeholder="邮箱(用于找回密码)" size="large" :prefix-icon="MailIcon" />
|
<el-input v-model="regForm.email" placeholder="邮箱(用于找回密码)" size="large" :prefix-icon="MailIcon" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="captcha_code">
|
<el-form-item prop="captcha_code">
|
||||||
<div style="display:flex;gap:10px;width:100%">
|
<div style="display:flex;gap:10px;width:100%;align-items:center">
|
||||||
<el-input v-model="regForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
<el-input v-model="regForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
||||||
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user