Files
client-web/src/modules/account/views/AccountProfileView.vue
T
2026-09-02 10:04:21 +08:00

397 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>