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
@@ -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;