feat(auth): 新增密码登录注册与改密功能并加固安全

- 后端:新增密码登录/注册/重置/改密接口,users 表新增 password_hash 字段
- 安全加固:注册改为冲突即失败防止"注册即改密",登录用户不存在统一返回密码错误并计失败次数防枚举,改密需校验旧密码,清理登录失败计数中的死代码
- 前端:登录页重构为"登录/注册"两个 tab,登录内可切换密码/短信方式,默认密码登录
- 个人中心新增修改密码入口(PC 弹窗 + 移动端 popup),PC 个人资料页移除买家/卖家服务面板
This commit is contained in:
yml2213
2026-07-02 19:02:49 +08:00
parent 3701ae36ab
commit 1ba7c1dea1
13 changed files with 1043 additions and 456 deletions
+1
View File
@@ -5,6 +5,7 @@ import "time"
type User struct { type User struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` 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"` Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
+102
View File
@@ -29,6 +29,28 @@ type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"` 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 { func NewHandler(service *Service) *Handler {
return &Handler{service: service} return &Handler{service: service}
} }
@@ -130,6 +152,65 @@ func (h *Handler) Logout(c *gin.Context) {
response.OK(c, gin.H{"logged_out": true}) 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) { func writeAuthError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
@@ -146,7 +227,28 @@ func writeAuthError(c *gin.Context, err error) {
response.BadRequest(c, "验证码错误或已过期") response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrUserDisabled): case errors.Is(err, ErrUserDisabled):
response.Error(c, http.StatusForbidden, "user_disabled", "用户已被冻结") 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: default:
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用") 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 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 { func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound) return errors.Is(err, gorm.ErrRecordNotFound)
} }
+198
View File
@@ -15,6 +15,7 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
) )
var ( var (
@@ -25,6 +26,12 @@ var (
ErrCodeInvalid = errors.New("sms code invalid") ErrCodeInvalid = errors.New("sms code invalid")
ErrSMSSendFailed = errors.New("sms send failed") ErrSMSSendFailed = errors.New("sms send failed")
ErrUserDisabled = errors.New("user disabled") 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 ( const (
@@ -32,6 +39,11 @@ const (
smsLoginHourlyLimit = 5 smsLoginHourlyLimit = 5
smsLoginHourlyWindow = time.Hour smsLoginHourlyWindow = time.Hour
smsCaptchaTTL = 3 * time.Minute smsCaptchaTTL = 3 * time.Minute
passwordMinLength = 8
passwordMaxLength = 20
loginFailureTTL = 15 * time.Minute
loginLockTTL = 15 * time.Minute
loginMaxFailureCount = 5
) )
type Service struct { type Service struct {
@@ -202,3 +214,189 @@ func PublicPhone(phone string) string {
} }
return fmt.Sprintf("%s****%s", phone[:3], phone[len(phone)-4:]) 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)
}
+4
View File
@@ -371,12 +371,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
authRoutes.GET("/captcha", authHandler.Captcha) authRoutes.GET("/captcha", authHandler.Captcha)
authRoutes.POST("/sms/send", authHandler.SendSMS) authRoutes.POST("/sms/send", authHandler.SendSMS)
authRoutes.POST("/sms/login", authHandler.Login) 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("/refresh", authHandler.Refresh)
authRoutes.POST("/logout", authHandler.Logout) authRoutes.POST("/logout", authHandler.Logout)
} }
api.GET("/me", requireAuth, userHandler.Me) api.GET("/me", requireAuth, userHandler.Me)
api.PUT("/me", requireAuth, userHandler.UpdateMe) api.PUT("/me", requireAuth, userHandler.UpdateMe)
api.PUT("/password", requireAuth, authHandler.SetPassword)
listingRoutes := api.Group("/listings") 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;
+25
View File
@@ -53,6 +53,31 @@ export async function loginWithSms(phone: string, code: string) {
return data.data 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() { export async function fetchMe() {
const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me') const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me')
return data.data return data.data
+236 -66
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { readError } from '@/shared/utils/error' import { readError } from '@/shared/utils/error'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ChatLineRound, Iphone, Key } from '@element-plus/icons-vue' import { ChatLineRound, Iphone, Key, Lock } from '@element-plus/icons-vue'
import { onMounted, onUnmounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { fetchAuthCaptcha, sendSmsCode, type AuthCaptcha } from '@/features/auth/api/auth' import { fetchAuthCaptcha, sendSmsCode, type AuthCaptcha } from '@/features/auth/api/auth'
@@ -15,12 +15,19 @@ const sending = ref(false)
const captchaLoading = ref(false) const captchaLoading = ref(false)
const captcha = ref<AuthCaptcha | null>(null) const captcha = ref<AuthCaptcha | null>(null)
const countDown = ref(0) const countDown = ref(0)
const topTab = ref<'login' | 'register'>('login')
const loginMode = ref<'password' | 'sms'>('password')
const form = reactive({ const form = reactive({
phone: '', phone: '',
captchaCode: '', captchaCode: '',
code: '', 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 let timer: ReturnType<typeof setInterval> | null = null
function startCountDown() { function startCountDown() {
@@ -43,6 +50,20 @@ onUnmounted(() => {
onMounted(loadCaptcha) 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() { async function loadCaptcha() {
captchaLoading.value = true captchaLoading.value = true
try { try {
@@ -78,9 +99,7 @@ async function handleSendCode() {
} }
async function refreshCaptcha() { async function refreshCaptcha() {
if (captchaLoading.value) { if (captchaLoading.value) return
return
}
await loadCaptcha() await loadCaptcha()
} }
@@ -96,6 +115,59 @@ async function handleLogin() {
loading.value = false 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> </script>
<template> <template>
@@ -120,78 +192,104 @@ async function handleLogin() {
<div class="login-right"> <div class="login-right">
<div class="login-header"> <div class="login-header">
<p class="eyebrow">SMS Login</p> <p class="eyebrow">{{ modeEyebrow }}</p>
<h1>欢迎回来</h1> <h1>{{ modeTitle }}</h1>
<p class="subtitle">登录后即可发布或租赁账号</p> <p class="subtitle">{{ modeSubtitle }}</p>
</div> </div>
<el-form class="user-form" label-position="top" @submit.prevent> <div class="login-tabs">
<el-form-item label="手机号"> <button
<el-input :class="{ active: topTab === 'login' }"
v-model="form.phone" @click="switchTab('login')"
maxlength="11" >登录</button>
placeholder="请输入手机号" <button
size="large" :class="{ active: topTab === 'register' }"
:prefix-icon="Iphone" @click="switchTab('register')"
/> >注册</button>
</el-form-item> </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="图形验证码"> <el-form-item label="图形验证码">
<div class="user-captcha-row"> <div class="user-captcha-row">
<el-input <el-input v-model="form.captchaCode" maxlength="4" placeholder="请输入图形验证码" size="large" :prefix-icon="Key" @keyup.enter="handleSendCode" />
v-model="form.captchaCode" <button class="user-captcha-image" type="button" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="refreshCaptcha">
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="图形验证码" /> <img v-if="captcha" :src="captcha.image" alt="图形验证码" />
<span v-else>刷新</span> <span v-else>刷新</span>
</button> </button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="验证码"> <el-form-item label="验证码">
<div class="user-code-row"> <div class="user-code-row">
<el-input <el-input v-model="form.code" maxlength="6" placeholder="6 位验证码" size="large" :prefix-icon="ChatLineRound" @keyup.enter="handleLogin" />
v-model="form.code" <el-button size="large" :disabled="countDown > 0 || sending" :loading="sending" @click="handleSendCode">
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 ? '发送中' : '发送验证码' }} {{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
</el-button> </el-button>
</div> </div>
</el-form-item> </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> <p class="login-notice">未收到验证码时请稍后重试或联系客服处理</p>
</el-form> </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>
</div> </div>
</section> </section>
@@ -287,16 +385,16 @@ async function handleLogin() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 36px 32px; padding: 32px 32px;
background: rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, 0.45);
} }
.login-header { .login-header {
margin-bottom: 24px; margin-bottom: 16px;
} }
.login-header .eyebrow { .login-header .eyebrow {
margin: 0 0 8px; margin: 0 0 6px;
color: #ff6a00; color: #ff6a00;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -313,11 +411,79 @@ async function handleLogin() {
} }
.login-header .subtitle { .login-header .subtitle {
margin: 8px 0 0; margin: 6px 0 0;
color: #64748b; color: #64748b;
font-size: 13px; 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) { .user-form :deep(.el-form-item__label) {
color: #334155; color: #334155;
font-size: 13px; font-size: 13px;
@@ -425,7 +591,7 @@ async function handleLogin() {
border-radius: 10px; border-radius: 10px;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
margin-top: 8px; margin-top: 4px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
background: linear-gradient(135deg, #ff6a00, #ff8a1f); background: linear-gradient(135deg, #ff6a00, #ff8a1f);
border: none; border: none;
@@ -440,13 +606,17 @@ async function handleLogin() {
} }
.login-notice { .login-notice {
margin: 18px 0 0; margin: 14px 0 0;
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
} }
.is-error :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px #ef4444 inset !important;
}
/* ========== 响应式 ========== */ /* ========== 响应式 ========== */
@media (max-width: 860px) { @media (max-width: 860px) {
.login-card { .login-card {
@@ -457,7 +627,7 @@ async function handleLogin() {
display: none; display: none;
} }
.login-right { .login-right {
padding: 32px 28px; padding: 28px 24px;
} }
.login-shell { .login-shell {
padding: 24px 16px; padding: 24px 16px;
@@ -15,16 +15,29 @@ const loading = ref(false)
const agreed = ref(false) const agreed = ref(false)
const captchaLoading = ref(false) const captchaLoading = ref(false)
const captcha = ref<AuthCaptcha | null>(null) const captcha = ref<AuthCaptcha | null>(null)
const loginMode = ref<'sms' | 'password'>('password')
const form = reactive({ const form = reactive({
phone: '', phone: '',
captchaCode: '', captchaCode: '',
code: '', code: '',
password: '',
}) })
const { countDown, sending, handleSendCode } = useSmsCountdown() const { countDown, sending, handleSendCode } = useSmsCountdown()
onMounted(loadCaptcha) onMounted(loadCaptcha)
function switchToPassword() {
loginMode.value = 'password'
form.captchaCode = ''
form.code = ''
}
function switchToSms() {
loginMode.value = 'sms'
form.password = ''
}
async function loadCaptcha() { async function loadCaptcha() {
captchaLoading.value = true captchaLoading.value = true
try { try {
@@ -67,6 +80,30 @@ async function handleLogin() {
loading.value = false 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> </script>
<template> <template>
@@ -84,56 +121,74 @@ async function handleLogin() {
</div> </div>
<div class="form-section"> <div class="form-section">
<h1>登录</h1> <h1>{{ loginMode === 'sms' ? '登录' : '密码登录' }}</h1>
<label class="auth-input-row"> <label class="auth-input-row">
<input v-model="form.phone" type="tel" maxlength="11" placeholder="手机号" /> <input v-model="form.phone" type="tel" maxlength="11" placeholder="手机号" />
</label> </label>
<label class="auth-input-row captcha-row"> <!-- 短信登录 -->
<input <template v-if="loginMode === 'sms'">
v-model="form.captchaCode" <label class="auth-input-row captcha-row">
type="text" <input
maxlength="4" v-model="form.captchaCode"
autocomplete="off" type="text"
placeholder="图形验证码" maxlength="4"
/> autocomplete="off"
<button placeholder="图形验证码"
class="captcha-image-button" />
type="button" <button
:disabled="captchaLoading" class="captcha-image-button"
@click="loadCaptcha" type="button"
> :disabled="captchaLoading"
<img v-if="captcha" :src="captcha.image" alt="图形验证码" /> @click="loadCaptcha"
<span v-else>刷新</span>
</button>
</label>
<label class="auth-input-row code-row">
<input
v-model="form.code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="验证码"
/>
<span class="code-action">
<van-button
size="small"
plain
:disabled="sending || countDown > 0"
:loading="sending"
class="code-btn"
@click="sendCode"
> >
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }} <img v-if="captcha" :src="captcha.image" alt="图形验证码" />
</van-button> <span v-else>刷新</span>
</span> </button>
</label> </label>
<label class="auth-input-row code-row">
<input
v-model="form.code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="验证码"
/>
<span class="code-action">
<van-button
size="small"
plain
:disabled="sending || countDown > 0"
:loading="sending"
class="code-btn"
@click="sendCode"
>
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
</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"> <div class="assist-row">
<span>验证码登录</span> <span v-if="loginMode === 'sms'">验证码登录</span>
<RouterLink to="/m/register">没有账号<b>立即注册</b></RouterLink> <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>
<div class="agreement-row"> <div class="agreement-row">
@@ -143,6 +198,7 @@ async function handleLogin() {
</div> </div>
<van-button <van-button
v-if="loginMode === 'sms'"
type="primary" type="primary"
block block
class="primary-button" class="primary-button"
@@ -152,8 +208,19 @@ async function handleLogin() {
> >
登录 登录
</van-button> </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> <RouterLink to="/m/register" class="secondary-entry">还没有账号创建一个</RouterLink>
</div> </div>
</section> </section>
</main> </main>
@@ -196,215 +263,169 @@ async function handleLogin() {
.auth-body { .auth-body {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
min-height: 100dvh;
flex-direction: column; flex-direction: column;
justify-content: center; align-items: center;
padding: 54px 34px 28px; padding: 100px 24px 48px;
min-height: 100dvh;
} }
.brand-lockup { .brand-lockup {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
margin: 0 auto clamp(34px, 6vh, 54px); margin-bottom: 36px;
} }
.brand-logo { .brand-logo {
display: grid; display: grid;
width: 46px; width: 44px;
height: 46px; height: 44px;
place-items: center; place-items: center;
border-radius: 14px; border-radius: 12px;
background: linear-gradient(135deg, #ff6a00, #ff914d); background: linear-gradient(135deg, #ff6a00, #ff914d);
box-shadow: 0 14px 32px rgba(255, 106, 0, 0.2); box-shadow: 0 10px 28px rgba(255, 106, 0, 0.24);
color: #ffffff; color: #fff;
font-size: 19px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.brand-lockup strong { .brand-lockup strong {
display: block; color: #0f172a;
color: #111827; font-size: 20px;
font-size: 18px; font-weight: 800;
font-weight: 900;
letter-spacing: 0;
} }
.form-section { .form-section {
width: min(100%, 420px); width: 100%;
margin: 0 auto; max-width: 400px;
} }
.form-section h1 { .form-section h1 {
margin: 0 0 22px; margin: 0 0 28px;
color: #05070a; color: #0f172a;
font-size: 22px; font-size: 26px;
font-weight: 900; font-weight: 800;
line-height: 1.2;
} }
.auth-input-row { .auth-input-row {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 48px; margin-bottom: 14px;
margin-bottom: 10px; border: 1px solid #e2e8f0;
padding: 0 16px; border-radius: 12px;
border: 1px solid #e1e5eb; background: #ffffff;
border-radius: 10px; overflow: hidden;
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;
} }
.auth-input-row input { .auth-input-row input {
width: 100%; flex: 1;
min-width: 0; min-width: 0;
border: none; border: none;
background: transparent; padding: 14px 16px;
color: #0f172a; color: #0f172a;
font-size: 16px; font-size: 16px;
background: transparent;
outline: none; outline: none;
} }
.auth-input-row input::placeholder { .auth-input-row input::placeholder {
color: #b6beca; color: #94a3b8;
} }
.code-action { .captcha-row {
display: flex; padding-right: 4px;
justify-content: flex-end;
} }
.captcha-image-button { .captcha-image-button {
display: grid; display: grid;
flex: 0 0 116px;
width: 116px;
height: 40px;
place-items: center; place-items: center;
overflow: hidden; min-width: 110px;
border: 1px solid #dbe5f0; height: 44px;
border: none;
border-radius: 8px; border-radius: 8px;
background: #fff7ed; background: #fff7ed;
color: #ff6a00; color: #ff6a00;
font-size: 12px; cursor: pointer;
font-weight: 800; font-size: 13px;
font-weight: 700;
padding: 0; padding: 0;
overflow: hidden;
} }
.captcha-image-button:disabled { .captcha-image-button:disabled {
opacity: 0.65; cursor: wait;
opacity: 0.7;
} }
.captcha-image-button img { .captcha-image-button img {
display: block; display: block;
width: 120px; width: 110px;
height: 40px; height: 44px;
object-fit: cover; }
.code-row {
padding-right: 4px;
}
.code-action {
min-width: 110px;
padding: 4px;
} }
.code-btn { .code-btn {
min-width: 82px; width: 100%;
height: 32px; font-size: 12px !important;
border-color: #ff6a00 !important;
border-radius: 8px;
color: #ff6a00 !important;
font-weight: 700;
} }
.assist-row { .assist-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 14px; gap: 12px;
margin: 14px 0 24px; margin: 8px 0 12px;
color: #111827;
font-size: 13px; font-size: 13px;
color: #64748b;
flex-wrap: wrap;
} }
.assist-row span { .assist-row a,
min-width: 0; .link-btn {
color: #ff6a00; color: #ff6a00;
}
.assist-row a {
flex-shrink: 0;
color: #22252b;
text-decoration: none; text-decoration: none;
} font-size: 13px;
font-weight: 700;
.assist-row b { background: none;
color: #05070a; border: none;
font-weight: 900; padding: 0;
cursor: pointer;
} }
.agreement-row { .agreement-row {
display: flex; margin: 16px 0 20px;
align-items: center; font-size: 13px;
margin: 0 0 20px; color: #475569;
} }
.agreement-row :deep(.van-checkbox__label) { .agreement-row b {
margin-left: 8px;
color: #252b36;
font-size: 12px;
line-height: 1.4;
}
.agreement-row :deep(.van-checkbox__label b) {
color: #ff6a00; color: #ff6a00;
font-weight: 700;
} }
.primary-button { .primary-button {
height: 48px; height: 50px;
border: none !important; border-radius: 12px;
border-radius: 10px !important;
background: linear-gradient(135deg, #ff6a00, #ff8a1f) !important;
box-shadow: 0 12px 24px rgba(255, 106, 0, 0.22);
font-size: 17px; 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 { .secondary-entry {
display: grid; display: block;
min-height: 46px; margin-top: 18px;
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;
text-align: center; text-align: center;
color: #94a3b8;
font-size: 14px;
text-decoration: none; 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> </style>
@@ -16,6 +16,7 @@ import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/
import { formatDateMinute } from '@/shared/utils/time' import { formatDateMinute } from '@/shared/utils/time'
import { uploadFile } from '@/shared/api/files' import { uploadFile } from '@/shared/api/files'
import { formatCent } from '@/shared/utils/money' import { formatCent } from '@/shared/utils/money'
import { setPassword } from '@/features/auth/api/auth'
const session = useSessionStore() const session = useSessionStore()
const router = useRouter() const router = useRouter()
@@ -36,6 +37,15 @@ const profileForm = reactive({
const avatarFileInput = ref<HTMLInputElement | null>(null) const avatarFileInput = ref<HTMLInputElement | null>(null)
const uploadingAvatar = ref(false) const uploadingAvatar = ref(false)
// 修改密码
const showPasswordEditor = ref(false)
const savingPassword = ref(false)
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
function triggerAvatarUpload() { function triggerAvatarUpload() {
avatarFileInput.value?.click() 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() { function confirmLogout() {
showSettings.value = false showSettings.value = false
showDialog({ showDialog({
@@ -387,6 +430,7 @@ function resolveAvatarURL(url: string | undefined | null) {
</van-cell> </van-cell>
<van-cell title="公告中心" icon="volume-o" is-link to="/m/announcements" /> <van-cell title="公告中心" icon="volume-o" is-link to="/m/announcements" />
<van-cell title="资料修改" icon="edit" is-link @click="openProfileEditor" /> <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"> <van-cell title="实名认证" icon="idcard" is-link to="/m/realname">
<template #value> <template #value>
<span :class="isRealnameVerified ? 'verified-color' : 'unverified-color'"> <span :class="isRealnameVerified ? 'verified-color' : 'unverified-color'">
@@ -568,6 +612,65 @@ function resolveAvatarURL(url: string | undefined | null) {
</section> </section>
</van-popup> </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 /> <MobileBottomNav />
</main> </main>
</template> </template>
@@ -1156,6 +1259,13 @@ function resolveAvatarURL(url: string | undefined | null) {
margin-bottom: 12px; margin-bottom: 12px;
} }
.password-hint {
margin: 8px 0 14px;
color: #8b9cb5;
font-size: 12px;
line-height: 1.5;
}
.editor-field span { .editor-field span {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
@@ -19,6 +19,8 @@ const form = reactive({
phone: '', phone: '',
captchaCode: '', captchaCode: '',
code: '', code: '',
password: '',
confirmPassword: '',
inviteCode: '', inviteCode: '',
}) })
@@ -57,10 +59,22 @@ async function handleRegister() {
}) })
return 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 loading.value = true
try { 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' const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/m/profile'
await router.replace(redirect) await router.replace(redirect)
} catch { } catch {
@@ -139,6 +153,24 @@ async function handleRegister() {
</span> </span>
</label> </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"> <label class="auth-input-row">
<input v-model="form.inviteCode" maxlength="16" placeholder="邀请码(选填)" /> <input v-model="form.inviteCode" maxlength="16" placeholder="邀请码(选填)" />
</label> </label>
+93 -221
View File
@@ -3,19 +3,11 @@ import { readError } from '@/shared/utils/error'
import { import {
Camera, Camera,
CircleCheckFilled, CircleCheckFilled,
CirclePlus,
Coin,
EditPen, EditPen,
Finished, Lock,
Goods,
Postcard, Postcard,
RefreshRight, RefreshRight,
Shop,
Tickets,
User, User,
Van,
VideoPlay,
Wallet,
WarningFilled, WarningFilled,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -25,6 +17,7 @@ import { useRouter } from 'vue-router'
import { uploadFile } from '@/shared/api/files' import { uploadFile } from '@/shared/api/files'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/shared/utils/statusLabels' import { realnameStatusLabel } from '@/shared/utils/statusLabels'
import { setPassword } from '@/features/auth/api/auth'
const session = useSessionStore() const session = useSessionStore()
const router = useRouter() const router = useRouter()
@@ -35,37 +28,13 @@ const form = reactive({
nickname: '', nickname: '',
avatar_url: '', avatar_url: '',
}) })
const buyerServices = [ const showPasswordDialog = ref(false)
{ const savingPassword = ref(false)
label: '待支付', const passwordForm = reactive({
icon: Coin, oldPassword: '',
tone: 'warning', newPassword: '',
to: { path: '/orders', query: { tab: 'pending_payment' } }, confirmPassword: '',
}, })
{
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 displayName = computed(() => session.displayName) const displayName = computed(() => session.displayName)
const maskedPhone = computed(() => { const maskedPhone = computed(() => {
@@ -145,6 +114,39 @@ async function saveProfile() {
saving.value = false 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> </script>
<template> <template>
@@ -261,56 +263,55 @@ async function saveProfile() {
<span>查看实名认证</span> <span>查看实名认证</span>
<el-icon><CircleCheckFilled /></el-icon> <el-icon><CircleCheckFilled /></el-icon>
</button> </button>
<button class="realname-shortcut" type="button" @click="openPasswordDialog">
<span>修改密码</span>
<el-icon><Lock /></el-icon>
</button>
</aside> </aside>
</div> </div>
<div class="service-panels"> <el-dialog
<section class="service-panel"> v-model="showPasswordDialog"
<div class="service-head"> title="修改密码"
<h2>买家服务</h2> width="420px"
<RouterLink class="service-all" :to="{ path: '/orders', query: { tab: 'all' } }"> :close-on-click-modal="false"
<span>全部订单</span> >
<el-icon><Tickets /></el-icon> <p class="password-hint">未设置过密码的老用户"原密码"可留空直接设置新密码</p>
</RouterLink> <el-form label-position="top">
</div> <el-form-item label="原密码">
<div class="service-grid buyer-grid"> <el-input
<RouterLink v-model="passwordForm.oldPassword"
v-for="item in buyerServices" type="password"
:key="item.label" maxlength="20"
class="service-item" placeholder="未设置过密码可留空"
:to="item.to" show-password
> />
<span class="service-icon" :class="`is-${item.tone}`"> </el-form-item>
<el-icon><component :is="item.icon" /></el-icon> <el-form-item label="新密码">
</span> <el-input
<strong>{{ item.label }}</strong> v-model="passwordForm.newPassword"
</RouterLink> type="password"
</div> maxlength="20"
</section> placeholder="8-20 位"
show-password
<section class="service-panel"> />
<div class="service-head"> </el-form-item>
<h2>卖家服务</h2> <el-form-item label="确认新密码">
<RouterLink class="service-all" to="/seller/listings"> <el-input
<span>管理发布</span> v-model="passwordForm.confirmPassword"
<el-icon><Goods /></el-icon> type="password"
</RouterLink> maxlength="20"
</div> placeholder="再次输入新密码"
<div class="service-grid seller-grid"> show-password
<RouterLink @keyup.enter="savePassword"
v-for="item in sellerServices" />
:key="item.label" </el-form-item>
class="service-item" </el-form>
:to="item.to" <template #footer>
> <el-button @click="showPasswordDialog = false">取消</el-button>
<span class="service-icon" :class="`is-${item.tone}`"> <el-button type="primary" :loading="savingPassword" @click="savePassword">保存密码</el-button>
<el-icon><component :is="item.icon" /></el-icon> </template>
</span> </el-dialog>
<strong>{{ item.label }}</strong>
</RouterLink>
</div>
</section>
</div>
</section> </section>
</template> </template>
@@ -623,145 +624,16 @@ async function saveProfile() {
color: #ff6a00; color: #ff6a00;
} }
.service-panels { .password-hint {
display: grid; margin: 0 0 12px;
grid-template-columns: repeat(2, minmax(0, 1fr)); color: #8b9cb5;
gap: 22px; font-size: 13px;
margin-top: 22px; line-height: 1.6;
}
.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;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.profile-layout { .profile-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.service-panels {
grid-template-columns: 1fr;
}
} }
</style> </style>
+11
View File
@@ -7,6 +7,7 @@ import {
getRefreshToken, getRefreshToken,
setAuthTokens, setAuthTokens,
} from '@/shared/utils/authStorage' } from '@/shared/utils/authStorage'
import { loginWithPassword, registerWithPassword } from '@/features/auth/api/auth'
export const useSessionStore = defineStore('session', { export const useSessionStore = defineStore('session', {
state: () => ({ state: () => ({
@@ -31,6 +32,16 @@ export const useSessionStore = defineStore('session', {
this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token) this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token)
return result 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() { async loadMe() {
if (this._loadingMe) { if (this._loadingMe) {
// 如果正在加载,等待完成 // 如果正在加载,等待完成