feat: portal auth login and password reset
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
"quyun/v2/app/http/web/dto"
|
"quyun/v2/app/http/web/dto"
|
||||||
@@ -13,6 +18,7 @@ import (
|
|||||||
"quyun/v2/providers/jwt"
|
"quyun/v2/providers/jwt"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +27,33 @@ type auth struct {
|
|||||||
jwt *jwt.JWT
|
jwt *jwt.JWT
|
||||||
}
|
}
|
||||||
|
|
||||||
var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
|
var (
|
||||||
|
reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
|
||||||
|
rePhone = regexp.MustCompile(`^[0-9]{6,20}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type passwordResetState struct {
|
||||||
|
code string
|
||||||
|
codeExpire time.Time
|
||||||
|
lastSentAt time.Time
|
||||||
|
resetToken string
|
||||||
|
tokenExpire time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordResetStore = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
phoneToItem map[string]*passwordResetState
|
||||||
|
tokenToPhone map[string]string
|
||||||
|
}{
|
||||||
|
phoneToItem: make(map[string]*passwordResetState),
|
||||||
|
tokenToPhone: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
passwordResetCodeTTL = 5 * time.Minute
|
||||||
|
passwordResetTokenTTL = 10 * time.Minute
|
||||||
|
passwordResetSendGap = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// Login 用户登录(平台侧,非超级管理员)。
|
// Login 用户登录(平台侧,非超级管理员)。
|
||||||
//
|
//
|
||||||
@@ -52,6 +84,165 @@ func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse,
|
|||||||
return &dto.LoginResponse{Token: token}, nil
|
return &dto.LoginResponse{Token: token}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PasswordResetSendSMS 找回密码:发送短信验证码(预留:当前返回验证码用于前端弹窗展示)。
|
||||||
|
//
|
||||||
|
// @Summary 找回密码-发送短信验证码
|
||||||
|
// @Tags Web
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param form body dto.PasswordResetSendSMSForm true "form"
|
||||||
|
// @Success 200 {object} dto.PasswordResetSendSMSResponse "成功"
|
||||||
|
// @Router /v1/auth/password/reset/sms [post]
|
||||||
|
// @Bind form body
|
||||||
|
func (ctl *auth) passwordResetSendSMS(ctx fiber.Ctx, form *dto.PasswordResetSendSMSForm) (*dto.PasswordResetSendSMSResponse, error) {
|
||||||
|
phone := strings.TrimSpace(form.Phone)
|
||||||
|
if phone == "" {
|
||||||
|
return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号")
|
||||||
|
}
|
||||||
|
if !rePhone.MatchString(phone) {
|
||||||
|
return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordResetStore.mu.Lock()
|
||||||
|
defer passwordResetStore.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
item := passwordResetStore.phoneToItem[phone]
|
||||||
|
if item == nil {
|
||||||
|
item = &passwordResetState{}
|
||||||
|
passwordResetStore.phoneToItem[phone] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
if !item.lastSentAt.IsZero() {
|
||||||
|
elapsed := now.Sub(item.lastSentAt)
|
||||||
|
if elapsed < passwordResetSendGap {
|
||||||
|
remain := int((passwordResetSendGap - elapsed).Seconds())
|
||||||
|
if remain < 1 {
|
||||||
|
remain = 1
|
||||||
|
}
|
||||||
|
return nil, errorx.ErrTooManyRequests.WithMsgf("操作太频繁,请 %d 秒后再试", remain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errorx.Wrap(err).WithMsg("验证码生成失败,请稍后再试")
|
||||||
|
}
|
||||||
|
code := fmt.Sprintf("%06d", n.Int64())
|
||||||
|
|
||||||
|
item.code = code
|
||||||
|
item.codeExpire = now.Add(passwordResetCodeTTL)
|
||||||
|
item.lastSentAt = now
|
||||||
|
item.resetToken = ""
|
||||||
|
item.tokenExpire = time.Time{}
|
||||||
|
|
||||||
|
return &dto.PasswordResetSendSMSResponse{
|
||||||
|
NextSendSeconds: int(passwordResetSendGap.Seconds()),
|
||||||
|
Code: code,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetVerify 找回密码:校验短信验证码。
|
||||||
|
//
|
||||||
|
// @Summary 找回密码-校验验证码
|
||||||
|
// @Tags Web
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param form body dto.PasswordResetVerifyForm true "form"
|
||||||
|
// @Success 200 {object} dto.PasswordResetVerifyResponse "成功"
|
||||||
|
// @Router /v1/auth/password/reset/verify [post]
|
||||||
|
// @Bind form body
|
||||||
|
func (ctl *auth) passwordResetVerify(ctx fiber.Ctx, form *dto.PasswordResetVerifyForm) (*dto.PasswordResetVerifyResponse, error) {
|
||||||
|
phone := strings.TrimSpace(form.Phone)
|
||||||
|
if phone == "" {
|
||||||
|
return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号")
|
||||||
|
}
|
||||||
|
if !rePhone.MatchString(phone) {
|
||||||
|
return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确")
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(form.Code)
|
||||||
|
if code == "" {
|
||||||
|
return nil, errorx.ErrMissingParameter.WithMsg("请输入验证码")
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordResetStore.mu.Lock()
|
||||||
|
defer passwordResetStore.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
item := passwordResetStore.phoneToItem[phone]
|
||||||
|
if item == nil || item.code == "" || item.codeExpire.IsZero() || now.After(item.codeExpire) {
|
||||||
|
return nil, errorx.ErrPreconditionFailed.WithMsg("验证码已过期,请重新获取")
|
||||||
|
}
|
||||||
|
if item.code != code {
|
||||||
|
return nil, errorx.ErrInvalidParameter.WithMsg("验证码错误,请重新输入")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一次性重置令牌,并清理验证码,避免复用。
|
||||||
|
resetToken := uuid.NewString()
|
||||||
|
item.resetToken = resetToken
|
||||||
|
item.tokenExpire = now.Add(passwordResetTokenTTL)
|
||||||
|
item.code = ""
|
||||||
|
item.codeExpire = time.Time{}
|
||||||
|
|
||||||
|
passwordResetStore.tokenToPhone[resetToken] = phone
|
||||||
|
|
||||||
|
return &dto.PasswordResetVerifyResponse{ResetToken: resetToken}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordReset 找回密码:重置密码。
|
||||||
|
//
|
||||||
|
// @Summary 找回密码-重置密码
|
||||||
|
// @Tags Web
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param form body dto.PasswordResetForm true "form"
|
||||||
|
// @Success 200 {object} dto.PasswordResetResponse "成功"
|
||||||
|
// @Router /v1/auth/password/reset [post]
|
||||||
|
// @Bind form body
|
||||||
|
func (ctl *auth) passwordReset(ctx fiber.Ctx, form *dto.PasswordResetForm) (*dto.PasswordResetResponse, error) {
|
||||||
|
resetToken := strings.TrimSpace(form.ResetToken)
|
||||||
|
if resetToken == "" {
|
||||||
|
return nil, errorx.ErrMissingParameter.WithMsg("请先完成验证码校验")
|
||||||
|
}
|
||||||
|
if form.Password == "" || form.ConfirmPassword == "" {
|
||||||
|
return nil, errorx.ErrMissingParameter.WithMsg("请输入新密码并确认")
|
||||||
|
}
|
||||||
|
if len(form.Password) < 8 {
|
||||||
|
return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码")
|
||||||
|
}
|
||||||
|
if form.Password != form.ConfirmPassword {
|
||||||
|
return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认")
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordResetStore.mu.Lock()
|
||||||
|
phone, ok := passwordResetStore.tokenToPhone[resetToken]
|
||||||
|
item := passwordResetStore.phoneToItem[phone]
|
||||||
|
now := time.Now()
|
||||||
|
if !ok || phone == "" || item == nil || item.resetToken != resetToken || item.tokenExpire.IsZero() || now.After(item.tokenExpire) {
|
||||||
|
passwordResetStore.mu.Unlock()
|
||||||
|
return nil, errorx.ErrTokenInvalid.WithMsg("重置会话已失效,请重新获取验证码")
|
||||||
|
}
|
||||||
|
// 令牌一次性使用
|
||||||
|
delete(passwordResetStore.tokenToPhone, resetToken)
|
||||||
|
item.resetToken = ""
|
||||||
|
item.tokenExpire = time.Time{}
|
||||||
|
passwordResetStore.mu.Unlock()
|
||||||
|
|
||||||
|
// 当前版本将手机号视为用户名。
|
||||||
|
if _, err := services.User.FindByUsername(ctx, phone); err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errorx.ErrRecordNotFound.WithMsg("该手机号尚未注册")
|
||||||
|
}
|
||||||
|
return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.User.ResetPasswordByUsername(ctx, phone, form.Password); err != nil {
|
||||||
|
return nil, errorx.Wrap(err).WithMsg("重置密码失败,请稍后再试")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.PasswordResetResponse{Ok: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Register 用户注册(平台侧,非超级管理员)。
|
// Register 用户注册(平台侧,非超级管理员)。
|
||||||
//
|
//
|
||||||
// @Summary 用户注册
|
// @Summary 用户注册
|
||||||
|
|||||||
45
backend/app/http/web/dto/password_reset.go
Normal file
45
backend/app/http/web/dto/password_reset.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// PasswordResetSendSMSForm 找回密码:发送短信验证码表单。
|
||||||
|
type PasswordResetSendSMSForm struct {
|
||||||
|
// Phone 手机号;当前版本将其作为用户名使用(users.username)。
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetSendSMSResponse 找回密码:发送短信验证码响应。
|
||||||
|
type PasswordResetSendSMSResponse struct {
|
||||||
|
// NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。
|
||||||
|
NextSendSeconds int `json:"nextSendSeconds"`
|
||||||
|
// Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetVerifyForm 找回密码:验证码校验表单。
|
||||||
|
type PasswordResetVerifyForm struct {
|
||||||
|
// Phone 手机号。
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
// Code 短信验证码。
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetVerifyResponse 找回密码:验证码校验响应。
|
||||||
|
type PasswordResetVerifyResponse struct {
|
||||||
|
// ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。
|
||||||
|
ResetToken string `json:"resetToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetForm 找回密码:重置密码表单。
|
||||||
|
type PasswordResetForm struct {
|
||||||
|
// ResetToken 重置令牌;由验证码校验接口返回。
|
||||||
|
ResetToken string `json:"resetToken,omitempty"`
|
||||||
|
// Password 新密码(明文)。
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
// ConfirmPassword 确认新密码;必须与 Password 一致。
|
||||||
|
ConfirmPassword string `json:"confirmPassword,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetResponse 找回密码:重置密码响应。
|
||||||
|
type PasswordResetResponse struct {
|
||||||
|
// Ok 是否成功。
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
}
|
||||||
@@ -52,6 +52,21 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
r.auth.login,
|
r.auth.login,
|
||||||
Body[dto.LoginForm]("form"),
|
Body[dto.LoginForm]("form"),
|
||||||
))
|
))
|
||||||
|
r.log.Debugf("Registering route: Post /v1/auth/password/reset -> auth.passwordReset")
|
||||||
|
router.Post("/v1/auth/password/reset"[len(r.Path()):], DataFunc1(
|
||||||
|
r.auth.passwordReset,
|
||||||
|
Body[dto.PasswordResetForm]("form"),
|
||||||
|
))
|
||||||
|
r.log.Debugf("Registering route: Post /v1/auth/password/reset/sms -> auth.passwordResetSendSMS")
|
||||||
|
router.Post("/v1/auth/password/reset/sms"[len(r.Path()):], DataFunc1(
|
||||||
|
r.auth.passwordResetSendSMS,
|
||||||
|
Body[dto.PasswordResetSendSMSForm]("form"),
|
||||||
|
))
|
||||||
|
r.log.Debugf("Registering route: Post /v1/auth/password/reset/verify -> auth.passwordResetVerify")
|
||||||
|
router.Post("/v1/auth/password/reset/verify"[len(r.Path()):], DataFunc1(
|
||||||
|
r.auth.passwordResetVerify,
|
||||||
|
Body[dto.PasswordResetVerifyForm]("form"),
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Post /v1/auth/register -> auth.register")
|
r.log.Debugf("Registering route: Post /v1/auth/register -> auth.register")
|
||||||
router.Post("/v1/auth/register"[len(r.Path()):], DataFunc1(
|
router.Post("/v1/auth/register"[len(r.Path()):], DataFunc1(
|
||||||
r.auth.register,
|
r.auth.register,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func shouldSkipUserJWTAuth(path string, method string) bool {
|
func shouldSkipUserJWTAuth(path, method string) bool {
|
||||||
// 仅对明确的公开接口放行,避免误伤其它路径。
|
// 仅对明确的公开接口放行,避免误伤其它路径。
|
||||||
if method != fiber.MethodPost {
|
if method != fiber.MethodPost {
|
||||||
return false
|
return false
|
||||||
@@ -18,7 +18,7 @@ func shouldSkipUserJWTAuth(path string, method string) bool {
|
|||||||
|
|
||||||
p := strings.TrimSuffix(path, "/")
|
p := strings.TrimSuffix(path, "/")
|
||||||
switch p {
|
switch p {
|
||||||
case "/v1/auth/login", "/v1/auth/register":
|
case "/v1/auth/login", "/v1/auth/register", "/v1/auth/password/reset/sms", "/v1/auth/password/reset/verify", "/v1/auth/password/reset":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"go.ipao.vip/gen"
|
"go.ipao.vip/gen"
|
||||||
"go.ipao.vip/gen/field"
|
"go.ipao.vip/gen/field"
|
||||||
"go.ipao.vip/gen/types"
|
"go.ipao.vip/gen/types"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @provider
|
// @provider
|
||||||
@@ -84,6 +85,31 @@ func (t *user) Create(ctx context.Context, user *models.User) (*models.User, err
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetPasswordByUsername 通过用户名(手机号)重置密码。
|
||||||
|
func (t *user) ResetPasswordByUsername(ctx context.Context, username, newPassword string) error {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username is required")
|
||||||
|
}
|
||||||
|
if newPassword == "" {
|
||||||
|
return errors.New("new_password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := t.FindByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// bcrypt hash,避免直接落明文。
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "generate password hash failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Password = string(bytes)
|
||||||
|
return m.Save(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// SetStatus 设置用户状态(超级管理员侧)。
|
// SetStatus 设置用户状态(超级管理员侧)。
|
||||||
func (t *user) SetStatus(ctx context.Context, userID int64, status consts.UserStatus) error {
|
func (t *user) SetStatus(ctx context.Context, userID int64, status consts.UserStatus) error {
|
||||||
m, err := t.FindByID(ctx, userID)
|
m, err := t.FindByID(ctx, userID)
|
||||||
|
|||||||
@@ -3885,6 +3885,105 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/auth/password/reset": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Web"
|
||||||
|
],
|
||||||
|
"summary": "找回密码-重置密码",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "form",
|
||||||
|
"name": "form",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetForm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/auth/password/reset/sms": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Web"
|
||||||
|
],
|
||||||
|
"summary": "找回密码-发送短信验证码",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "form",
|
||||||
|
"name": "form",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetSendSMSForm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetSendSMSResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/auth/password/reset/verify": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Web"
|
||||||
|
],
|
||||||
|
"summary": "找回密码-校验验证码",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "form",
|
||||||
|
"name": "form",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetVerifyForm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetVerifyResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/auth/register": {
|
"/v1/auth/register": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -4897,6 +4996,76 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dto.PasswordResetForm": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"confirmPassword": {
|
||||||
|
"description": "ConfirmPassword 确认新密码;必须与 Password 一致。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "Password 新密码(明文)。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"resetToken": {
|
||||||
|
"description": "ResetToken 重置令牌;由验证码校验接口返回。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"description": "Ok 是否成功。",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetSendSMSForm": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"description": "Phone 手机号;当前版本将其作为用户名使用(users.username)。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetSendSMSResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"description": "Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nextSendSeconds": {
|
||||||
|
"description": "NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetVerifyForm": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"description": "Code 短信验证码。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"description": "Phone 手机号。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetVerifyResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resetToken": {
|
||||||
|
"description": "ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dto.PurchaseContentForm": {
|
"dto.PurchaseContentForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -3879,6 +3879,105 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/auth/password/reset": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Web"
|
||||||
|
],
|
||||||
|
"summary": "找回密码-重置密码",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "form",
|
||||||
|
"name": "form",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetForm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/auth/password/reset/sms": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Web"
|
||||||
|
],
|
||||||
|
"summary": "找回密码-发送短信验证码",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "form",
|
||||||
|
"name": "form",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetSendSMSForm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetSendSMSResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/auth/password/reset/verify": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Web"
|
||||||
|
],
|
||||||
|
"summary": "找回密码-校验验证码",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "form",
|
||||||
|
"name": "form",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetVerifyForm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.PasswordResetVerifyResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/auth/register": {
|
"/v1/auth/register": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -4891,6 +4990,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dto.PasswordResetForm": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"confirmPassword": {
|
||||||
|
"description": "ConfirmPassword 确认新密码;必须与 Password 一致。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "Password 新密码(明文)。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"resetToken": {
|
||||||
|
"description": "ResetToken 重置令牌;由验证码校验接口返回。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"description": "Ok 是否成功。",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetSendSMSForm": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"description": "Phone 手机号;当前版本将其作为用户名使用(users.username)。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetSendSMSResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"description": "Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nextSendSeconds": {
|
||||||
|
"description": "NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetVerifyForm": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"description": "Code 短信验证码。",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"description": "Phone 手机号。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dto.PasswordResetVerifyResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resetToken": {
|
||||||
|
"description": "ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dto.PurchaseContentForm": {
|
"dto.PurchaseContentForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -657,6 +657,54 @@ definitions:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
dto.PasswordResetForm:
|
||||||
|
properties:
|
||||||
|
confirmPassword:
|
||||||
|
description: ConfirmPassword 确认新密码;必须与 Password 一致。
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
description: Password 新密码(明文)。
|
||||||
|
type: string
|
||||||
|
resetToken:
|
||||||
|
description: ResetToken 重置令牌;由验证码校验接口返回。
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
dto.PasswordResetResponse:
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
description: Ok 是否成功。
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
dto.PasswordResetSendSMSForm:
|
||||||
|
properties:
|
||||||
|
phone:
|
||||||
|
description: Phone 手机号;当前版本将其作为用户名使用(users.username)。
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
dto.PasswordResetSendSMSResponse:
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
description: Code 验证码(预留:当前用于前端弹窗展示;正式接入短信后应移除/仅在开发环境返回)。
|
||||||
|
type: string
|
||||||
|
nextSendSeconds:
|
||||||
|
description: NextSendSeconds 下次可发送剩余秒数(用于前端 60s 倒计时)。
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
dto.PasswordResetVerifyForm:
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
description: Code 短信验证码。
|
||||||
|
type: string
|
||||||
|
phone:
|
||||||
|
description: Phone 手机号。
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
dto.PasswordResetVerifyResponse:
|
||||||
|
properties:
|
||||||
|
resetToken:
|
||||||
|
description: ResetToken 重置令牌;验证码校验通过后,用该令牌提交新密码。
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
dto.PurchaseContentForm:
|
dto.PurchaseContentForm:
|
||||||
properties:
|
properties:
|
||||||
idempotency_key:
|
idempotency_key:
|
||||||
@@ -4223,6 +4271,69 @@ paths:
|
|||||||
summary: 用户登录
|
summary: 用户登录
|
||||||
tags:
|
tags:
|
||||||
- Web
|
- Web
|
||||||
|
/v1/auth/password/reset:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: form
|
||||||
|
in: body
|
||||||
|
name: form
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.PasswordResetForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.PasswordResetResponse'
|
||||||
|
summary: 找回密码-重置密码
|
||||||
|
tags:
|
||||||
|
- Web
|
||||||
|
/v1/auth/password/reset/sms:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: form
|
||||||
|
in: body
|
||||||
|
name: form
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.PasswordResetSendSMSForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.PasswordResetSendSMSResponse'
|
||||||
|
summary: 找回密码-发送短信验证码
|
||||||
|
tags:
|
||||||
|
- Web
|
||||||
|
/v1/auth/password/reset/verify:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: form
|
||||||
|
in: body
|
||||||
|
name: form
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.PasswordResetVerifyForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.PasswordResetVerifyResponse'
|
||||||
|
summary: 找回密码-校验验证码
|
||||||
|
tags:
|
||||||
|
- Web
|
||||||
/v1/auth/register:
|
/v1/auth/register:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<Toast />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -67,5 +67,4 @@ function isOutsideClicked(event) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="layout-mask animate-fadein"></div>
|
<div class="layout-mask animate-fadein"></div>
|
||||||
</div>
|
</div>
|
||||||
<Toast />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
{ path: '/auth/login', name: 'login', component: () => import('@/views/pages/auth/Login.vue'), meta: { title: '登录' } },
|
{ path: '/auth/login', name: 'login', component: () => import('@/views/pages/auth/Login.vue'), meta: { title: '登录' } },
|
||||||
{ path: '/auth/register', name: 'register', component: () => import('@/views/pages/auth/Register.vue'), meta: { title: '注册' } },
|
{ path: '/auth/register', name: 'register', component: () => import('@/views/pages/auth/Register.vue'), meta: { title: '注册' } },
|
||||||
{ path: '/auth/forgot-password', name: 'forgotPassword', component: TitlePage, meta: { title: '忘记密码' } },
|
{ path: '/auth/forgot-password', name: 'forgotPassword', component: () => import('@/views/pages/auth/ForgotPassword.vue'), meta: { title: '忘记密码' } },
|
||||||
{ path: '/auth/verify', name: 'verify', component: TitlePage, meta: { title: '验证(邮箱/手机)' } },
|
{ path: '/auth/verify', name: 'verify', component: TitlePage, meta: { title: '验证(邮箱/手机)' } },
|
||||||
|
|
||||||
{ path: '/404', name: 'notFound', component: NotFoundPage, meta: { title: '404' } },
|
{ path: '/404', name: 'notFound', component: NotFoundPage, meta: { title: '404' } },
|
||||||
|
|||||||
@@ -20,3 +20,24 @@ export async function register({ username, password, confirmPassword, verifyCode
|
|||||||
if (token) await setTokenAndLoadMe(token);
|
if (token) await setTokenAndLoadMe(token);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendPasswordResetSms({ phone }) {
|
||||||
|
return await requestJson('/v1/auth/password/reset/sms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { phone }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasswordResetSms({ phone, code }) {
|
||||||
|
return await requestJson('/v1/auth/password/reset/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { phone, code }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword({ resetToken, password, confirmPassword }) {
|
||||||
|
return await requestJson('/v1/auth/password/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { resetToken, password, confirmPassword }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
237
frontend/portal/src/views/pages/auth/ForgotPassword.vue
Normal file
237
frontend/portal/src/views/pages/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup>
|
||||||
|
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||||
|
import { resetPassword, sendPasswordResetSms, verifyPasswordResetSms } from '@/service/auth';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const phone = ref('');
|
||||||
|
const smsCode = ref('');
|
||||||
|
|
||||||
|
const resetToken = ref('');
|
||||||
|
const newPassword = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
|
const sending = ref(false);
|
||||||
|
const verifying = ref(false);
|
||||||
|
const resetting = ref(false);
|
||||||
|
|
||||||
|
const sendCooldown = ref(0);
|
||||||
|
let cooldownTimer = null;
|
||||||
|
|
||||||
|
const showCodeDialog = ref(false);
|
||||||
|
const debugCode = ref('');
|
||||||
|
|
||||||
|
const step = computed(() => (resetToken.value ? 'reset' : 'verify'));
|
||||||
|
|
||||||
|
function startCooldown(seconds) {
|
||||||
|
sendCooldown.value = Math.max(0, Number(seconds) || 0);
|
||||||
|
if (cooldownTimer) window.clearInterval(cooldownTimer);
|
||||||
|
if (sendCooldown.value <= 0) return;
|
||||||
|
cooldownTimer = window.setInterval(() => {
|
||||||
|
sendCooldown.value = Math.max(0, sendCooldown.value - 1);
|
||||||
|
if (sendCooldown.value <= 0 && cooldownTimer) {
|
||||||
|
window.clearInterval(cooldownTimer);
|
||||||
|
cooldownTimer = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainError(err, fallback) {
|
||||||
|
const payload = err?.payload;
|
||||||
|
const message = String(payload?.message || err?.message || '').trim();
|
||||||
|
const id = payload?.id;
|
||||||
|
if (err?.status === 0 || err?.status === undefined) return { summary: '网络开小差了', detail: '请检查网络连接后重试。' };
|
||||||
|
if (err?.status >= 500) return { summary: '服务器忙,请稍后再试', detail: id ? `错误编号:${id}` : '请稍后重试,或联系管理员。' };
|
||||||
|
return { summary: fallback, detail: message || (id ? `错误编号:${id}` : '请稍后重试。') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
if (sending.value) return;
|
||||||
|
if (!phone.value.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: '请输入手机号', detail: '用于找回密码', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sendCooldown.value > 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sending.value = true;
|
||||||
|
const data = await sendPasswordResetSms({ phone: phone.value.trim() });
|
||||||
|
debugCode.value = String(data?.code || '').trim();
|
||||||
|
showCodeDialog.value = Boolean(debugCode.value);
|
||||||
|
startCooldown(Number(data?.nextSendSeconds || 60));
|
||||||
|
toast.add({ severity: 'success', summary: '验证码已发送', detail: '请查收短信(当前为预留弹窗展示)', life: 2000 });
|
||||||
|
} catch (err) {
|
||||||
|
const tip = explainError(err, '发送失败');
|
||||||
|
toast.add({ severity: 'error', summary: tip.summary, detail: tip.detail, life: 3500 });
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCode() {
|
||||||
|
if (verifying.value) return;
|
||||||
|
if (!phone.value.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: '请输入手机号', detail: '用于找回密码', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!smsCode.value.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: '请输入验证码', detail: '短信验证码不能为空', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
verifying.value = true;
|
||||||
|
const data = await verifyPasswordResetSms({ phone: phone.value.trim(), code: smsCode.value.trim() });
|
||||||
|
resetToken.value = String(data?.resetToken || '').trim();
|
||||||
|
if (!resetToken.value) throw new Error('resetToken missing');
|
||||||
|
toast.add({ severity: 'success', summary: '验证通过', detail: '请设置新密码', life: 2000 });
|
||||||
|
} catch (err) {
|
||||||
|
const tip = explainError(err, '验证失败');
|
||||||
|
toast.add({ severity: 'error', summary: tip.summary, detail: tip.detail, life: 3500 });
|
||||||
|
} finally {
|
||||||
|
verifying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReset() {
|
||||||
|
if (resetting.value) return;
|
||||||
|
if (!resetToken.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: '请先完成验证码校验', detail: '验证码校验通过后才能重置密码', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newPassword.value || !confirmPassword.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: '请输入新密码', detail: '请填写新密码并确认', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
toast.add({ severity: 'warn', summary: '密码强度不足', detail: '密码至少 8 位', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
toast.add({ severity: 'error', summary: '两次密码不一致', detail: '请重新确认', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resetting.value = true;
|
||||||
|
await resetPassword({ resetToken: resetToken.value, password: newPassword.value, confirmPassword: confirmPassword.value });
|
||||||
|
toast.add({ severity: 'success', summary: '重置成功', detail: '请使用新密码登录', life: 2000 });
|
||||||
|
await router.push('/auth/login');
|
||||||
|
} catch (err) {
|
||||||
|
const tip = explainError(err, '重置失败');
|
||||||
|
toast.add({ severity: 'error', summary: tip.summary, detail: tip.detail, life: 3500 });
|
||||||
|
} finally {
|
||||||
|
resetting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FloatingConfigurator />
|
||||||
|
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, var(--primary-color) 10%, rgba(33, 150, 243, 0) 30%)">
|
||||||
|
<div class="w-full bg-surface-0 dark:bg-surface-900 py-16 px-8 sm:px-20" style="border-radius: 53px">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-2">找回密码</div>
|
||||||
|
<span class="text-muted-color font-medium">通过手机号验证后重置密码</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="phone" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">手机号</label>
|
||||||
|
<InputText
|
||||||
|
id="phone"
|
||||||
|
v-model="phone"
|
||||||
|
type="text"
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
class="w-full md:w-[30rem] text-xl py-3"
|
||||||
|
autocomplete="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="step === 'verify'">
|
||||||
|
<label for="smsCode" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-1">短信验证码</label>
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<InputText
|
||||||
|
id="smsCode"
|
||||||
|
v-model="smsCode"
|
||||||
|
type="text"
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
class="flex-1 text-xl py-3"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:loading="sending"
|
||||||
|
:disabled="sendCooldown > 0"
|
||||||
|
:label="sendCooldown > 0 ? `${sendCooldown}s` : '发送验证码'"
|
||||||
|
size="large"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="sendCode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button :loading="verifying" label="验证验证码" size="large" class="w-full mt-6" @click="verifyCode" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<label for="newPassword" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-1">新密码</label>
|
||||||
|
<Password
|
||||||
|
id="newPassword"
|
||||||
|
v-model="newPassword"
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入新密码(至少 8 位)"
|
||||||
|
:toggleMask="true"
|
||||||
|
fluid
|
||||||
|
:feedback="false"
|
||||||
|
inputClass="text-xl py-3"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirmNewPassword" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-1">确认新密码</label>
|
||||||
|
<Password
|
||||||
|
id="confirmNewPassword"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
size="large"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
:toggleMask="true"
|
||||||
|
fluid
|
||||||
|
:feedback="false"
|
||||||
|
inputClass="text-xl py-3"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="submitReset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button :loading="resetting" label="提交重置" size="large" class="w-full mt-6" @click="submitReset" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-2 text-sm mt-2">
|
||||||
|
<router-link class="text-primary font-medium" to="/auth/login">返回登录</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showCodeDialog" modal header="验证码(预留弹窗)" :style="{ width: '26rem' }">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-muted-color">当前版本未接入短信服务,临时通过弹窗展示验证码。</div>
|
||||||
|
<div class="text-2xl font-semibold">{{ debugCode }}</div>
|
||||||
|
<Button label="我知道了" size="large" class="w-full" @click="showCodeDialog = false" />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user