Files
client-web/src/modules/account/views/AccountProfileView.vue
T

397 lines
18 KiB
Vue
Raw Normal View History

2026-07-30 17:59:42 +08:00
<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 {
2026-08-03 10:51:53 +08:00
CheckCircle2,
2026-07-30 17:59:42 +08:00
KeyRound,
Pencil,
Save,
ShieldCheck,
2026-08-03 10:51:53 +08:00
UserRound,
2026-07-30 17:59:42 +08:00
X,
} from '@lucide/vue'
2026-08-03 18:52:30 +08:00
import { changeCurrentPassword, useAuthStore } from '@/modules/auth/public'
import { ApiError } from '@/api/http'
import { updateMerchantLoginIdentifiers } from '@/modules/tenant-management/public'
2026-07-30 17:59:42 +08:00
import {
getPasswordStrength,
validateEditableAccountProfile,
validateLoginIdentifierChange,
2026-07-30 17:59:42 +08:00
validatePasswordChange,
type LoginIdentifierChangePayload,
2026-07-30 17:59:42 +08:00
type PasswordChangePayload,
2026-08-03 18:52:30 +08:00
} from '@/modules/account/utils/accountProfile'
2026-07-30 17:59:42 +08:00
type AccountTab = 'profile' | 'identifiers' | 'password'
2026-07-30 17:59:42 +08:00
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('')
2026-07-30 17:59:42 +08:00
const passwordSaving = ref(false)
const passwordChangedAt = ref('')
const profileFormRef = ref<FormInstance>()
const identifiersFormRef = ref<FormInstance>()
2026-07-30 17:59:42 +08:00
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: '',
})
2026-07-30 17:59:42 +08:00
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' }],
}
2026-07-30 17:59:42 +08:00
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) => {
2026-08-03 18:15:34 +08:00
if (!value) {
callback(new Error('请输入新密码'))
return
}
2026-07-30 17:59:42 +08:00
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 = ''
2026-09-02 10:04:21 +08:00
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
}
}
2026-07-30 17:59:42 +08:00
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>
2026-08-03 10:51:53 +08:00
<div v-if="!editing" class="account-actions">
<el-button type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
</div>
2026-07-30 17:59:42 +08:00
<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>
2026-07-30 17:59:42 +08:00
<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>
2026-09-02 10:04:21 +08:00
<div><dt>登录手机号</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
2026-07-30 17:59:42 +08:00
<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">
2026-09-02 10:04:21 +08:00
<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>
2026-09-02 10:04:21 +08:00
<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>
2026-07-30 17:59:42 +08:00
<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; }
2026-08-03 10:51:53 +08:00
.account-actions { display: flex; justify-content: flex-end; gap: 8px; margin: 20px 0; }
2026-07-30 17:59:42 +08:00
.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); }
2026-08-03 18:15:34 +08:00
.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; }
2026-07-30 17:59:42 +08:00
.password-form { max-width: 440px; }
2026-08-03 18:15:34 +08:00
.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); }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-03 18:15:34 +08:00
.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); }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-03 18:15:34 +08:00
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 0; padding: 22px; background: #f2f7f6; color: var(--primary); }
2026-07-30 17:59:42 +08:00
.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) {
2026-08-03 18:15:34 +08:00
.password-layout { grid-template-columns: 1fr; gap: 24px; padding-top: 18px; }
2026-07-30 17:59:42 +08:00
.security-note { margin-top: 0; }
}
@media (max-width: 620px) {
.account-workspace { min-height: 0; padding: 0 16px 22px; }
2026-08-03 10:51:53 +08:00
.account-actions { width: 100%; margin: 16px 0; }
.account-actions .el-button { width: 100%; min-height: 44px; }
2026-07-30 17:59:42 +08:00
.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; }
2026-07-30 17:59:42 +08:00
}
</style>