优化用户相关功能 (#8)

* improve(user):优化注册功能提示

* improve(user):优化邮箱大小写重复问题

* improve(user):添加密码强度指示器

* fix(setting):修复系统设置功能里面的数字转换问题

* feat(email):优化邮件发送功能

* chore(email):移除无用配置和日志

* fix(user):修复重置密码功能

* chore(ui):调整轮询间隔

* chore(ui):添加充值提示

* chore(ui):优化用户注册功能

* chore(sql):补充数据库变更文件

* fix(email):优化邮箱校验

---------

Co-authored-by: qian <wuchengqian5@163.com>
This commit is contained in:
Qianandqian authored and GitHub committed 2026-06-17 22:47:26 +08:00
1 parent cce79ebb28
commit 501726b831
42 files changed
+1168 -124

No files matched your search

+3
View File
@@ -93,6 +93,9 @@ type IAdminV1 interface {
DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (res *v1.DataGovernanceExportRes, err error)
DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (res *v1.DataGovernanceDeletionRes, err error)
DataGovernanceCleanup(ctx context.Context, req *v1.DataGovernanceCleanupReq) (res *v1.DataGovernanceCleanupRes, err error)
EmailSendLogList(ctx context.Context, req *v1.EmailSendLogListReq) (res *v1.EmailSendLogListRes, err error)
EmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (res *v1.EmailSendLogDetailRes, err error)
EmailVerifyCodeList(ctx context.Context, req *v1.EmailVerifyCodeListReq) (res *v1.EmailVerifyCodeListRes, err error)
ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (res *v1.ErrorLogListRes, err error)
ErrorLogDetail(ctx context.Context, req *v1.ErrorLogDetailReq) (res *v1.ErrorLogDetailRes, err error)
ErrorLogResolve(ctx context.Context, req *v1.ErrorLogResolveReq) (res *v1.ErrorLogResolveRes, err error)
+60
View File
@@ -0,0 +1,60 @@
package v1
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// === 邮件发送记录(ntf_send_log)===
type EmailSendLogListReq struct {
g.Meta `path:"/email/send-logs" method:"get" mime:"json" tags:"管理后台-邮件" summary:"邮件发送记录列表"`
Page int `json:"page" d:"1"`
PageSize int `json:"page_size" d:"20"`
Status string `json:"status" dc:"状态:sent/failed"`
Recipient string `json:"recipient" dc:"收件人(模糊匹配)"`
TemplateCode string `json:"template_code" dc:"模板编码"`
StartDate string `json:"start_date" dc:"开始日期 YYYY-MM-DD"`
EndDate string `json:"end_date" dc:"结束日期 YYYY-MM-DD"`
}
type EmailSendLogListRes struct {
List []map[string]any `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type EmailSendLogDetailReq struct {
g.Meta `path:"/email/send-logs/{id}" method:"get" mime:"json" tags:"管理后台-邮件" summary:"邮件发送记录详情"`
Id int64 `json:"id" in:"path" v:"required|min:1"`
}
type EmailSendLogDetailRes struct {
Data map[string]any `json:"-"`
}
func (r *EmailSendLogDetailRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// === 验证码申请记录(sys_email_verify_codes)===
type EmailVerifyCodeListReq struct {
g.Meta `path:"/email/verify-codes" method:"get" mime:"json" tags:"管理后台-邮件" summary:"验证码申请记录列表"`
Page int `json:"page" d:"1"`
PageSize int `json:"page_size" d:"20"`
Email string `json:"email" dc:"邮箱(模糊匹配)"`
Purpose string `json:"purpose" dc:"用途:register/reset_password/change_email"`
Used string `json:"used" dc:"是否已使用:true/false"`
StartDate string `json:"start_date" dc:"开始日期 YYYY-MM-DD"`
EndDate string `json:"end_date" dc:"结束日期 YYYY-MM-DD"`
}
type EmailVerifyCodeListRes struct {
List []map[string]any `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+1
View File
@@ -48,6 +48,7 @@ type ITenantV1 interface {
TenantSendCode(ctx context.Context, req *v1.TenantSendCodeReq) (res *v1.TenantSendCodeRes, err error)
TenantResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (res *v1.TenantResetPasswordRes, err error)
TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error)
TenantSendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (res *v1.TenantSendChangeEmailCodeRes, err error)
FeedbackCreate(ctx context.Context, req *v1.FeedbackCreateReq) (res *v1.FeedbackCreateRes, err error)
FeedbackList(ctx context.Context, req *v1.FeedbackListReq) (res *v1.FeedbackListRes, err error)
FeedbackGet(ctx context.Context, req *v1.FeedbackGetReq) (res *v1.FeedbackGetRes, err error)
+13 -3
View File
@@ -4,9 +4,11 @@ import "github.com/gogf/gf/v2/frame/g"
// TenantSendCodeReq 发送验证码请求
type TenantSendCodeReq struct {
g.Meta `path:"/email/send-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送验证码" group:"public" middleware:"-"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Purpose string `json:"purpose" v:"required|in:register,reset_password,change_email#请选择用途|用途无效" dc:"用途:register / reset_password / change_email"`
g.Meta `path:"/email/send-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送验证码" group:"public" middleware:"-"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Purpose string `json:"purpose" v:"required|in:register,reset_password,change_email#请选择用途|用途无效" dc:"用途:register / reset_password / change_email"`
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(找回密码用途必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(找回密码用途必填)"`
}
type TenantSendCodeRes struct{}
@@ -31,3 +33,11 @@ type TenantChangeEmailReq struct {
}
type TenantChangeEmailRes struct{}
// TenantSendChangeEmailCodeReq 设置/修改邮箱时发送验证码(需登录,发送前校验租户内邮箱唯一性)
type TenantSendChangeEmailCodeReq struct {
g.Meta `path:"/email/send-change-email-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送设置邮箱验证码"`
NewEmail string `json:"new_email" v:"required|email#请输入新邮箱|邮箱格式不正确" dc:"新邮箱"`
}
type TenantSendChangeEmailCodeRes struct{}
+1 -1
View File
@@ -98,7 +98,7 @@ type TenantMemberJoinReq struct {
Code string `json:"code" v:"required#请提供邀请码" dc:"邀请码"`
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"`
DisplayName string `json:"display_name" dc:"显示名称"`
}
+20
View File
@@ -485,3 +485,23 @@ Events []string `json:"events" v:"required|foreach|length:1,100#请选择事件|
```
**注意**:GoFrame v2 没有内置规则直接校验 slice 的元素个数。如需限制元素数量,需自定义校验规则或在 logic 层手动检查。
### 2026-06-16:gerror.NewCode 误用为格式化构造,错误消息占位符未替换
**问题描述**:注册时命中禁用词,返回的错误消息未格式化,用户看到 `%s包含禁用词「%s」,请修改后重试, 用户名, test`——`%s` 原样输出,参数被用 `, ` 拼接在末尾。
**原因**:`gerror.NewCode(code gcode.Code, text ...string)` 的第二及后续参数是**字面文本**(`...string`),不是 `format + args`。代码误把含 `%s` 占位符的模板连同 `fieldName`、`word` 一起传入,GoFrame 把它们当作多段 text 用 `, ` 拼接,模板原样保留、占位符不替换。带格式化的对应方法是 `gerror.NewCodef(code, format, args...)`。
```go
// 错误 — NewCode 的参数是字面 text(...string),%s 不会被替换,多余参数被拼到末尾
return gerror.NewCode(gcode.New(...), "%s包含禁用词「%s」,请修改后重试", fieldName, word)
// 输出:%s包含禁用词「%s」,请修改后重试, 用户名, test
// 正确 — 用 NewCodef 做格式化
return gerror.NewCodef(gcode.New(...), "%s包含禁用词「%s」,请修改后重试", fieldName, word)
// 输出:用户名包含禁用词「test」,请修改后重试
```
**修复方式**:`internal/logic/common/validation.go` 中 `ValidateForbiddenWords` 的 `gerror.NewCode` 改为 `gerror.NewCodef`。
**正确做法**:构造错误时区分 `NewXxx`(字面文本)与 `NewXxxf`(格式化)两个系列。凡消息含 `%s`/`%v`/`%d` 等占位符且需填入变量,必须用 `f` 变体(`gerror.Newf`、`gerror.NewCodef`、`gerror.NewWrapf`、`gerror.NewSkipf`);把格式串当字面文本传入 `NewXxx` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。
+2
View File
@@ -177,6 +177,7 @@ const (
CodeIpRateLimitExceeded = 10090 // IP级别限流
CodeGlobalRateLimitExceeded = 10091 // 全局限流
CodeForbiddenWord = 10096 // 名称包含禁用词
CodeEmailExists = 10097 // 邮箱已被使用
// Setup errors
CodeSetupCompleted = 10078
@@ -253,6 +254,7 @@ const (
MsgRateLimitExceeded = "请求频率超限"
MsgPasswordTooWeak = "密码不符合策略"
MsgUsernameExists = "用户名已存在"
MsgEmailExists = "该邮箱已被使用"
MsgTenantCodeExists = "租户代码已存在"
MsgSessionExpired = "会话已过期"
MsgTokenExpired = "Token 已过期"
@@ -0,0 +1,18 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) EmailSendLogList(ctx context.Context, req *v1.EmailSendLogListReq) (res *v1.EmailSendLogListRes, err error) {
return service.Admin().ListEmailSendLogs(ctx, req)
}
func (c *ControllerV1) EmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (res *v1.EmailSendLogDetailRes, err error) {
return service.Admin().GetEmailSendLogDetail(ctx, req)
}
func (c *ControllerV1) EmailVerifyCodeList(ctx context.Context, req *v1.EmailVerifyCodeListReq) (res *v1.EmailVerifyCodeListRes, err error) {
return service.Admin().ListEmailVerifyCodes(ctx, req)
}
@@ -16,3 +16,6 @@ func (c *ControllerV1) TenantResetPassword(ctx context.Context, req *v1.TenantRe
func (c *ControllerV1) TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error) {
return service.Tenant().ChangeEmail(ctx, req)
}
func (c *ControllerV1) TenantSendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (res *v1.TenantSendChangeEmailCodeRes, err error) {
return service.Tenant().SendChangeEmailCode(ctx, req)
}
+19 -8
View File
@@ -71,6 +71,7 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
// CreateUser creates a new admin user.
func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) {
username := strings.TrimSpace(req.Username)
email := strings.ToLower(strings.TrimSpace(req.Email))
// Validate username format
if err := common.ValidateUsername(username); err != nil {
@@ -87,15 +88,16 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
return nil, common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
}
// Check email uniqueness if provided
if req.Email != "" {
// Check email uniqueness if provided (normalized to lowercase so that
// e.g. "User@Example.com" and "user@example.com" collide as the same address).
if email != "" {
count, err = dao.SysAdminUsers.Ctx(ctx).
Where("email", strings.TrimSpace(req.Email)).Count()
Where("email", email).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBadRequestError("邮箱已被使用")
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
}
@@ -122,12 +124,18 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{
Username: username,
PasswordHash: passwordHash,
Email: strings.TrimSpace(req.Email),
Email: email,
DisplayName: username,
Role: role,
Status: "active",
}).Insert()
if err != nil {
// Race condition: another request inserted a colliding username/email
// between our pre-check and this insert. Translate the DB unique
// violation into a friendly business error.
if common.IsDuplicateKeyError(err) {
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
return nil, err
}
@@ -158,15 +166,15 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
data.DisplayName = *req.DisplayName
}
if req.Email != nil {
email := strings.TrimSpace(*req.Email)
// Check uniqueness
email := strings.ToLower(strings.TrimSpace(*req.Email))
// Check uniqueness (normalized to lowercase)
count, err := dao.SysAdminUsers.Ctx(ctx).
Where("email", email).Where("id != ?", req.Id).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBadRequestError("邮箱已被使用")
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
data.Email = email
}
@@ -179,6 +187,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(data)
if err != nil {
if common.IsDuplicateKeyError(err) {
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
return nil, err
}
+172
View File
@@ -0,0 +1,172 @@
package admin
import (
"context"
"encoding/json"
"strings"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/model/entity"
)
// ListEmailSendLogs 分页查询邮件发送记录(ntf_send_log,主库)。
// 列表不返回 body(HTML 正文较大),由详情接口返回完整记录。
func (s *sAdmin) ListEmailSendLogs(ctx context.Context, req *v1.EmailSendLogListReq) (*v1.EmailSendLogListRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, req.Status)
}
if req.Recipient != "" {
conditions = append(conditions, "recipient LIKE ?")
args = append(args, "%"+req.Recipient+"%")
}
if req.TemplateCode != "" {
conditions = append(conditions, "template_code = ?")
args = append(args, req.TemplateCode)
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := strings.Join(conditions, " AND ")
countM := dao.NtfSendLog.Ctx(ctx).Safe()
if where != "" {
countM = countM.Where(where, args...)
}
total, err := countM.Count()
if err != nil {
return nil, err
}
if total == 0 {
return &v1.EmailSendLogListRes{List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize}, nil
}
dataM := dao.NtfSendLog.Ctx(ctx).Safe().
Fields("id, tenant_id, user_id, template_code, channel, recipient, subject, status, error_message, retry_count, sent_at, created_at").
OrderDesc("created_at").
Page(page, pageSize)
if where != "" {
dataM = dataM.Where(where, args...)
}
result, err := dataM.All()
if err != nil {
return nil, err
}
items := make([]map[string]any, 0, len(result))
for _, row := range result {
m := make(map[string]any, len(row))
for k, v := range row {
m[k] = v.Val()
}
items = append(items, m)
}
return &v1.EmailSendLogListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// GetEmailSendLogDetail 查询单条邮件发送记录详情(含 body 正文)。
func (s *sAdmin) GetEmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (*v1.EmailSendLogDetailRes, error) {
var record *entity.NtfSendLog
err := dao.NtfSendLog.Ctx(ctx).Where("id", req.Id).Scan(&record)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if record == nil {
return nil, common.NewNotFoundError("邮件发送记录")
}
b, _ := json.Marshal(record)
var detail map[string]any
_ = json.Unmarshal(b, &detail)
return &v1.EmailSendLogDetailRes{Data: detail}, nil
}
// ListEmailVerifyCodes 分页查询验证码申请记录(sys_email_verify_codes,主库)。
func (s *sAdmin) ListEmailVerifyCodes(ctx context.Context, req *v1.EmailVerifyCodeListReq) (*v1.EmailVerifyCodeListRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.Email != "" {
conditions = append(conditions, "email LIKE ?")
args = append(args, "%"+req.Email+"%")
}
if req.Purpose != "" {
conditions = append(conditions, "purpose = ?")
args = append(args, req.Purpose)
}
if req.Used == "true" {
conditions = append(conditions, "used_at IS NOT NULL")
} else if req.Used == "false" {
conditions = append(conditions, "used_at IS NULL")
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := strings.Join(conditions, " AND ")
countM := dao.SysEmailVerifyCodes.Ctx(ctx).Safe()
if where != "" {
countM = countM.Where(where, args...)
}
total, err := countM.Count()
if err != nil {
return nil, err
}
if total == 0 {
return &v1.EmailVerifyCodeListRes{List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize}, nil
}
dataM := dao.SysEmailVerifyCodes.Ctx(ctx).Safe().
Fields("id, email, code, purpose, expires_at, used_at, created_at").
OrderDesc("created_at").
Page(page, pageSize)
if where != "" {
dataM = dataM.Where(where, args...)
}
result, err := dataM.All()
if err != nil {
return nil, err
}
items := make([]map[string]any, 0, len(result))
for _, row := range result {
m := make(map[string]any, len(row))
for k, v := range row {
m[k] = v.Val()
}
items = append(items, m)
}
return &v1.EmailVerifyCodeListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
+1 -12
View File
@@ -79,7 +79,7 @@ func CreateAdmin(ctx context.Context, username, password, displayName string) er
Status: "active",
}).Insert()
if err != nil {
if isDuplicateKeyError(err) {
if common.IsDuplicateKeyError(err) {
return common.NewBusinessError(consts.CodeSetupCompleted, consts.MsgSetupCompleted)
}
return err
@@ -112,14 +112,3 @@ func AutoInitAdmin(ctx context.Context) (bool, error) {
g.Log().Info(ctx, "通过环境变量自动初始化管理员完成")
return true, nil
}
// isDuplicateKeyError checks if the error is a PostgreSQL unique constraint violation.
// PostgreSQL error code 23505 = unique_violation.
// NOTE: Uses string matching on the error message. Consider switching to
// pq.Error type assertion if github.com/lib/pq becomes a direct dependency.
func isDuplicateKeyError(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key")
}
+20
View File
@@ -205,6 +205,26 @@ func CheckCaptchaRequired(ctx context.Context, _ string, captchaKey string, capt
return nil
}
// IsCaptchaVerified checks whether the slider captcha has been completed
// (the verified flag is set by VerifyCaptcha during the slide). Unlike
// CheckCaptchaRequired it does NOT consume the flag, so a subsequent
// consuming check (e.g. at password reset) still passes. Used to gate
// low-sensitivity actions like sending a reset code without burning the
// one-shot verification before the final sensitive operation.
func IsCaptchaVerified(ctx context.Context, captchaKey string, _ int) error {
if captchaKey == "" {
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
}
verifiedKey := fmt.Sprintf("%s:%s", captchaVerifiedPrefix, captchaKey)
result, err := g.Redis().Do(ctx, "GET", verifiedKey)
if err != nil || result.IsNil() {
// No verified flag → user never completed the slide (or it expired)
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
}
return nil
}
func getCaptchaExpireSeconds(ctx context.Context) time.Duration {
secs := Config().GetInt(ctx, "captcha_expire_seconds")
if secs < 60 {
+14
View File
@@ -3,6 +3,7 @@ package common
import (
"database/sql"
"errors"
"strings"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
@@ -56,3 +57,16 @@ func IgnoreScanNoRows(err error) error {
}
return err
}
// IsDuplicateKeyError reports whether err is a PostgreSQL unique-constraint
// violation (SQLSTATE 23505). Used to convert race-condition insert failures
// into friendly business errors at the data layer's last line of defense.
//
// Uses string matching on the driver error message. Switch to a typed assertion
// (pq.Error / pgconn.PgError) if a Postgres driver becomes a direct dependency.
func IsDuplicateKeyError(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key")
}
+1 -1
View File
@@ -97,7 +97,7 @@ func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error
continue
}
if strings.Contains(lowerVal, strings.ToLower(word)) {
return gerror.NewCode(gcode.New(consts.CodeForbiddenWord, consts.MsgForbiddenWord, nil),
return gerror.NewCodef(gcode.New(consts.CodeForbiddenWord, consts.MsgForbiddenWord, nil),
"%s包含禁用词「%s」,请修改后重试", fieldName, word)
}
}
+5
View File
@@ -80,6 +80,11 @@ func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) er
}
go func() {
defer func() {
if r := recover(); r != nil {
g.Log().Errorf(context.Background(), "send verify code email panic: %v", r)
}
}()
bgCtx := context.Background()
emailCfg, err := EmailConfigFromOptions(bgCtx)
if err != nil {
+86 -5
View File
@@ -25,9 +25,28 @@ func (s *sTenant) SendCode(ctx context.Context, req *v1.TenantSendCodeReq) (*v1.
return nil, common.NewBusinessError(consts.CodeEmailVerificationDisabled, consts.MsgEmailVerificationDisabled)
}
case common.VerifyPurposeResetPwd:
// Check if user exists with this email
// For tenant reset, we need to know which tenant - user provides email
// We'll verify the email exists during the actual reset
// Require slider captcha before sending a reset code. Non-consuming:
// the verified flag is left intact for the consuming check at
// ResetPassword. (register/change_email intentionally not gated here.)
if err := common.IsCaptchaVerified(ctx, req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
}
// 仅主用户(owner)可通过邮箱重置密码;校验邮箱是否属于某个 owner 账号。
// 放在滑块验证之后,避免未通过人机验证就枚举哪些邮箱是主账号。
var owner *entity.TntUsers
err := dao.TntUsers.Ctx(ctx).
Where("email", email).
Where("role", "owner").
Scan(&owner)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if owner == nil {
return nil, common.NewBadRequestError("该邮箱未注册或非主账号,无法通过邮箱重置密码")
}
if owner.Status != "active" {
return nil, common.NewBadRequestError("账号状态异常,请联系管理员")
}
case common.VerifyPurposeChangeEmail:
// Check if new email is already taken by another user in same tenant
// Tenant context should be available
@@ -57,16 +76,18 @@ func (s *sTenant) ResetPassword(ctx context.Context, req *v1.TenantResetPassword
return nil, err
}
// Find user by email (search across all tenants)
// Find owner (主用户) by email — only owners may reset by email.
// Members are managed by the owner/admin and do not self-reset via email.
var user *entity.TntUsers
err = dao.TntUsers.Ctx(ctx).
Where("email", email).
Where("role", "owner").
Scan(&user)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if user == nil {
return nil, common.NewBadRequestError("该邮箱未注册")
return nil, common.NewBadRequestError("该邮箱未注册或非主账号,无法重置密码")
}
if user.Status != "active" {
@@ -107,6 +128,11 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
userID := middleware.GetUserID(ctx)
newEmail := strings.TrimSpace(strings.ToLower(req.NewEmail))
// Reject no-op: block if the new email equals the user's current email.
if err := rejectSameEmail(ctx, userID, newEmail); err != nil {
return nil, err
}
// Verify code for new email
err := common.VerifyCode(ctx, newEmail, req.Code, "change_email")
if err != nil {
@@ -178,3 +204,58 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
return nil, nil
}
// SendChangeEmailCode sends a verification code for setting/changing email.
// Authenticated (unlike the public send-code): pre-checks that newEmail is not
// already taken by another member in the same tenant before sending, so we don't
// waste a code (and an email) on an address the user can't actually use.
func (s *sTenant) SendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (*v1.TenantSendChangeEmailCodeRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
newEmail := strings.TrimSpace(strings.ToLower(req.NewEmail))
// Reject no-op: block if the new email equals the user's current email.
if err := rejectSameEmail(ctx, userID, newEmail); err != nil {
return nil, err
}
// Pre-check: reject early if the email is already used by another member in this tenant.
count, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("email", newEmail).
Where("id <> ?", userID).
Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBadRequestError("该邮箱已被其他成员使用")
}
if err := common.SendVerifyCode(ctx, newEmail, common.VerifyPurposeChangeEmail); err != nil {
return nil, err
}
return nil, nil
}
// rejectSameEmail blocks a no-op email change: if newEmail equals the user's
// current email there's nothing to do, so refuse before sending a code (and a
// pointless notification email) or performing an identical update. A user with
// no email yet (NULL/empty) may still set any valid address.
func rejectSameEmail(ctx context.Context, userID int64, newEmail string) error {
if newEmail == "" {
return nil
}
var me struct {
Email string `json:"email"`
}
if err := dao.TntUsers.Ctx(ctx).Where("id", userID).Fields("email").Scan(&me); err != nil {
if err = common.IgnoreScanNoRows(err); err != nil {
return err
}
}
if me.Email != "" && me.Email == newEmail {
return common.NewBadRequestError("新邮箱与当前邮箱一致,无需修改")
}
return nil
}
+35 -13
View File
@@ -177,6 +177,16 @@ func buildInviteURL(ctx context.Context, code string) string {
return fmt.Sprintf("%s/#/tenant/join?code=%s", base, code)
}
// nullableEmail converts an empty email to nil so an optional email is stored as
// SQL NULL (tnt_users.email is nullable) rather than "" — the latter would occupy
// the UNIQUE(tenant_id, email) slot and block other email-less members in the tenant.
func nullableEmail(email string) any {
if email == "" {
return nil
}
return email
}
// JoinByInvite handles a user joining a tenant via invitation link.
func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq) (*v1.TenantMemberJoinRes, error) {
// Find invitation
@@ -268,16 +278,18 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
return common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
}
// Check email uniqueness within tenant
count, err = tx.Model("tnt_users").Ctx(ctx).
Where("tenant_id", tenantID).
Where("email", email).
Count()
if err != nil {
return err
}
if count > 0 {
return common.NewBadRequestError("该邮箱已被使用")
// Check email uniqueness within tenant (email is optional — skip when empty)
if email != "" {
count, err = tx.Model("tnt_users").Ctx(ctx).
Where("tenant_id", tenantID).
Where("email", email).
Count()
if err != nil {
return err
}
if count > 0 {
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
}
// Create user
@@ -289,13 +301,18 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: email,
Email: nullableEmail(email),
PasswordHash: passwordHash,
DisplayName: displayName,
Role: invitation.Role,
Status: "active",
}).Insert()
if err != nil {
// Race condition: another request inserted a colliding
// username/email between our pre-check and this insert.
if common.IsDuplicateKeyError(err) {
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
return gerror.Wrapf(err, "创建用户失败")
}
userID, err = userResult.LastInsertId()
@@ -441,7 +458,7 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
return err
}
if count > 0 {
return common.NewBadRequestError("该邮箱已被使用")
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
}
@@ -449,13 +466,18 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: email,
Email: nullableEmail(email),
PasswordHash: passwordHash,
DisplayName: displayName,
Role: req.Role,
Status: "active",
}).Insert()
if err != nil {
// Race condition: another request inserted a colliding
// username/email between our pre-check and this insert.
if common.IsDuplicateKeyError(err) {
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
}
return gerror.Wrapf(err, "创建用户失败")
}
userID, err = userResult.LastInsertId()
+7
View File
@@ -178,6 +178,13 @@ type (
DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (*v1.DataGovernanceDeletionRes, error)
// TriggerDataCleanup 手动触发数据清理
DataGovernanceCleanup(ctx context.Context, _ *v1.DataGovernanceCleanupReq) (*v1.DataGovernanceCleanupRes, error)
// ListEmailSendLogs 分页查询邮件发送记录(ntf_send_log,主库)。
// 列表不返回 body(HTML 正文较大),由详情接口返回完整记录。
ListEmailSendLogs(ctx context.Context, req *v1.EmailSendLogListReq) (*v1.EmailSendLogListRes, error)
// GetEmailSendLogDetail 查询单条邮件发送记录详情(含 body 正文)。
GetEmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (*v1.EmailSendLogDetailRes, error)
// ListEmailVerifyCodes 分页查询验证码申请记录(sys_email_verify_codes,主库)。
ListEmailVerifyCodes(ctx context.Context, req *v1.EmailVerifyCodeListReq) (*v1.EmailVerifyCodeListRes, error)
// ErrorLogList returns a paginated list of system error logs.
ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (*v1.ErrorLogListRes, error)
// ErrorLogDetail returns the detail of a single error log.
+5
View File
@@ -94,6 +94,11 @@ type (
ResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (*v1.TenantResetPasswordRes, error)
// ChangeEmail handles email change for a tenant user.
ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (*v1.TenantChangeEmailRes, error)
// SendChangeEmailCode sends a verification code for setting/changing email.
// Authenticated (unlike the public send-code): pre-checks that newEmail is not
// already taken by another member in the same tenant before sending, so we don't
// waste a code (and an email) on an address the user can't actually use.
SendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (*v1.TenantSendChangeEmailCodeRes, error)
// CreateFeedback 提交反馈
CreateFeedback(ctx context.Context, req *v1.FeedbackCreateReq) (*v1.FeedbackCreateRes, error)
// ListFeedbacks 我的反馈列表
-8
View File
@@ -56,14 +56,6 @@ jwt:
crypto:
encryptionKey: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
# Email SMTP (fallback defaults, override via opt_options or env vars)
email:
smtp:
host: ""
port: 587
username: ""
password: ""
from: ""
# Demo mode (read-only mode, configured here — not in admin UI)
# Set enabled: true to block all write operations and show a watermark
-8
View File
@@ -73,14 +73,6 @@ jwt:
crypto:
encryptionKey: "请修改:运行 openssl rand -hex 32 生成 64 位十六进制密钥" # 请修改
# 邮件 SMTP 配置(可选,用于邮箱验证码等功能)
email:
smtp:
host: ""
port: 587
username: ""
password: ""
from: ""
# 演示模式(通过配置文件控制,不在管理后台设置)
# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印
+25
View File
@@ -0,0 +1,25 @@
-- +goose Up
-- 将 tnt_users.email 由 NOT NULL 改为可空。
--
-- 背景:普通成员的邮箱在系统中基本用不上——成员只能用 RAM 账号(username@tenant_code)
-- 登录(admin 邮箱登录只认 role=owner 的主用户),且成员不能凭邮箱自助重置密码,
-- 邮箱仅用于邮件通知与成员列表展示。因此将邀请加入、管理员创建、CSV 导入三条
-- 创建路径统一为「邮箱选填」,与 CreateMember / member_import 既有的选填行为对齐。
--
-- 顺带修复既有隐患:原 email 为 NOT NULL + UNIQUE(tenant_id, email),不填邮箱时
-- 代码只能写入空字符串 "",而空字符串是确定值,导致同一租户下仅能存在一个空邮箱
-- 成员,第二个会撞唯一约束报「邮箱已存在」。改为可空后,PostgreSQL 唯一索引允许
-- 多个 NULL,多个无邮箱成员不再冲突。
--
-- 注意:列的可空性变更不需要重新 gf gen dao——DO 中 Email 字段本就声明为 any 类型,
-- 业务代码在 email 为空时传 nil 即可写入 SQL NULL。
ALTER TABLE tnt_users ALTER COLUMN email DROP NOT NULL;
-- 将已存在的空字符串 email 归一为 NULL,避免旧空串数据继续占用唯一约束位。
UPDATE tnt_users SET email = NULL WHERE email = '';
-- +goose Down
-- 恢复 NOT NULL 前需保证无 NULL email(否则 SET NOT NULL 会失败),回退为空字符串。
UPDATE tnt_users SET email = '' WHERE email IS NULL;
ALTER TABLE tnt_users ALTER COLUMN email SET NOT NULL;
+6
View File
@@ -186,6 +186,12 @@ const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/operation/MessagesPage.vue'),
meta: { title: '消息管理' },
},
{
path: 'email-logs',
name: 'AdminEmailLogs',
component: () => import('@/views/operation/EmailLogsPage.vue'),
meta: { title: '邮件发送记录' },
},
{
path: 'announcements',
name: 'AdminAnnouncements',
+1
View File
@@ -326,6 +326,7 @@ const menuGroups = [
items: [
{ name: 'AdminNotificationTemplates', label: '通知模板', icon: IconNotification },
{ name: 'AdminMessages', label: '消息管理', icon: IconSend },
{ name: 'AdminEmailLogs', label: '邮件发送记录', icon: IconFile },
{ name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage },
{ name: 'AdminTickets', label: '工单管理', icon: IconCommand },
{ name: 'AdminFeedback', label: '反馈管理', icon: IconMessage },
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch, h } from 'vue'
import { Tag, Button } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
const activeTab = ref<'send-logs' | 'verify-codes'>('send-logs')
// ====== Tab 1: 邮件发送记录(ntf_send_log)======
const sendLoading = ref(false)
const sendData = ref<any[]>([])
const sendPagination = reactive({
current: 1, pageSize: 20, total: 0,
showPageSize: true, pageSizeOptions: [10, 20, 50],
})
const sendFilter = reactive({
status: '',
recipient: '',
date_range: [] as string[],
})
const statusColor: Record<string, string> = { sent: 'green', failed: 'red', pending: 'orange' }
const statusLabel: Record<string, string> = { sent: '已发送', failed: '发送失败', pending: '待发送' }
const sendColumns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '收件人', dataIndex: 'recipient', width: 200, ellipsis: true },
{ title: '主题', dataIndex: 'subject', width: 200, ellipsis: true },
{ title: '模板', dataIndex: 'template_code', width: 120, ellipsis: true, render({ record }) { return record.template_code || '-' } },
{
title: '状态', dataIndex: 'status', width: 90,
render({ record }) {
return h(Tag, { color: statusColor[record.status] || 'gray', size: 'small' }, () => statusLabel[record.status] || record.status)
},
},
{ title: '重试', dataIndex: 'retry_count', width: 60 },
{
title: '错误信息', dataIndex: 'error_message', width: 220, ellipsis: true,
render({ record }) {
return record.error_message ? h('span', { style: 'color:#f53f3f' }, record.error_message) : '-'
},
},
{ title: '发送时间', dataIndex: 'sent_at', width: 170, render({ record }) { return record.sent_at || record.created_at } },
{
title: '操作', dataIndex: 'actions', width: 70, fixed: 'right',
render({ record }) {
return h(Button, { size: 'mini', type: 'text', onClick: () => fetchSendDetail(record.id) }, () => '详情')
},
},
]
async function fetchSendData() {
sendLoading.value = true
try {
const params: Record<string, any> = { page: sendPagination.current, page_size: sendPagination.pageSize }
if (sendFilter.status) params.status = sendFilter.status
if (sendFilter.recipient) params.recipient = sendFilter.recipient
if (sendFilter.date_range.length === 2) {
params.start_date = sendFilter.date_range[0]
params.end_date = sendFilter.date_range[1]
}
const res: any = await request.get('/admin/email/send-logs', { params })
const raw = res.data?.data
sendData.value = (Array.isArray(raw?.list) ? raw.list : []).filter(Boolean)
sendPagination.total = Number(raw?.total) || 0
} catch {
sendData.value = []
sendPagination.total = 0
} finally {
sendLoading.value = false
}
}
function handleSendFilter() { sendPagination.current = 1; fetchSendData() }
function handleSendReset() {
sendFilter.status = ''
sendFilter.recipient = ''
sendFilter.date_range = []
sendPagination.current = 1
fetchSendData()
}
// 详情抽屉
const detailLoading = ref(false)
const showDetail = ref(false)
const detailRecord = ref<any>(null)
async function fetchSendDetail(id: number) {
detailLoading.value = true
showDetail.value = true
try {
const res: any = await request.get(`/admin/email/send-logs/${id}`)
detailRecord.value = res.data?.data || null
} catch {
detailRecord.value = null
} finally {
detailLoading.value = false
}
}
// ====== Tab 2: 验证码记录(sys_email_verify_codes)======
const codeLoading = ref(false)
const codeData = ref<any[]>([])
const codePagination = reactive({
current: 1, pageSize: 20, total: 0,
showPageSize: true, pageSizeOptions: [10, 20, 50],
})
const codeFilter = reactive({ email: '', purpose: '', used: '' })
const purposeColor: Record<string, string> = { register: 'arcoblue', reset_password: 'orange', change_email: 'cyan' }
const purposeLabel: Record<string, string> = { register: '注册', reset_password: '重置密码', change_email: '更换邮箱' }
const codeColumns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '邮箱', dataIndex: 'email', width: 220, ellipsis: true },
{
title: '验证码', dataIndex: 'code', width: 110,
render({ record }) {
return h('span', { style: 'font-family:monospace;letter-spacing:2px;font-weight:600;color:#165dff' }, record.code)
},
},
{
title: '用途', dataIndex: 'purpose', width: 100,
render({ record }) {
return h(Tag, { color: purposeColor[record.purpose] || 'gray', size: 'small' }, () => purposeLabel[record.purpose] || record.purpose)
},
},
{ title: '有效期至', dataIndex: 'expires_at', width: 170 },
{
title: '是否已用', dataIndex: 'used_at', width: 100,
render({ record }) {
return h(Tag, { color: record.used_at ? 'green' : 'gray', size: 'small' }, () => record.used_at ? '已使用' : '未使用')
},
},
{ title: '申请时间', dataIndex: 'created_at', width: 170 },
]
async function fetchCodeData() {
codeLoading.value = true
try {
const params: Record<string, any> = { page: codePagination.current, page_size: codePagination.pageSize }
if (codeFilter.email) params.email = codeFilter.email
if (codeFilter.purpose) params.purpose = codeFilter.purpose
if (codeFilter.used) params.used = codeFilter.used
const res: any = await request.get('/admin/email/verify-codes', { params })
const raw = res.data?.data
codeData.value = (Array.isArray(raw?.list) ? raw.list : []).filter(Boolean)
codePagination.total = Number(raw?.total) || 0
} catch {
codeData.value = []
codePagination.total = 0
} finally {
codeLoading.value = false
}
}
function handleCodeFilter() { codePagination.current = 1; fetchCodeData() }
function handleCodeReset() {
codeFilter.email = ''
codeFilter.purpose = ''
codeFilter.used = ''
codePagination.current = 1
fetchCodeData()
}
// 切 Tab 懒加载
const loaded = reactive({ 'send-logs': false, 'verify-codes': false })
watch(activeTab, (v) => {
if (v === 'verify-codes' && !loaded['verify-codes']) {
loaded['verify-codes'] = true
fetchCodeData()
}
})
onMounted(() => {
loaded['send-logs'] = true
fetchSendData()
})
</script>
<template>
<div class="page-table">
<PageHeader title="邮件发送记录" description="查看邮件实际发送记录与验证码申请记录,排查邮件是否成功发出" />
<a-tabs v-model:active-key="activeTab">
<!-- 邮件发送记录 -->
<a-tab-pane key="send-logs" title="邮件发送记录">
<a-card :bordered="false" class="mb-4">
<a-space wrap>
<a-select v-model="sendFilter.status" placeholder="状态" allow-clear style="width: 140px" @change="handleSendFilter">
<a-option value="sent">已发送</a-option>
<a-option value="failed">发送失败</a-option>
</a-select>
<a-input v-model="sendFilter.recipient" placeholder="收件人" allow-clear style="width: 220px" @keydown.enter="handleSendFilter" />
<a-range-picker v-model="sendFilter.date_range" style="width: 260px" format="YYYY-MM-DD" />
<a-button type="primary" @click="handleSendFilter">搜索</a-button>
<a-button @click="handleSendReset">重置</a-button>
</a-space>
</a-card>
<a-card :bordered="false">
<a-table
:columns="sendColumns"
:data="sendData"
:loading="sendLoading"
:scroll="{ x: 1300 }"
:bordered="false"
:stripe="true"
size="small"
:pagination="false"
row-key="id"
/>
<div class="table-footer">
<a-pagination
v-model:current="sendPagination.current"
v-model:page-size="sendPagination.pageSize"
:total="sendPagination.total"
:page-size-options="sendPagination.pageSizeOptions"
show-page-size
@change="fetchSendData"
@page-size-change="(s: number) => { sendPagination.pageSize = s; sendPagination.current = 1; fetchSendData() }"
/>
</div>
</a-card>
</a-tab-pane>
<!-- 验证码记录 -->
<a-tab-pane key="verify-codes" title="验证码记录">
<a-card :bordered="false" class="mb-4">
<a-space wrap>
<a-input v-model="codeFilter.email" placeholder="邮箱" allow-clear style="width: 220px" @keydown.enter="handleCodeFilter" />
<a-select v-model="codeFilter.purpose" placeholder="用途" allow-clear style="width: 140px" @change="handleCodeFilter">
<a-option value="register">注册</a-option>
<a-option value="reset_password">重置密码</a-option>
<a-option value="change_email">更换邮箱</a-option>
</a-select>
<a-select v-model="codeFilter.used" placeholder="是否已用" allow-clear style="width: 140px" @change="handleCodeFilter">
<a-option value="true">已使用</a-option>
<a-option value="false">未使用</a-option>
</a-select>
<a-button type="primary" @click="handleCodeFilter">搜索</a-button>
<a-button @click="handleCodeReset">重置</a-button>
</a-space>
</a-card>
<a-card :bordered="false">
<a-table
:columns="codeColumns"
:data="codeData"
:loading="codeLoading"
:scroll="{ x: 1000 }"
:bordered="false"
:stripe="true"
size="small"
:pagination="false"
row-key="id"
/>
<div class="table-footer">
<a-pagination
v-model:current="codePagination.current"
v-model:page-size="codePagination.pageSize"
:total="codePagination.total"
:page-size-options="codePagination.pageSizeOptions"
show-page-size
@change="fetchCodeData"
@page-size-change="(s: number) => { codePagination.pageSize = s; codePagination.current = 1; fetchCodeData() }"
/>
</div>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- 详情抽屉 -->
<a-drawer v-model:visible="showDetail" :width="560" title="邮件发送详情">
<a-spin :loading="detailLoading" class="w-full">
<template v-if="detailRecord">
<a-descriptions :column="1" bordered size="medium">
<a-descriptions-item label="收件人">{{ detailRecord.recipient }}</a-descriptions-item>
<a-descriptions-item label="主题">{{ detailRecord.subject }}</a-descriptions-item>
<a-descriptions-item label="模板">{{ detailRecord.template_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<Tag :color="statusColor[detailRecord.status] || 'gray'" size="small">{{ statusLabel[detailRecord.status] || detailRecord.status }}</Tag>
</a-descriptions-item>
<a-descriptions-item label="重试次数">{{ detailRecord.retry_count }}</a-descriptions-item>
<a-descriptions-item label="发送时间">{{ detailRecord.sent_at || '-' }}</a-descriptions-item>
<a-descriptions-item label="租户 / 用户">{{ detailRecord.tenant_id || '-' }} / {{ detailRecord.user_id || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="detailRecord.error_message" label="错误信息">
<span style="color: #f53f3f">{{ detailRecord.error_message }}</span>
</a-descriptions-item>
</a-descriptions>
<div v-if="detailRecord.body" class="mt-4">
<h4 style="margin-bottom: 8px; color: var(--ta-text-primary)">邮件正文</h4>
<pre class="email-body">{{ detailRecord.body }}</pre>
</div>
</template>
</a-spin>
</a-drawer>
</div>
</template>
<style scoped>
.table-footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border-light, #e5e6eb);
}
.email-body {
background: var(--color-fill-2, #f7f8fa);
padding: 12px;
border-radius: 6px;
font-size: 12px;
max-height: 400px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -27,15 +27,15 @@ const values = useFormValues()
<div class="section-grid">
<AFormItem label="审计日志保留天数" class="field-full">
<AInputNumber
:model-value="values['audit_retention_days'] as number"
@change="(v: number | undefined) => values['audit_retention_days'] = String(v ?? 90)"
:model-value="Number(values['audit_retention_days']) || undefined"
@change="(v: number | undefined) => values['audit_retention_days'] = v"
:min="7" :max="3650" style="width: 100%"
/>
</AFormItem>
<AFormItem label="操作日志保留天数" class="field-full">
<AInputNumber
:model-value="values['operation_log_retention_days'] as number"
@change="(v: number | undefined) => values['operation_log_retention_days'] = String(v ?? 365)"
:model-value="Number(values['operation_log_retention_days']) || undefined"
@change="(v: number | undefined) => values['operation_log_retention_days'] = v"
:min="30" :max="3650" style="width: 100%"
/>
</AFormItem>
@@ -14,9 +14,10 @@ const values = useFormValues()
</AFormItem>
<AFormItem label="SMTP 端口">
<AInputNumber
:model-value="values['email_smtp_port'] as number"
@change="(v: number | undefined) => values['email_smtp_port'] = String(v ?? 587)"
:model-value="Number(values['email_smtp_port']) || undefined"
@change="(v: number | undefined) => values['email_smtp_port'] = v"
:min="1" :max="65535"
placeholder="587"
style="width: 100%"
/>
</AFormItem>
@@ -31,8 +31,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
</AFormItem>
<AFormItem label="最低充值金额">
<AInputNumber
:model-value="values['payment_min_topup'] as number"
@change="(v: number | undefined) => values['payment_min_topup'] = String(v ?? 1)"
:model-value="Number(values['payment_min_topup']) || undefined"
@change="(v: number | undefined) => values['payment_min_topup'] = v"
:min="1" style="width: 100%"
/>
</AFormItem>
+2 -2
View File
@@ -169,12 +169,12 @@ const columns: TableColumnData[] = [
},
},
{
title: '钱包余额',
title: '钱包余额(美元)',
dataIndex: 'wallet_balance',
width: 120,
render({ record }) {
const val = parseFloat(record.wallet_balance || '0')
return `¥${val.toFixed(2)}`
return `\$${val.toFixed(2)}`
},
},
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
@@ -9,7 +9,7 @@ let timer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await fetchSettings()
timer = setInterval(() => fetchSettings(), 60_000)
timer = setInterval(() => fetchSettings(), 30 * 60 * 1000)
})
onBeforeUnmount(() => {
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
password?: string
}>()
// 强度档位:弱 / 中等 / 强 / 很强,颜色遵循租户端设计规范语义色
const levels = [
{ label: '弱', bar: 'bg-red-500', text: 'text-red-500' },
{ label: '中等', bar: 'bg-amber-500', text: 'text-amber-600' },
{ label: '强', bar: 'bg-primary-500', text: 'text-primary-600' },
{ label: '很强', bar: 'bg-emerald-500', text: 'text-emerald-600' },
] as const
// 评分规则:长度 + 字符多样性,映射到 1~4 档;空密码返回 0(不渲染)
const score = computed(() => {
const pw = props.password ?? ''
if (!pw) return 0
let s = 0
if (pw.length >= 8) s++
if (pw.length >= 12) s++
if (pw.length >= 16) s++
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++
if (/\d/.test(pw)) s++
if (/[^a-zA-Z0-9]/.test(pw)) s++
if (s <= 1) return 1
if (s <= 3) return 2
if (s <= 4) return 3
return 4
})
const current = computed(() => levels[score.value - 1])
</script>
<template>
<div v-if="score > 0" class="mt-1.5 flex items-center gap-2">
<div class="flex flex-1 gap-1">
<div
v-for="i in 4"
:key="i"
class="h-1 flex-1 rounded-full transition-colors duration-200"
:class="i <= score ? current.bar : 'bg-gray-200'"
/>
</div>
<span class="w-8 text-right text-xs font-medium" :class="current.text">
{{ current.label }}
</span>
</div>
</template>
@@ -14,6 +14,10 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: { captchaKey: string; captchaX: number }]
// Emits true only after a successful server verify; lets parents
// enable/disable submit buttons reactively. (The captcha key is set on
// image load, so the key alone cannot signal a completed slide.)
'verified': [value: boolean]
}>()
const { captchaData, loading, error, status, fetchCaptcha, verify, resetCaptcha } = useCaptcha()
@@ -26,6 +30,12 @@ function handleReset() {
}
defineExpose({ resetCaptcha: handleReset, activate })
// Notify parent of verification state changes so it can reactively enable or
// disable submit buttons. Also emits on mount/refresh when status resets.
watch(status, (val) => {
emit('verified', val === 'success')
})
const triggerRef = ref<HTMLDivElement | null>(null)
const popupRef = ref<HTMLDivElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
@@ -77,7 +77,7 @@ async function fetchAnnouncements() {
onMounted(() => {
fetchAnnouncements()
timer = setInterval(fetchAnnouncements, 60_000)
timer = setInterval(fetchAnnouncements, 30 * 60 * 1000)
})
onBeforeUnmount(() => {
+13 -2
View File
@@ -16,7 +16,7 @@ interface ApiError {
/** Request config extension — set _suppressErrorMsg to skip auto error toast */
declare module 'axios' {
interface InternalAxiosRequestConfig {
interface AxiosRequestConfig {
_suppressErrorMsg?: boolean
}
}
@@ -104,7 +104,17 @@ async function doRefresh(): Promise<TokenPair> {
return newTokens
}
const PUBLIC_PATHS = ['/settings/', '/captcha', '/captcha/', '/auth/login', '/auth/register', '/agreements/']
const PUBLIC_PATHS = [
'/settings/',
'/captcha',
'/captcha/',
'/auth/login',
'/auth/register',
'/agreements/',
// 找回密码流程无需登录态:发送验证码 / 重置密码
'/email/send-code',
'/email/reset-password',
]
function isPublicPath(url?: string): boolean {
if (!url) return false
@@ -184,6 +194,7 @@ request.interceptors.response.use(
}
const err = new Error(msg)
;(err as any).apiError = error.response.data as ApiError
;(err as any).isBusinessError = true
;(err as any).isDemoModeError = error.response.data.code === DEMO_MODE_CODE
return Promise.reject(err)
}
+2 -2
View File
@@ -194,11 +194,11 @@ onMounted(async () => {
authStore.loadFromStorage()
document.addEventListener('click', handleClickOutside)
fetchAnnouncements()
announcementTimer = setInterval(fetchAnnouncements, 10 * 60 * 1000)
announcementTimer = setInterval(fetchAnnouncements, 30 * 60 * 1000)
startNotificationPolling()
if (canViewWallet.value) {
fetchWalletBalance()
walletTimer = setInterval(fetchWalletBalance, 60000)
walletTimer = setInterval(fetchWalletBalance, 5 * 60 * 1000)
} else {
fetchMemberQuota()
}
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import Icon from '@/components/common/Icon.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
import request, { extractApiError } from '@/utils/request'
const router = useRouter()
@@ -23,8 +24,19 @@ const form = reactive({
const errors = reactive<Record<string, string>>({})
// Captcha state
const captcha = reactive({ captchaKey: '', captchaX: 0 })
// Captcha state. Must be a ref: <SlideCaptcha v-model="captcha"> compiles to
// `captcha = $event`, which would reassign a const reactive and silently drop
// the value. With a ref Vue writes to captcha.value instead.
const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 })
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
// Reactive verification flag (driven by SlideCaptcha's `verified` emit) so the
// send button can be disabled until the slide is completed.
const captchaVerified = ref(false)
// 10057 = 请完成滑块验证 (not completed), 10058 = 滑块验证失败 (failed/expired)
function isCaptchaError(apiErr: any): boolean {
return apiErr?.code === 10057 || apiErr?.code === 10058
}
function clearErrors() {
Object.keys(errors).forEach((k) => delete errors[k])
@@ -41,17 +53,18 @@ async function sendCode() {
await request.post('/tenant/email/send-code', {
email: form.email,
purpose: 'reset_password',
captcha_key: captcha.captchaKey,
captcha_x: captcha.captchaX,
captcha_key: captcha.value.captchaKey,
captcha_x: captcha.value.captchaX,
})
startCountdown()
} catch (err: any) {
const apiErr = extractApiError(err)
if (apiErr?.code === 10058) {
errors.email = '滑块验证失败,请重新拖动'
if (isCaptchaError(apiErr)) {
// 滑块验证已过期:引导用户返回第一步重新验证
errors.code = apiErr?.message || '滑块验证已过期,请返回重新验证'
return
}
errors.email = apiErr?.message || '发送验证码失败'
errors.code = apiErr?.message || '发送验证码失败'
} finally {
codeSending.value = false
}
@@ -99,24 +112,31 @@ function validateStep2(): boolean {
async function goToStep2() {
if (!validateStep1()) return
// 必须先完成滑块验证才允许发送验证码(captchaKey 在图片加载后即非空,
// 无法据此判断是否真的拖动过,改用 captchaVerified 判断服务端验证通过状态)
if (!captchaVerified.value) {
errors.captcha = '请先完成滑块验证'
return
}
codeSending.value = true
try {
await request.post('/tenant/email/send-code', {
email: form.email,
purpose: 'reset_password',
captcha_key: captcha.captchaKey,
captcha_x: captcha.captchaX,
captcha_key: captcha.value.captchaKey,
captcha_x: captcha.value.captchaX,
})
step.value = 2
startCountdown()
} catch (err: any) {
const apiErr = extractApiError(err)
if (apiErr?.code === 10058) {
errors.email = '滑块验证失败,请重新拖动'
if (isCaptchaError(apiErr)) {
errors.captcha = apiErr?.message || '请重新完成滑块验证'
captchaRef.value?.resetCaptcha()
return
}
errors.email = apiErr?.message || '发送验证码失败'
errors.captcha = apiErr?.message || '发送验证码失败'
} finally {
codeSending.value = false
}
@@ -131,14 +151,14 @@ async function handleReset() {
email: form.email,
code: form.code,
password: form.newPassword,
captcha_key: captcha.captchaKey,
captcha_x: captcha.captchaX,
captcha_key: captcha.value.captchaKey,
captcha_x: captcha.value.captchaX,
})
router.push('/tenant/login')
} catch (err: any) {
const apiErr = extractApiError(err)
if (apiErr?.code === 10058) {
errors.code = '滑块验证失败,请重新拖动'
if (isCaptchaError(apiErr)) {
errors.code = apiErr?.message || '滑块验证已过期,请返回重新验证'
return
}
errors.code = apiErr?.message || '重置密码失败'
@@ -198,15 +218,18 @@ function goBack() {
<p v-if="errors.email" class="input-error-text">{{ errors.email }}</p>
</div>
<SlideCaptcha v-model="captcha" class="mb-4" />
<button
type="submit"
:disabled="codeSending"
class="btn btn-primary btn-lg w-full"
>
<div v-if="codeSending" class="spinner h-4 w-4 border-white"></div>
{{ codeSending ? '发送中...' : '发送重置验证码' }}
</button>
<SlideCaptcha ref="captchaRef" v-model="captcha" @verified="captchaVerified = $event" class="mb-4" />
<div>
<button
type="submit"
:disabled="codeSending || !captchaVerified"
class="btn btn-primary btn-lg w-full"
>
<div v-if="codeSending" class="spinner h-4 w-4 border-white"></div>
{{ codeSending ? '发送中...' : '发送重置验证码' }}
</button>
<p v-if="errors.captcha" class="input-error-text mt-2 text-center">{{ errors.captcha }}</p>
</div>
</form>
<!-- Step 2: Code + New Password -->
@@ -243,6 +266,7 @@ function goBack() {
class="input"
:class="{ 'input-error': errors.newPassword }"
/>
<PasswordStrengthMeter :password="form.newPassword" />
<p v-if="errors.newPassword" class="input-error-text">{{ errors.newPassword }}</p>
</div>
-18
View File
@@ -65,22 +65,6 @@
</div>
</div>
<div>
<label class="input-label">邮箱 <span class="text-red-500">*</span></label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400">
<Icon name="mail" size="sm" />
</div>
<input
v-model="form.email"
type="email"
required
placeholder="请输入邮箱"
class="input pl-11"
/>
</div>
</div>
<div>
<label class="input-label">密码 <span class="text-red-500">*</span></label>
<div class="relative">
@@ -192,7 +176,6 @@ const joinResult = ref({
const form = ref({
username: '',
display_name: '',
email: '',
password: '',
})
@@ -252,7 +235,6 @@ async function handleJoin() {
code: route.query.code as string,
username: form.value.username,
display_name: form.value.display_name || undefined,
email: form.value.email,
password: form.value.password,
}),
})
@@ -8,6 +8,7 @@ import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue'
import Icon from '@/components/common/Icon.vue'
import AgreementViewModal from '@/components/common/AgreementViewModal.vue'
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
import request from '@/utils/request'
import { extractApiError } from '@/utils/request'
@@ -403,6 +404,7 @@ async function handleRegister() {
class="input"
:class="{ 'input-error': userErrors.password }"
/>
<PasswordStrengthMeter :password="userForm.password" />
<p v-if="userErrors.password" class="input-error-text">{{ userErrors.password }}</p>
</div>
@@ -479,6 +479,14 @@ onBeforeUnmount(() => {
充值 ¥{{ rechargeAmount || '—' }}
</template>
</button>
<!-- Conversion note -->
<div class="recharge-note">
<div class="recharge-note-icon">
<Icon name="infoCircle" size="xs" />
</div>
<p>充值金额为人民币(¥),支付成功后将按平台汇率换算为美元($)入账到您的钱包余额。</p>
</div>
</div>
</div>
</div>
@@ -872,6 +880,24 @@ onBeforeUnmount(() => {
.pay-method-icon-wxpay { background: linear-gradient(135deg, #07c160, #06ad56); }
.pay-method-icon-default { background: linear-gradient(135deg, #6b7280, #4b5563); }
/* ==========================================
Recharge Conversion Note
========================================== */
.recharge-note {
display: flex; align-items: flex-start; gap: 0.5rem;
padding: 0.75rem 0.875rem;
border-radius: 0.75rem;
background-color: #f0fdfa;
border: 1px solid #ccfbf1;
}
.recharge-note-icon {
flex-shrink: 0; margin-top: 0.0625rem;
color: #14b8a6;
}
.recharge-note p {
font-size: 0.75rem; line-height: 1.5; color: #0f766e;
}
/* ==========================================
Transitions
========================================== */
+15 -1
View File
@@ -1,8 +1,15 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
const router = useRouter()
function goToPlayground() {
router.push({ name: 'TenantPlayground' })
}
interface PricingTierItem {
min_tokens: number
max_tokens: number | null
@@ -193,7 +200,14 @@ onMounted(fetchModels)
<div>
<div class="page-header">
<h1 class="page-title">可用模型</h1>
<p class="page-description">查看当前组织可使用的 AI 模型及其配置信息</p>
<p class="page-description">
查看当前组织可使用的 AI 模型及其配置信息。如果需要体验模型,请前往
<a
class="font-medium text-primary-600 hover:text-primary-700 underline underline-offset-2 transition-colors cursor-pointer"
@click="goToPlayground"
>在线体验</a>
功能
</p>
</div>
<!-- Filters -->
+149 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import request from '@/utils/request'
@@ -13,8 +13,6 @@ const { settings } = usePublicSettings()
// OAuth state
const oauthProviders = ref<any[]>([])
const oauthLoading = ref(false)
const linkProvider = ref('')
const linkCode = ref('')
const linkLoading = ref(false)
const linkError = ref('')
@@ -33,6 +31,18 @@ const passwordSaving = ref(false)
const passwordSuccess = ref(false)
const showPasswordModal = ref(false)
// Email (set / change) modal state
const showEmailModal = ref(false)
const emailForm = reactive({
new_email: '',
code: '',
})
const emailSending = ref(false)
const emailSaving = ref(false)
const emailCooldown = ref(0)
const emailError = ref('')
let emailCooldownTimer: ReturnType<typeof setInterval> | null = null
const roleLabel: Record<string, string> = {
owner: '所有者',
admin: '管理员',
@@ -129,6 +139,75 @@ async function handleChangePassword() {
}
}
function openEmailModal() {
emailForm.new_email = userInfo.value?.email || ''
emailForm.code = ''
emailError.value = ''
emailCooldown.value = 0
if (emailCooldownTimer) {
clearInterval(emailCooldownTimer)
emailCooldownTimer = null
}
showEmailModal.value = true
}
async function sendEmailCode() {
emailError.value = ''
if (!emailForm.new_email.trim()) {
emailError.value = '请输入邮箱'
return
}
emailSending.value = true
try {
await request.post('/tenant/email/send-change-email-code', {
new_email: emailForm.new_email,
}, { _suppressErrorMsg: true })
emailCooldown.value = 60
emailCooldownTimer = setInterval(() => {
emailCooldown.value--
if (emailCooldown.value <= 0 && emailCooldownTimer) {
clearInterval(emailCooldownTimer)
emailCooldownTimer = null
}
}, 1000)
} catch (e: any) {
emailError.value = e?.message || '验证码发送失败'
} finally {
emailSending.value = false
}
}
async function handleChangeEmail() {
emailError.value = ''
if (!emailForm.new_email.trim()) {
emailError.value = '请输入邮箱'
return
}
if (!emailForm.code.trim()) {
emailError.value = '请输入验证码'
return
}
emailSaving.value = true
try {
await request.post('/tenant/email/change-email', {
new_email: emailForm.new_email,
code: emailForm.code,
}, { _suppressErrorMsg: true })
if (userInfo.value) {
userInfo.value.email = emailForm.new_email
}
showEmailModal.value = false
if (emailCooldownTimer) {
clearInterval(emailCooldownTimer)
emailCooldownTimer = null
}
} catch (e: any) {
emailError.value = e?.message || '设置失败'
} finally {
emailSaving.value = false
}
}
async function revokeSession(sessionId: number) {
try {
await request.delete(`/tenant/auth/sessions/${sessionId}`)
@@ -152,6 +231,13 @@ onMounted(() => {
fetchOAuthProviders()
})
onUnmounted(() => {
if (emailCooldownTimer) {
clearInterval(emailCooldownTimer)
emailCooldownTimer = null
}
})
async function fetchOAuthProviders() {
oauthLoading.value = true
try {
@@ -181,7 +267,7 @@ async function handleOAuthLink(provider: string) {
linkError.value = ''
try {
const res: any = await request.get('/tenant/oauth/authorize', { params: { provider } })
const { authorize_url, state } = res.data?.data || {}
const { authorize_url } = res.data?.data || {}
if (authorize_url) {
// Open OAuth in popup window
const width = 600
@@ -324,7 +410,16 @@ async function handleOAuthUnlink(provider: string) {
<!-- Email -->
<div>
<p class="input-label">邮箱</p>
<span class="text-gray-900 font-medium">{{ userInfo.email || '--' }}</span>
<div class="flex items-center gap-2">
<span class="text-gray-900 font-medium">{{ userInfo.email || '未设置' }}</span>
<button
@click="openEmailModal"
class="text-primary-600 hover:text-primary-500 transition-colors"
title="设置/修改邮箱"
>
<Icon name="edit" size="sm" />
</button>
</div>
</div>
<!-- Role -->
@@ -485,5 +580,54 @@ async function handleOAuthUnlink(provider: string) {
</button>
</template>
</BaseModal>
<!-- Email Modal -->
<BaseModal :show="showEmailModal" :title="userInfo?.email ? '修改邮箱' : '设置邮箱'" width="narrow" @close="showEmailModal = false">
<form @submit.prevent="handleChangeEmail" class="space-y-4">
<div>
<label class="input-label">新邮箱</label>
<div class="flex gap-2">
<input
v-model="emailForm.new_email"
type="email"
placeholder="请输入邮箱"
class="input flex-1"
/>
<button
type="button"
class="btn btn-secondary whitespace-nowrap"
:disabled="emailSending || emailCooldown > 0"
@click="sendEmailCode"
>
{{ emailCooldown > 0 ? `${emailCooldown}s` : (emailSending ? '发送中...' : '发送验证码') }}
</button>
</div>
</div>
<div>
<label class="input-label">验证码</label>
<input
v-model="emailForm.code"
type="text"
maxlength="6"
placeholder="请输入6位验证码"
class="input"
/>
</div>
<p v-if="emailError" class="input-error-text">{{ emailError }}</p>
</form>
<template #footer>
<button class="btn btn-secondary" @click="showEmailModal = false">取消</button>
<button
class="btn btn-primary"
:disabled="emailSaving"
@click="handleChangeEmail"
>
{{ emailSaving ? '设置中...' : '确认' }}
</button>
</template>
</BaseModal>
</div>
</template>