397 lines
18 KiB
Vue
397 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import type { FormInstance, FormRules } from 'element-plus'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
CheckCircle2,
|
||
KeyRound,
|
||
Pencil,
|
||
Save,
|
||
ShieldCheck,
|
||
UserRound,
|
||
X,
|
||
} from '@lucide/vue'
|
||
import { changeCurrentPassword, useAuthStore } from '@/modules/auth/public'
|
||
import { ApiError } from '@/api/http'
|
||
import { updateMerchantLoginIdentifiers } from '@/modules/tenant-management/public'
|
||
import {
|
||
getPasswordStrength,
|
||
validateEditableAccountProfile,
|
||
validateLoginIdentifierChange,
|
||
validatePasswordChange,
|
||
type LoginIdentifierChangePayload,
|
||
type PasswordChangePayload,
|
||
} from '@/modules/account/utils/accountProfile'
|
||
|
||
type AccountTab = 'profile' | 'identifiers' | 'password'
|
||
|
||
interface ProfileForm {
|
||
username: string
|
||
realName: string
|
||
phone: string
|
||
email: string
|
||
tenantName: string
|
||
}
|
||
|
||
const auth = useAuthStore()
|
||
const router = useRouter()
|
||
const activeTab = ref<AccountTab>('profile')
|
||
const editing = ref(false)
|
||
const profileSaving = ref(false)
|
||
const identifiersSaving = ref(false)
|
||
const identifiersError = ref('')
|
||
const passwordSaving = ref(false)
|
||
const passwordChangedAt = ref('')
|
||
const profileFormRef = ref<FormInstance>()
|
||
const identifiersFormRef = ref<FormInstance>()
|
||
const passwordFormRef = ref<FormInstance>()
|
||
|
||
const profileForm = reactive<ProfileForm>({ ...auth.profile })
|
||
const passwordForm = reactive<PasswordChangePayload>({
|
||
currentPassword: '',
|
||
newPassword: '',
|
||
confirmPassword: '',
|
||
})
|
||
const identifiersForm = reactive<LoginIdentifierChangePayload>({
|
||
username: auth.profile.username,
|
||
phone: auth.profile.phone,
|
||
currentPassword: '',
|
||
})
|
||
|
||
const profileRules: FormRules<ProfileForm> = {
|
||
realName: [
|
||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||
{ min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' },
|
||
],
|
||
email: [
|
||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
|
||
],
|
||
}
|
||
|
||
const identifierRules: FormRules<LoginIdentifierChangePayload> = {
|
||
username: [{
|
||
required: true,
|
||
pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/,
|
||
message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成',
|
||
trigger: 'blur',
|
||
}],
|
||
phone: [{
|
||
required: true,
|
||
pattern: /^1[3-9]\d{9}$/,
|
||
message: '请输入有效的中国大陆手机号',
|
||
trigger: 'blur',
|
||
}],
|
||
currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
|
||
}
|
||
|
||
const passwordRules: FormRules<PasswordChangePayload> = {
|
||
currentPassword: [{
|
||
required: true,
|
||
validator: (_rule, value: string, callback) => {
|
||
if (!value) callback(new Error('请输入当前密码'))
|
||
else callback()
|
||
},
|
||
trigger: 'blur',
|
||
}],
|
||
newPassword: [{
|
||
required: true,
|
||
validator: (_rule, value: string, callback) => {
|
||
if (!value) {
|
||
callback(new Error('请输入新密码'))
|
||
return
|
||
}
|
||
const errors = validatePasswordChange({
|
||
currentPassword: passwordForm.currentPassword,
|
||
newPassword: value,
|
||
confirmPassword: value,
|
||
})
|
||
const relevantError = errors.find((error) => error.includes('8–64') || error.includes('同时包含') || error.includes('当前密码相同'))
|
||
if (relevantError) callback(new Error(relevantError))
|
||
else callback()
|
||
},
|
||
trigger: 'blur',
|
||
}],
|
||
confirmPassword: [{
|
||
required: true,
|
||
validator: (_rule, value: string, callback) => {
|
||
if (!value) callback(new Error('请再次输入新密码'))
|
||
else if (value !== passwordForm.newPassword) callback(new Error('两次输入的新密码不一致'))
|
||
else callback()
|
||
},
|
||
trigger: 'blur',
|
||
}],
|
||
}
|
||
|
||
const passwordStrength = computed(() => getPasswordStrength(passwordForm.newPassword))
|
||
const passwordStrengthState = computed(() => passwordForm.newPassword ? passwordStrength.value : 'empty')
|
||
const strengthLabel = computed(() => passwordForm.newPassword
|
||
? ({ weak: '较弱', medium: '中等', strong: '较强' })[passwordStrength.value]
|
||
: '未输入')
|
||
|
||
function syncProfileForm() {
|
||
Object.assign(profileForm, auth.profile)
|
||
profileFormRef.value?.clearValidate()
|
||
}
|
||
|
||
function startEditing() {
|
||
syncProfileForm()
|
||
editing.value = true
|
||
}
|
||
|
||
function cancelEditing() {
|
||
syncProfileForm()
|
||
editing.value = false
|
||
}
|
||
|
||
async function saveProfile() {
|
||
if (profileSaving.value) return
|
||
const valid = await profileFormRef.value?.validate().catch(() => false)
|
||
const errors = validateEditableAccountProfile(profileForm)
|
||
if (!valid || errors.length > 0) return
|
||
|
||
profileSaving.value = true
|
||
try {
|
||
await auth.savePersonalProfile({
|
||
realName: profileForm.realName.trim(),
|
||
email: profileForm.email.trim(),
|
||
})
|
||
syncProfileForm()
|
||
editing.value = false
|
||
ElMessage.success('个人信息已保存')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '个人信息保存失败,请重试')
|
||
} finally {
|
||
profileSaving.value = false
|
||
}
|
||
}
|
||
|
||
function resetIdentifiersForm() {
|
||
Object.assign(identifiersForm, {
|
||
username: auth.profile.username,
|
||
phone: auth.profile.phone,
|
||
currentPassword: '',
|
||
})
|
||
identifiersError.value = ''
|
||
identifiersFormRef.value?.clearValidate()
|
||
}
|
||
|
||
async function changeLoginIdentifiers() {
|
||
if (identifiersSaving.value) return
|
||
identifiersError.value = ''
|
||
const valid = await identifiersFormRef.value?.validate().catch(() => false)
|
||
if (!valid || validateLoginIdentifierChange(identifiersForm).length > 0) return
|
||
identifiersSaving.value = true
|
||
const username = identifiersForm.username.trim()
|
||
try {
|
||
await updateMerchantLoginIdentifiers({
|
||
username,
|
||
phone: identifiersForm.phone.trim(),
|
||
currentPassword: identifiersForm.currentPassword,
|
||
})
|
||
auth.clearSession()
|
||
await router.replace({ path: '/login', query: { account: username, reason: 'identifiers-updated' } })
|
||
} catch (error) {
|
||
identifiersForm.currentPassword = ''
|
||
if (error instanceof ApiError && error.code === 'A0409') identifiersError.value = '用户名或登录手机号已被使用,请更换后重试'
|
||
else if (error instanceof ApiError && error.code === 'A0401') identifiersError.value = '当前密码错误'
|
||
else if (error instanceof ApiError && error.code === 'A0403') identifiersError.value = '当前会话不允许修改登录标识,请退出代登录后重试'
|
||
else identifiersError.value = error instanceof Error ? error.message : '登录标识修改失败,请重试'
|
||
} finally {
|
||
identifiersSaving.value = false
|
||
}
|
||
}
|
||
|
||
function resetPasswordForm() {
|
||
passwordForm.currentPassword = ''
|
||
passwordForm.newPassword = ''
|
||
passwordForm.confirmPassword = ''
|
||
passwordFormRef.value?.clearValidate()
|
||
}
|
||
|
||
async function changePassword() {
|
||
if (passwordSaving.value) return
|
||
const valid = await passwordFormRef.value?.validate().catch(() => false)
|
||
if (!valid || validatePasswordChange(passwordForm).length > 0) return
|
||
|
||
passwordSaving.value = true
|
||
try {
|
||
await changeCurrentPassword(passwordForm.currentPassword, passwordForm.newPassword)
|
||
passwordChangedAt.value = new Date().toLocaleString('zh-CN', { hour12: false })
|
||
resetPasswordForm()
|
||
ElMessage.success('密码已修改,请使用新密码重新登录')
|
||
await auth.logout().catch(() => undefined)
|
||
await router.replace('/login')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '密码修改失败,请重试')
|
||
} finally {
|
||
passwordSaving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="account-page">
|
||
<div class="client-card account-workspace">
|
||
<el-tabs v-model="activeTab" class="account-tabs">
|
||
<el-tab-pane name="profile">
|
||
<template #label><span class="account-tab-label"><UserRound :size="16" />基本信息</span></template>
|
||
|
||
<div v-if="!editing" class="account-actions">
|
||
<el-button type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
|
||
</div>
|
||
|
||
<el-form
|
||
v-if="editing"
|
||
ref="profileFormRef"
|
||
:model="profileForm"
|
||
:rules="profileRules"
|
||
label-position="top"
|
||
class="account-form-grid"
|
||
@submit.prevent="saveProfile"
|
||
>
|
||
<el-form-item label="用户名"><el-input v-model="profileForm.username" disabled /><span class="field-help">请在“登录标识”中修改</span></el-form-item>
|
||
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit placeholder="请输入真实姓名" /></el-form-item>
|
||
<el-form-item label="登录手机号"><el-input v-model="profileForm.phone" disabled /><span class="field-help">请在“登录标识”中修改</span></el-form-item>
|
||
<el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item>
|
||
<el-form-item label="所属公司">
|
||
<el-input v-model="profileForm.tenantName" disabled />
|
||
<span class="field-help">企业归属由平台审核关系确定</span>
|
||
</el-form-item>
|
||
<div class="account-form-actions">
|
||
<el-button :disabled="profileSaving" @click="cancelEditing"><X :size="16" />取消</el-button>
|
||
<el-button type="primary" native-type="submit" :loading="profileSaving"><Save :size="16" />保存修改</el-button>
|
||
</div>
|
||
</el-form>
|
||
|
||
<dl v-else class="account-info-grid">
|
||
<div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div>
|
||
<div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div>
|
||
<div><dt>登录手机号</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
|
||
<div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div>
|
||
<div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div>
|
||
</dl>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane name="identifiers">
|
||
<template #label><span class="account-tab-label"><ShieldCheck :size="16" />登录标识</span></template>
|
||
<div class="identifier-layout">
|
||
<el-form ref="identifiersFormRef" :model="identifiersForm" :rules="identifierRules" label-position="top" class="identifier-form" @submit.prevent="changeLoginIdentifiers">
|
||
<el-alert title="修改成功后,国内与海外的全部登录会话都会立即退出,需要使用新用户名或登录手机号重新登录。" type="warning" :closable="false" show-icon />
|
||
<el-form-item label="用户名" prop="username"><el-input v-model="identifiersForm.username" maxlength="64" autocomplete="username" placeholder="请输入新用户名" /></el-form-item>
|
||
<el-form-item label="登录手机号" prop="phone"><el-input v-model="identifiersForm.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="请输入11位中国大陆手机号" /></el-form-item>
|
||
<el-form-item label="当前密码" prop="currentPassword"><el-input v-model="identifiersForm.currentPassword" type="password" show-password maxlength="72" autocomplete="current-password" placeholder="用于确认是本人操作" /></el-form-item>
|
||
<el-alert v-if="identifiersError" :title="identifiersError" type="error" :closable="false" show-icon />
|
||
<div class="identifier-actions"><el-button :disabled="identifiersSaving" @click="resetIdentifiersForm">重置</el-button><el-button type="primary" native-type="submit" :loading="identifiersSaving"><Save :size="16" />保存并重新登录</el-button></div>
|
||
</el-form>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane name="password">
|
||
<template #label><span class="account-tab-label"><KeyRound :size="16" />修改密码</span></template>
|
||
|
||
<div class="password-layout">
|
||
<div>
|
||
<el-form
|
||
ref="passwordFormRef"
|
||
:model="passwordForm"
|
||
:rules="passwordRules"
|
||
label-position="top"
|
||
class="password-form"
|
||
@submit.prevent="changePassword"
|
||
>
|
||
<el-form-item label="当前密码" prop="currentPassword">
|
||
<el-input v-model="passwordForm.currentPassword" type="password" show-password autocomplete="current-password" placeholder="请输入当前密码" />
|
||
</el-form-item>
|
||
<el-form-item label="新密码" prop="newPassword">
|
||
<el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="请输入新密码" />
|
||
<div class="password-strength" :data-strength="passwordStrengthState">
|
||
<span aria-hidden="true"><i /><i /><i /></span>
|
||
<small>密码强度:{{ strengthLabel }}</small>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||
<el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="请再次输入新密码" />
|
||
</el-form-item>
|
||
<div class="password-actions">
|
||
<el-button :disabled="passwordSaving" @click="resetPasswordForm">清空</el-button>
|
||
<el-button type="primary" native-type="submit" :loading="passwordSaving"><KeyRound :size="16" />确认修改</el-button>
|
||
</div>
|
||
</el-form>
|
||
<div v-if="passwordChangedAt" class="password-result" role="status">
|
||
<CheckCircle2 :size="18" />
|
||
<div><strong>安全记录已更新</strong><p>{{ passwordChangedAt }};服务端已使旧会话失效。</p></div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="security-note">
|
||
<ShieldCheck :size="22" />
|
||
<div><strong>账户安全提示</strong><p>建议组合使用字母、数字和符号,不要与其他网站共用密码。</p></div>
|
||
<p>当前密码仅用于本次服务端校验,前端不会持久化密码。修改成功后需要重新登录。</p>
|
||
</aside>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.account-page { display: flex; flex-direction: column; gap: 14px; }
|
||
.account-workspace { min-height: 520px; padding: 0 24px 28px; }
|
||
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||
.account-tabs :deep(.el-tabs__item) { min-height: 52px; }
|
||
.account-tab-label { display: inline-flex; align-items: center; gap: 7px; }
|
||
.account-actions { display: flex; justify-content: flex-end; gap: 8px; margin: 20px 0; }
|
||
.account-info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
|
||
.account-info-grid div { min-width: 0; min-height: 94px; padding: 18px 20px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||
.account-info-grid dt { color: var(--muted); font-size: 11px; }
|
||
.account-info-grid dd { margin: 9px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; font-weight: 650; }
|
||
.account-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; max-width: 900px; }
|
||
.account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; }
|
||
.account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
||
.field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||
.identifier-layout { max-width: 620px; padding-top: 22px; }
|
||
.identifier-form { display: grid; gap: 2px; }
|
||
.identifier-form > :deep(.el-alert) { margin-bottom: 16px; }
|
||
.identifier-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||
.password-layout { display: grid; grid-template-columns: minmax(0, 440px) minmax(260px, 1fr); align-items: start; max-width: 980px; gap: clamp(32px, 6vw, 72px); padding-top: 22px; }
|
||
.password-form { max-width: 440px; }
|
||
.password-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||
.password-form :deep(.el-form-item__error) { padding-top: 5px; line-height: 1.45; white-space: normal; }
|
||
.password-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||
.password-result { display: flex; align-items: flex-start; gap: 9px; margin-top: 18px; padding: 14px; background: #edf6f4; color: var(--primary); }
|
||
.password-result strong { color: var(--navy); font-size: 12px; }
|
||
.password-result p { margin: 3px 0 0; color: #526b67; font-size: 11px; line-height: 1.6; }
|
||
.password-strength { display: grid; width: 100%; min-height: 18px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-top: 9px; color: var(--muted); }
|
||
.password-strength > span { display: grid; flex: 1; grid-template-columns: repeat(3, 1fr); gap: 4px; }
|
||
.password-strength i { height: 3px; background: #dce3e6; }
|
||
.password-strength[data-strength="weak"] i:first-child { background: #c2413b; }
|
||
.password-strength[data-strength="medium"] i:nth-child(-n+2) { background: #c47913; }
|
||
.password-strength[data-strength="strong"] i { background: var(--primary); }
|
||
.password-strength small { white-space: nowrap; font-size: 10px; }
|
||
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 0; padding: 22px; background: #f2f7f6; color: var(--primary); }
|
||
.security-note strong { color: var(--navy); font-size: 13px; }
|
||
.security-note p { margin: 5px 0 0; color: #526b67; font-size: 11px; line-height: 1.7; }
|
||
.security-note > p { grid-column: 1 / -1; margin-top: 8px; padding-top: 14px; border-top: 1px solid #d7e5e2; }
|
||
.security-note code { color: var(--navy); font-weight: 700; }
|
||
|
||
@media (max-width: 820px) {
|
||
.password-layout { grid-template-columns: 1fr; gap: 24px; padding-top: 18px; }
|
||
.security-note { margin-top: 0; }
|
||
}
|
||
|
||
@media (max-width: 620px) {
|
||
.account-workspace { min-height: 0; padding: 0 16px 22px; }
|
||
.account-actions { width: 100%; margin: 16px 0; }
|
||
.account-actions .el-button { width: 100%; min-height: 44px; }
|
||
.account-form-actions { width: 100%; }
|
||
.account-form-actions .el-button { min-height: 44px; flex: 1; }
|
||
.account-info-grid, .account-form-grid { grid-template-columns: 1fr; }
|
||
.account-info-grid div { min-height: 82px; }
|
||
.password-actions { width: 100%; }
|
||
.password-actions .el-button { min-height: 44px; flex: 1; }
|
||
.identifier-actions .el-button { min-height: 44px; flex: 1; }
|
||
}
|
||
</style>
|