feat(auth): 新增密码登录注册与改密功能并加固安全
- 后端:新增密码登录/注册/重置/改密接口,users 表新增 password_hash 字段 - 安全加固:注册改为冲突即失败防止"注册即改密",登录用户不存在统一返回密码错误并计失败次数防枚举,改密需校验旧密码,清理登录失败计数中的死代码 - 前端:登录页重构为"登录/注册"两个 tab,登录内可切换密码/短信方式,默认密码登录 - 个人中心新增修改密码入口(PC 弹窗 + 移动端 popup),PC 个人资料页移除买家/卖家服务面板
This commit is contained in:
@@ -5,6 +5,7 @@ import "time"
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||
|
||||
@@ -29,6 +29,28 @@ type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8,max=20"`
|
||||
}
|
||||
|
||||
type SetPasswordRequest struct {
|
||||
Password string `json:"password" binding:"required,min=8,max=20"`
|
||||
OldPassword string `json:"old_password"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
Password string `json:"password" binding:"required,min=8,max=20"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8,max=20"`
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
@@ -130,6 +152,65 @@ func (h *Handler) Logout(c *gin.Context) {
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
}
|
||||
|
||||
func (h *Handler) PasswordLogin(c *gin.Context) {
|
||||
var req PasswordLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "手机号和密码不能为空,密码长度 8-20 位")
|
||||
return
|
||||
}
|
||||
result, err := h.service.LoginWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), req.Password, c.ClientIP())
|
||||
if err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
|
||||
return
|
||||
}
|
||||
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password)
|
||||
if err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) SetPassword(c *gin.Context) {
|
||||
var req SetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "密码不能为空,长度 8-20 位")
|
||||
return
|
||||
}
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
if err := h.service.SetPassword(c.Request.Context(), userID, req.Password, req.OldPassword); err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "手机号、验证码和新密码不能为空")
|
||||
return
|
||||
}
|
||||
if err := h.service.ResetPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.NewPassword); err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reseted": true})
|
||||
}
|
||||
|
||||
func writeAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
@@ -146,7 +227,28 @@ func writeAuthError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "验证码错误或已过期")
|
||||
case errors.Is(err, ErrUserDisabled):
|
||||
response.Error(c, http.StatusForbidden, "user_disabled", "用户已被冻结")
|
||||
case errors.Is(err, ErrInvalidPassword):
|
||||
response.BadRequest(c, "手机号或密码错误")
|
||||
case errors.Is(err, ErrInvalidOldPassword):
|
||||
response.BadRequest(c, "原密码错误")
|
||||
case errors.Is(err, ErrPasswordNotSet):
|
||||
response.BadRequest(c, "未设置密码,请使用短信验证码登录后设置密码")
|
||||
case errors.Is(err, ErrPasswordTooWeak):
|
||||
response.BadRequest(c, "密码长度应为 8-20 位")
|
||||
case errors.Is(err, ErrLoginLocked):
|
||||
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请 15 分钟后再试")
|
||||
case errors.Is(err, ErrUserAlreadyExists):
|
||||
response.BadRequest(c, "该手机号已注册,请直接登录或使用短信验证码登录")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
|
||||
}
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
val, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := val.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
@@ -64,6 +64,38 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := r.db.WithContext(ctx).Where("phone = ?", phone).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
|
||||
now := time.Now()
|
||||
user := model.User{
|
||||
Phone: phone,
|
||||
PasswordHash: hash,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Create(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -25,6 +26,12 @@ var (
|
||||
ErrCodeInvalid = errors.New("sms code invalid")
|
||||
ErrSMSSendFailed = errors.New("sms send failed")
|
||||
ErrUserDisabled = errors.New("user disabled")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrInvalidOldPassword = errors.New("invalid old password")
|
||||
ErrPasswordNotSet = errors.New("password not set")
|
||||
ErrPasswordTooWeak = errors.New("password too weak")
|
||||
ErrLoginLocked = errors.New("login locked")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,6 +39,11 @@ const (
|
||||
smsLoginHourlyLimit = 5
|
||||
smsLoginHourlyWindow = time.Hour
|
||||
smsCaptchaTTL = 3 * time.Minute
|
||||
passwordMinLength = 8
|
||||
passwordMaxLength = 20
|
||||
loginFailureTTL = 15 * time.Minute
|
||||
loginLockTTL = 15 * time.Minute
|
||||
loginMaxFailureCount = 5
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -202,3 +214,189 @@ func PublicPhone(phone string) string {
|
||||
}
|
||||
return fmt.Sprintf("%s****%s", phone[:3], phone[len(phone)-4:])
|
||||
}
|
||||
|
||||
func (s *Service) LoginWithPassword(ctx context.Context, phone, password, clientIP string) (LoginResult, error) {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if !isPhone(phone) {
|
||||
return LoginResult{}, ErrInvalidPhone
|
||||
}
|
||||
|
||||
locked, err := s.redis.Exists(ctx, loginLockRedisKey(phone, clientIP)).Result()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if locked > 0 {
|
||||
return LoginResult{}, ErrLoginLocked
|
||||
}
|
||||
|
||||
user, err := s.users.FindByPhone(ctx, phone)
|
||||
if IsNotFound(err) {
|
||||
// 用户不存在也计失败次数,避免该接口被用于探测手机号是否已注册
|
||||
_ = recordLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
return LoginResult{}, ErrInvalidPassword
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
if user.PasswordHash == "" {
|
||||
return LoginResult{}, ErrPasswordNotSet
|
||||
}
|
||||
if checkPasswordHash(password, user.PasswordHash) != nil {
|
||||
_ = recordLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
return LoginResult{}, ErrInvalidPassword
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
return LoginResult{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
|
||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{User: user, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, userID uint64, password, oldPassword string) error {
|
||||
if !isValidPassword(password) {
|
||||
return ErrPasswordTooWeak
|
||||
}
|
||||
user, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 已设置过密码时必须校验旧密码,避免会话被他人借用后直接改密锁定原号主
|
||||
if user.PasswordHash != "" {
|
||||
if checkPasswordHash(oldPassword, user.PasswordHash) != nil {
|
||||
return ErrInvalidOldPassword
|
||||
}
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.users.SetPassword(ctx, userID, string(hash))
|
||||
}
|
||||
|
||||
func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword string) error {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if !isPhone(phone) {
|
||||
return ErrInvalidPhone
|
||||
}
|
||||
|
||||
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
|
||||
if errors.Is(err, redis.Nil) || stored != code {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isValidPassword(newPassword) {
|
||||
return ErrPasswordTooWeak
|
||||
}
|
||||
|
||||
user, err := s.users.FindByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.users.SetPassword(ctx, user.ID, string(hash)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.redis.Del(ctx, codeKey(phone)).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string) (LoginResult, error) {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if !isPhone(phone) {
|
||||
return LoginResult{}, ErrInvalidPhone
|
||||
}
|
||||
|
||||
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
|
||||
if errors.Is(err, redis.Nil) || stored != code {
|
||||
return LoginResult{}, ErrCodeInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if !isValidPassword(password) {
|
||||
return LoginResult{}, ErrPasswordTooWeak
|
||||
}
|
||||
|
||||
// 已注册的号不允许通过注册流程覆盖密码,避免"注册即改密"接管账号
|
||||
existing, err := s.users.FindByPhone(ctx, phone)
|
||||
if err == nil && existing != nil {
|
||||
return LoginResult{}, ErrUserAlreadyExists
|
||||
}
|
||||
if err != nil && !IsNotFound(err) {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash))
|
||||
if err != nil {
|
||||
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
|
||||
return LoginResult{}, ErrUserAlreadyExists
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return LoginResult{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.redis.Del(ctx, codeKey(phone)).Err()
|
||||
return LoginResult{User: user, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func isValidPassword(password string) bool {
|
||||
return len(password) >= passwordMinLength && len(password) <= passwordMaxLength
|
||||
}
|
||||
|
||||
func checkPasswordHash(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
func recordLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error {
|
||||
key := loginFailureRedisKey(phone, clientIP)
|
||||
count, err := rdb.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = rdb.Expire(ctx, key, loginFailureTTL).Err()
|
||||
if count >= int64(loginMaxFailureCount) {
|
||||
_ = rdb.Set(ctx, loginLockRedisKey(phone, clientIP), "1", loginLockTTL).Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error {
|
||||
return rdb.Del(ctx, loginFailureRedisKey(phone, clientIP), loginLockRedisKey(phone, clientIP)).Err()
|
||||
}
|
||||
|
||||
func loginFailureRedisKey(phone, clientIP string) string {
|
||||
return fmt.Sprintf("user:login:fail:%s:%s", clientIP, phone)
|
||||
}
|
||||
|
||||
func loginLockRedisKey(phone, clientIP string) string {
|
||||
return fmt.Sprintf("user:login:lock:%s:%s", clientIP, phone)
|
||||
}
|
||||
|
||||
@@ -371,12 +371,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
authRoutes.GET("/captcha", authHandler.Captcha)
|
||||
authRoutes.POST("/sms/send", authHandler.SendSMS)
|
||||
authRoutes.POST("/sms/login", authHandler.Login)
|
||||
authRoutes.POST("/password/login", authHandler.PasswordLogin)
|
||||
authRoutes.POST("/password/register", authHandler.Register)
|
||||
authRoutes.POST("/password/reset", authHandler.ResetPassword)
|
||||
authRoutes.POST("/refresh", authHandler.Refresh)
|
||||
authRoutes.POST("/logout", authHandler.Logout)
|
||||
}
|
||||
|
||||
api.GET("/me", requireAuth, userHandler.Me)
|
||||
api.PUT("/me", requireAuth, userHandler.UpdateMe)
|
||||
api.PUT("/password", requireAuth, authHandler.SetPassword)
|
||||
|
||||
listingRoutes := api.Group("/listings")
|
||||
{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希(bcrypt)'
|
||||
AFTER phone;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE users DROP COLUMN password_hash;
|
||||
@@ -53,6 +53,31 @@ export async function loginWithSms(phone: string, code: string) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function loginWithPassword(phone: string, password: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/login', {
|
||||
phone,
|
||||
password,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function registerWithPassword(phone: string, code: string, password: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/register', {
|
||||
phone,
|
||||
code,
|
||||
password,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function setPassword(password: string, oldPassword?: string) {
|
||||
await apiClient.put('/password', { password, old_password: oldPassword })
|
||||
}
|
||||
|
||||
export async function resetPassword(phone: string, code: string, newPassword: string) {
|
||||
await apiClient.post('/auth/password/reset', { phone, code, new_password: newPassword })
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me')
|
||||
return data.data
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatLineRound, Iphone, Key } from '@element-plus/icons-vue'
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { ChatLineRound, Iphone, Key, Lock } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAuthCaptcha, sendSmsCode, type AuthCaptcha } from '@/features/auth/api/auth'
|
||||
@@ -15,12 +15,19 @@ const sending = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
const captcha = ref<AuthCaptcha | null>(null)
|
||||
const countDown = ref(0)
|
||||
const topTab = ref<'login' | 'register'>('login')
|
||||
const loginMode = ref<'password' | 'sms'>('password')
|
||||
const form = reactive({
|
||||
phone: '',
|
||||
captchaCode: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const passwordValid = computed(() => form.password.length >= 8 && form.password.length <= 20)
|
||||
const confirmValid = computed(() => form.confirmPassword && form.confirmPassword === form.password)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startCountDown() {
|
||||
@@ -43,6 +50,20 @@ onUnmounted(() => {
|
||||
|
||||
onMounted(loadCaptcha)
|
||||
|
||||
function switchTab(tab: 'login' | 'register') {
|
||||
topTab.value = tab
|
||||
form.code = ''
|
||||
form.captchaCode = ''
|
||||
form.password = ''
|
||||
form.confirmPassword = ''
|
||||
}
|
||||
|
||||
function switchLoginMode(mode: 'password' | 'sms') {
|
||||
loginMode.value = mode
|
||||
form.code = ''
|
||||
form.captchaCode = ''
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
@@ -78,9 +99,7 @@ async function handleSendCode() {
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
if (captchaLoading.value) {
|
||||
return
|
||||
}
|
||||
if (captchaLoading.value) return
|
||||
await loadCaptcha()
|
||||
}
|
||||
|
||||
@@ -96,6 +115,59 @@ async function handleLogin() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordLogin() {
|
||||
if (!form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.password) {
|
||||
ElMessage.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await session.loginByPassword(form.phone, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
await router.push('/')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '登录失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.code) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (!passwordValid.value) {
|
||||
ElMessage.warning('密码长度应为 8-20 位')
|
||||
return
|
||||
}
|
||||
if (!confirmValid.value) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await session.register(form.phone, form.code, form.password)
|
||||
ElMessage.success('注册成功')
|
||||
await router.push('/')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '注册失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const modeEyebrow = computed(() => (topTab.value === 'register' ? 'Register' : 'Login'))
|
||||
const modeTitle = computed(() => (topTab.value === 'register' ? '创建账号' : '欢迎回来'))
|
||||
const modeSubtitle = computed(() => (topTab.value === 'register' ? '注册后即可发布或租赁账号' : '登录后即可发布或租赁账号'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -120,78 +192,104 @@ async function handleLogin() {
|
||||
|
||||
<div class="login-right">
|
||||
<div class="login-header">
|
||||
<p class="eyebrow">SMS Login</p>
|
||||
<h1>欢迎回来</h1>
|
||||
<p class="subtitle">登录后即可发布或租赁账号</p>
|
||||
<p class="eyebrow">{{ modeEyebrow }}</p>
|
||||
<h1>{{ modeTitle }}</h1>
|
||||
<p class="subtitle">{{ modeSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<el-form class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form-item label="手机号">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
:prefix-icon="Iphone"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="login-tabs">
|
||||
<button
|
||||
:class="{ active: topTab === 'login' }"
|
||||
@click="switchTab('login')"
|
||||
>登录</button>
|
||||
<button
|
||||
:class="{ active: topTab === 'register' }"
|
||||
@click="switchTab('register')"
|
||||
>注册</button>
|
||||
</div>
|
||||
|
||||
<!-- 登录模式下的方式切换 -->
|
||||
<div v-if="topTab === 'login'" class="login-sub-tabs">
|
||||
<button
|
||||
:class="{ active: loginMode === 'password' }"
|
||||
@click="switchLoginMode('password')"
|
||||
>密码登录</button>
|
||||
<span class="sub-divider">|</span>
|
||||
<button
|
||||
:class="{ active: loginMode === 'sms' }"
|
||||
@click="switchLoginMode('sms')"
|
||||
>短信登录</button>
|
||||
</div>
|
||||
|
||||
<!-- 短信登录 -->
|
||||
<el-form v-if="topTab === 'login' && loginMode === 'sms'" class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" size="large" :prefix-icon="Iphone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="user-captcha-row">
|
||||
<el-input
|
||||
v-model="form.captchaCode"
|
||||
maxlength="4"
|
||||
placeholder="请输入图形验证码"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
@keyup.enter="handleSendCode"
|
||||
/>
|
||||
<button
|
||||
class="user-captcha-image"
|
||||
type="button"
|
||||
:disabled="captchaLoading"
|
||||
aria-label="刷新图形验证码"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<el-input v-model="form.captchaCode" maxlength="4" placeholder="请输入图形验证码" size="large" :prefix-icon="Key" @keyup.enter="handleSendCode" />
|
||||
<button class="user-captcha-image" type="button" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="refreshCaptcha">
|
||||
<img v-if="captcha" :src="captcha.image" alt="图形验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码">
|
||||
<div class="user-code-row">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
maxlength="6"
|
||||
placeholder="6 位验证码"
|
||||
size="large"
|
||||
:prefix-icon="ChatLineRound"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<el-button
|
||||
size="large"
|
||||
:disabled="countDown > 0 || sending"
|
||||
:loading="sending"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
<el-input v-model="form.code" maxlength="6" placeholder="6 位验证码" size="large" :prefix-icon="ChatLineRound" @keyup.enter="handleLogin" />
|
||||
<el-button size="large" :disabled="countDown > 0 || sending" :loading="sending" @click="handleSendCode">
|
||||
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
class="user-login-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
|
||||
<el-button class="user-login-btn" type="primary" size="large" :loading="loading" @click="handleLogin">登录</el-button>
|
||||
<p class="login-notice">未收到验证码时,请稍后重试或联系客服处理</p>
|
||||
</el-form>
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<el-form v-if="topTab === 'login' && loginMode === 'password'" class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" size="large" :prefix-icon="Iphone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" maxlength="20" placeholder="请输入密码" size="large" :prefix-icon="Lock" show-password @keyup.enter="handlePasswordLogin" />
|
||||
</el-form-item>
|
||||
<el-button class="user-login-btn" type="primary" size="large" :loading="loading" @click="handlePasswordLogin">登录</el-button>
|
||||
<p class="login-notice">未设置密码?请使用短信登录后前往个人中心设置密码</p>
|
||||
</el-form>
|
||||
|
||||
<!-- 注册 -->
|
||||
<el-form v-if="topTab === 'register'" class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" size="large" :prefix-icon="Iphone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="user-captcha-row">
|
||||
<el-input v-model="form.captchaCode" maxlength="4" placeholder="请输入图形验证码" size="large" :prefix-icon="Key" @keyup.enter="handleSendCode" />
|
||||
<button class="user-captcha-image" type="button" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="refreshCaptcha">
|
||||
<img v-if="captcha" :src="captcha.image" alt="图形验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="user-code-row">
|
||||
<el-input v-model="form.code" maxlength="6" placeholder="6 位验证码" size="large" :prefix-icon="ChatLineRound" @keyup.enter="handleRegister" />
|
||||
<el-button size="large" :disabled="countDown > 0 || sending" :loading="sending" @click="handleSendCode">
|
||||
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="设置密码" :error="form.password && !passwordValid ? '密码长度 8-20 位' : ''">
|
||||
<el-input v-model="form.password" type="password" maxlength="20" placeholder="请输入密码(8-20 位)" size="large" :prefix-icon="Lock" show-password :class="{ 'is-error': form.password && !passwordValid }" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" :error="form.confirmPassword && !confirmValid ? '两次密码不一致' : ''">
|
||||
<el-input v-model="form.confirmPassword" type="password" maxlength="20" placeholder="请再次输入密码" size="large" :prefix-icon="Lock" show-password :class="{ 'is-error': form.confirmPassword && !confirmValid }" />
|
||||
</el-form-item>
|
||||
<el-button class="user-login-btn" type="primary" size="large" :loading="loading" @click="handleRegister">注册</el-button>
|
||||
<p class="login-notice">注册即表示同意《用户协议》和《隐私政策》</p>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -287,16 +385,16 @@ async function handleLogin() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 36px 32px;
|
||||
padding: 32px 32px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-header .eyebrow {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 6px;
|
||||
color: #ff6a00;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -313,11 +411,79 @@ async function handleLogin() {
|
||||
}
|
||||
|
||||
.login-header .subtitle {
|
||||
margin: 8px 0 0;
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-tabs button {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
border: none;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-tabs button + button {
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.login-tabs button.active {
|
||||
background: #ff6a00;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.login-tabs button:hover:not(.active) {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.login-sub-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-sub-tabs button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-sub-tabs button.active {
|
||||
color: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-sub-tabs button:hover:not(.active) {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.login-sub-tabs .sub-divider {
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-form :deep(.el-form-item__label) {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
@@ -425,7 +591,7 @@ async function handleLogin() {
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
background: linear-gradient(135deg, #ff6a00, #ff8a1f);
|
||||
border: none;
|
||||
@@ -440,13 +606,17 @@ async function handleLogin() {
|
||||
}
|
||||
|
||||
.login-notice {
|
||||
margin: 18px 0 0;
|
||||
margin: 14px 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.is-error :deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px #ef4444 inset !important;
|
||||
}
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 860px) {
|
||||
.login-card {
|
||||
@@ -457,7 +627,7 @@ async function handleLogin() {
|
||||
display: none;
|
||||
}
|
||||
.login-right {
|
||||
padding: 32px 28px;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.login-shell {
|
||||
padding: 24px 16px;
|
||||
|
||||
@@ -15,16 +15,29 @@ const loading = ref(false)
|
||||
const agreed = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
const captcha = ref<AuthCaptcha | null>(null)
|
||||
const loginMode = ref<'sms' | 'password'>('password')
|
||||
const form = reactive({
|
||||
phone: '',
|
||||
captchaCode: '',
|
||||
code: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const { countDown, sending, handleSendCode } = useSmsCountdown()
|
||||
|
||||
onMounted(loadCaptcha)
|
||||
|
||||
function switchToPassword() {
|
||||
loginMode.value = 'password'
|
||||
form.captchaCode = ''
|
||||
form.code = ''
|
||||
}
|
||||
|
||||
function switchToSms() {
|
||||
loginMode.value = 'sms'
|
||||
form.password = ''
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
@@ -67,6 +80,30 @@ async function handleLogin() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordLogin() {
|
||||
if (!agreed.value) {
|
||||
showDialog({
|
||||
title: '提示',
|
||||
message: '请先阅读并同意用户协议和隐私政策',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!form.password) {
|
||||
showToast({ message: '请输入密码', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await session.loginByPassword(form.phone, form.password)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/m/profile'
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, '登录失败,请检查手机号和密码'), icon: 'cross' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -84,12 +121,14 @@ async function handleLogin() {
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h1>登录</h1>
|
||||
<h1>{{ loginMode === 'sms' ? '登录' : '密码登录' }}</h1>
|
||||
|
||||
<label class="auth-input-row">
|
||||
<input v-model="form.phone" type="tel" maxlength="11" placeholder="手机号" />
|
||||
</label>
|
||||
|
||||
<!-- 短信登录 -->
|
||||
<template v-if="loginMode === 'sms'">
|
||||
<label class="auth-input-row captcha-row">
|
||||
<input
|
||||
v-model="form.captchaCode"
|
||||
@@ -130,10 +169,26 @@ async function handleLogin() {
|
||||
</van-button>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<template v-if="loginMode === 'password'">
|
||||
<label class="auth-input-row">
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="密码"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="assist-row">
|
||||
<span>验证码登录</span>
|
||||
<RouterLink to="/m/register">没有账号?<b>立即注册</b></RouterLink>
|
||||
<span v-if="loginMode === 'sms'">验证码登录</span>
|
||||
<span v-else>密码登录</span>
|
||||
<button v-if="loginMode === 'sms'" class="link-btn" type="button" @click="switchToPassword">密码登录</button>
|
||||
<button v-else class="link-btn" type="button" @click="switchToSms">短信验证码登录</button>
|
||||
<RouterLink v-if="loginMode === 'sms'" to="/m/register">没有账号?<b>立即注册</b></RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="agreement-row">
|
||||
@@ -143,6 +198,7 @@ async function handleLogin() {
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
v-if="loginMode === 'sms'"
|
||||
type="primary"
|
||||
block
|
||||
class="primary-button"
|
||||
@@ -152,6 +208,17 @@ async function handleLogin() {
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
<van-button
|
||||
v-else
|
||||
type="primary"
|
||||
block
|
||||
class="primary-button"
|
||||
:loading="loading"
|
||||
loading-text="登录中..."
|
||||
@click="handlePasswordLogin"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
|
||||
<RouterLink to="/m/register" class="secondary-entry">还没有账号,创建一个</RouterLink>
|
||||
</div>
|
||||
@@ -196,215 +263,169 @@ async function handleLogin() {
|
||||
.auth-body {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 54px 34px 28px;
|
||||
align-items: center;
|
||||
padding: 100px 24px 48px;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 auto clamp(34px, 6vh, 54px);
|
||||
gap: 10px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ff6a00, #ff914d);
|
||||
box-shadow: 0 14px 32px rgba(255, 106, 0, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 19px;
|
||||
box-shadow: 0 10px 28px rgba(255, 106, 0, 0.24);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.brand-lockup strong {
|
||||
display: block;
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
width: min(100%, 420px);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.form-section h1 {
|
||||
margin: 0 0 22px;
|
||||
color: #05070a;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 28px;
|
||||
color: #0f172a;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.auth-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e1e5eb;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.auth-input-row.code-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-input-row.captcha-row {
|
||||
gap: 10px;
|
||||
padding-right: 4px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-input-row input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 14px 16px;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-input-row input::placeholder {
|
||||
color: #b6beca;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.code-action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
.captcha-row {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.captcha-image-button {
|
||||
display: grid;
|
||||
flex: 0 0 116px;
|
||||
width: 116px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe5f0;
|
||||
min-width: 110px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #fff7ed;
|
||||
color: #ff6a00;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.captcha-image-button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.captcha-image-button img {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
width: 110px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.code-action {
|
||||
min-width: 110px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
min-width: 82px;
|
||||
height: 32px;
|
||||
border-color: #ff6a00 !important;
|
||||
border-radius: 8px;
|
||||
color: #ff6a00 !important;
|
||||
font-weight: 700;
|
||||
width: 100%;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.assist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin: 14px 0 24px;
|
||||
color: #111827;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assist-row span {
|
||||
min-width: 0;
|
||||
.assist-row a,
|
||||
.link-btn {
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.assist-row a {
|
||||
flex-shrink: 0;
|
||||
color: #22252b;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.assist-row b {
|
||||
color: #05070a;
|
||||
font-weight: 900;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 20px;
|
||||
margin: 16px 0 20px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.agreement-row :deep(.van-checkbox__label) {
|
||||
margin-left: 8px;
|
||||
color: #252b36;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.agreement-row :deep(.van-checkbox__label b) {
|
||||
.agreement-row b {
|
||||
color: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
height: 48px;
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
background: linear-gradient(135deg, #ff6a00, #ff8a1f) !important;
|
||||
box-shadow: 0 12px 24px rgba(255, 106, 0, 0.22);
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ff6a00, #ff8a1f);
|
||||
border: none;
|
||||
box-shadow: 0 10px 28px rgba(255, 106, 0, 0.24);
|
||||
}
|
||||
|
||||
.secondary-entry {
|
||||
display: grid;
|
||||
min-height: 46px;
|
||||
margin-top: 12px;
|
||||
place-items: center;
|
||||
border: 1px solid #e4e8ee;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: #ff6a00;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.auth-body {
|
||||
justify-content: flex-start;
|
||||
padding-top: 52px;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.form-section h1 {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.assist-row {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/
|
||||
import { formatDateMinute } from '@/shared/utils/time'
|
||||
import { uploadFile } from '@/shared/api/files'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import { setPassword } from '@/features/auth/api/auth'
|
||||
|
||||
const session = useSessionStore()
|
||||
const router = useRouter()
|
||||
@@ -36,6 +37,15 @@ const profileForm = reactive({
|
||||
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadingAvatar = ref(false)
|
||||
|
||||
// 修改密码
|
||||
const showPasswordEditor = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
function triggerAvatarUpload() {
|
||||
avatarFileInput.value?.click()
|
||||
}
|
||||
@@ -209,6 +219,39 @@ async function saveProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
function openPasswordEditor() {
|
||||
passwordForm.oldPassword = ''
|
||||
passwordForm.newPassword = ''
|
||||
passwordForm.confirmPassword = ''
|
||||
showPasswordEditor.value = true
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
const { newPassword, confirmPassword } = passwordForm
|
||||
if (!newPassword) {
|
||||
showToast({ message: '请输入新密码', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8 || newPassword.length > 20) {
|
||||
showToast({ message: '密码长度应为 8-20 位', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast({ message: '两次输入的密码不一致', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
savingPassword.value = true
|
||||
try {
|
||||
await setPassword(newPassword, passwordForm.oldPassword || undefined)
|
||||
showToast({ message: '密码已更新', icon: 'passed' })
|
||||
showPasswordEditor.value = false
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, '密码更新失败'), icon: 'cross' })
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmLogout() {
|
||||
showSettings.value = false
|
||||
showDialog({
|
||||
@@ -387,6 +430,7 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
</van-cell>
|
||||
<van-cell title="公告中心" icon="volume-o" is-link to="/m/announcements" />
|
||||
<van-cell title="资料修改" icon="edit" is-link @click="openProfileEditor" />
|
||||
<van-cell title="修改密码" icon="lock" is-link @click="openPasswordEditor" />
|
||||
<van-cell title="实名认证" icon="idcard" is-link to="/m/realname">
|
||||
<template #value>
|
||||
<span :class="isRealnameVerified ? 'verified-color' : 'unverified-color'">
|
||||
@@ -568,6 +612,65 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
</section>
|
||||
</van-popup>
|
||||
|
||||
<!-- 修改密码 Popup -->
|
||||
<van-popup
|
||||
v-model:show="showPasswordEditor"
|
||||
position="bottom"
|
||||
round
|
||||
:style="{ maxWidth: '430px', margin: '0 auto', left: 0, right: 0 }"
|
||||
>
|
||||
<section class="profile-editor">
|
||||
<header class="profile-editor-header">
|
||||
<h2>修改密码</h2>
|
||||
<button type="button" @click="showPasswordEditor = false">
|
||||
<van-icon name="cross" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="password-hint">未设置过密码的老用户,"原密码"可留空直接设置新密码。</p>
|
||||
|
||||
<label class="editor-field">
|
||||
<span>原密码</span>
|
||||
<input
|
||||
v-model="passwordForm.oldPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="未设置过密码可留空"
|
||||
/>
|
||||
</label>
|
||||
<label class="editor-field">
|
||||
<span>新密码</span>
|
||||
<input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="8-20 位"
|
||||
/>
|
||||
</label>
|
||||
<label class="editor-field">
|
||||
<span>确认新密码</span>
|
||||
<input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
class="profile-save-btn"
|
||||
:loading="savingPassword"
|
||||
loading-text="保存中..."
|
||||
@click="savePassword"
|
||||
>
|
||||
保存密码
|
||||
</van-button>
|
||||
</section>
|
||||
</van-popup>
|
||||
|
||||
<MobileBottomNav />
|
||||
</main>
|
||||
</template>
|
||||
@@ -1156,6 +1259,13 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
margin: 8px 0 14px;
|
||||
color: #8b9cb5;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-field span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
|
||||
@@ -19,6 +19,8 @@ const form = reactive({
|
||||
phone: '',
|
||||
captchaCode: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
inviteCode: '',
|
||||
})
|
||||
|
||||
@@ -57,10 +59,22 @@ async function handleRegister() {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!form.password) {
|
||||
showToast({ message: '请设置密码', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
if (form.password.length < 8 || form.password.length > 20) {
|
||||
showToast({ message: '密码长度应为 8-20 位', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
if (form.password !== form.confirmPassword) {
|
||||
showToast({ message: '两次输入的密码不一致', icon: 'warning-o' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await session.login(form.phone, form.code)
|
||||
await session.register(form.phone, form.code, form.password)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/m/profile'
|
||||
await router.replace(redirect)
|
||||
} catch {
|
||||
@@ -139,6 +153,24 @@ async function handleRegister() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="auth-input-row">
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="设置密码(8-20 位)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="auth-input-row">
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="确认密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="auth-input-row">
|
||||
<input v-model="form.inviteCode" maxlength="16" placeholder="邀请码(选填)" />
|
||||
</label>
|
||||
|
||||
@@ -3,19 +3,11 @@ import { readError } from '@/shared/utils/error'
|
||||
import {
|
||||
Camera,
|
||||
CircleCheckFilled,
|
||||
CirclePlus,
|
||||
Coin,
|
||||
EditPen,
|
||||
Finished,
|
||||
Goods,
|
||||
Lock,
|
||||
Postcard,
|
||||
RefreshRight,
|
||||
Shop,
|
||||
Tickets,
|
||||
User,
|
||||
Van,
|
||||
VideoPlay,
|
||||
Wallet,
|
||||
WarningFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -25,6 +17,7 @@ import { useRouter } from 'vue-router'
|
||||
import { uploadFile } from '@/shared/api/files'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { realnameStatusLabel } from '@/shared/utils/statusLabels'
|
||||
import { setPassword } from '@/features/auth/api/auth'
|
||||
|
||||
const session = useSessionStore()
|
||||
const router = useRouter()
|
||||
@@ -35,37 +28,13 @@ const form = reactive({
|
||||
nickname: '',
|
||||
avatar_url: '',
|
||||
})
|
||||
const buyerServices = [
|
||||
{
|
||||
label: '待支付',
|
||||
icon: Coin,
|
||||
tone: 'warning',
|
||||
to: { path: '/orders', query: { tab: 'pending_payment' } },
|
||||
},
|
||||
{
|
||||
label: '待交接',
|
||||
icon: Van,
|
||||
tone: 'info',
|
||||
to: { path: '/orders', query: { tab: 'pending_handoff' } },
|
||||
},
|
||||
{
|
||||
label: '使用中',
|
||||
icon: VideoPlay,
|
||||
tone: 'primary',
|
||||
to: { path: '/orders', query: { tab: 'renting' } },
|
||||
},
|
||||
{
|
||||
label: '已完成',
|
||||
icon: Finished,
|
||||
tone: 'success',
|
||||
to: { path: '/orders', query: { tab: 'completed' } },
|
||||
},
|
||||
]
|
||||
const sellerServices = [
|
||||
{ label: '发布商品', icon: CirclePlus, tone: 'orange', to: '/seller/listings/create' },
|
||||
{ label: '我的商品', icon: Shop, tone: 'purple', to: '/seller/listings' },
|
||||
{ label: '提现/账单', icon: Wallet, tone: 'teal', to: '/wallet' },
|
||||
]
|
||||
const showPasswordDialog = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const displayName = computed(() => session.displayName)
|
||||
const maskedPhone = computed(() => {
|
||||
@@ -145,6 +114,39 @@ async function saveProfile() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPasswordDialog() {
|
||||
passwordForm.oldPassword = ''
|
||||
passwordForm.newPassword = ''
|
||||
passwordForm.confirmPassword = ''
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
const { newPassword, confirmPassword } = passwordForm
|
||||
if (!newPassword) {
|
||||
ElMessage.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8 || newPassword.length > 20) {
|
||||
ElMessage.warning('密码长度应为 8-20 位')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
savingPassword.value = true
|
||||
try {
|
||||
await setPassword(newPassword, passwordForm.oldPassword || undefined)
|
||||
ElMessage.success('密码已更新')
|
||||
showPasswordDialog.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '密码更新失败'))
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -261,56 +263,55 @@ async function saveProfile() {
|
||||
<span>查看实名认证</span>
|
||||
<el-icon><CircleCheckFilled /></el-icon>
|
||||
</button>
|
||||
<button class="realname-shortcut" type="button" @click="openPasswordDialog">
|
||||
<span>修改密码</span>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="service-panels">
|
||||
<section class="service-panel">
|
||||
<div class="service-head">
|
||||
<h2>买家服务</h2>
|
||||
<RouterLink class="service-all" :to="{ path: '/orders', query: { tab: 'all' } }">
|
||||
<span>全部订单</span>
|
||||
<el-icon><Tickets /></el-icon>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="service-grid buyer-grid">
|
||||
<RouterLink
|
||||
v-for="item in buyerServices"
|
||||
:key="item.label"
|
||||
class="service-item"
|
||||
:to="item.to"
|
||||
<el-dialog
|
||||
v-model="showPasswordDialog"
|
||||
title="修改密码"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<span class="service-icon" :class="`is-${item.tone}`">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
</span>
|
||||
<strong>{{ item.label }}</strong>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="service-panel">
|
||||
<div class="service-head">
|
||||
<h2>卖家服务</h2>
|
||||
<RouterLink class="service-all" to="/seller/listings">
|
||||
<span>管理发布</span>
|
||||
<el-icon><Goods /></el-icon>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="service-grid seller-grid">
|
||||
<RouterLink
|
||||
v-for="item in sellerServices"
|
||||
:key="item.label"
|
||||
class="service-item"
|
||||
:to="item.to"
|
||||
>
|
||||
<span class="service-icon" :class="`is-${item.tone}`">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
</span>
|
||||
<strong>{{ item.label }}</strong>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="password-hint">未设置过密码的老用户,"原密码"可留空直接设置新密码。</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="原密码">
|
||||
<el-input
|
||||
v-model="passwordForm.oldPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="未设置过密码可留空"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="8-20 位"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="再次输入新密码"
|
||||
show-password
|
||||
@keyup.enter="savePassword"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingPassword" @click="savePassword">保存密码</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -623,145 +624,16 @@ async function saveProfile() {
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.service-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.service-panel {
|
||||
padding: 24px;
|
||||
border: 1px solid #eef1f5;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
|
||||
}
|
||||
|
||||
.service-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.service-head h2 {
|
||||
margin: 0;
|
||||
color: #17233d;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.service-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.service-all:hover {
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.buyer-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.seller-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 118px;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 10px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 14px;
|
||||
background: #fbfdff;
|
||||
color: #1f2937;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.service-item:hover {
|
||||
border-color: rgba(255, 106, 0, 0.35);
|
||||
box-shadow: 0 12px 24px rgba(23, 35, 61, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.service-item strong {
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.service-icon.is-warning {
|
||||
background: #fff7ed;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.service-icon.is-info {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.service-icon.is-primary {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.service-icon.is-success {
|
||||
background: #ecfdf3;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.service-icon.is-orange {
|
||||
background: #fff1e8;
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.service-icon.is-purple {
|
||||
background: #f3e8ff;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.service-icon.is-teal {
|
||||
background: #e8f7f5;
|
||||
color: #0f766e;
|
||||
.password-hint {
|
||||
margin: 0 0 12px;
|
||||
color: #8b9cb5;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.service-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getRefreshToken,
|
||||
setAuthTokens,
|
||||
} from '@/shared/utils/authStorage'
|
||||
import { loginWithPassword, registerWithPassword } from '@/features/auth/api/auth'
|
||||
|
||||
export const useSessionStore = defineStore('session', {
|
||||
state: () => ({
|
||||
@@ -31,6 +32,16 @@ export const useSessionStore = defineStore('session', {
|
||||
this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token)
|
||||
return result
|
||||
},
|
||||
async loginByPassword(phone: string, password: string) {
|
||||
const result = await loginWithPassword(phone, password)
|
||||
this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token)
|
||||
return result
|
||||
},
|
||||
async register(phone: string, code: string, password: string) {
|
||||
const result = await registerWithPassword(phone, code, password)
|
||||
this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token)
|
||||
return result
|
||||
},
|
||||
async loadMe() {
|
||||
if (this._loadingMe) {
|
||||
// 如果正在加载,等待完成
|
||||
|
||||
Reference in New Issue
Block a user