351 lines
16 KiB
Vue
351 lines
16 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 {
|
||
Building2,
|
||
CheckCircle2,
|
||
KeyRound,
|
||
Pencil,
|
||
Save,
|
||
ShieldCheck,
|
||
UserRound,
|
||
X,
|
||
} from '@lucide/vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { changeCurrentPassword } from '@/api/auth'
|
||
import {
|
||
getPasswordStrength,
|
||
validateEditableAccountProfile,
|
||
validatePasswordChange,
|
||
type PasswordChangePayload,
|
||
} from '@/utils/accountProfile'
|
||
|
||
type AccountTab = 'profile' | '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 passwordSaving = ref(false)
|
||
const passwordChangedAt = ref('')
|
||
const profileFormRef = ref<FormInstance>()
|
||
const passwordFormRef = ref<FormInstance>()
|
||
|
||
const profileForm = reactive<ProfileForm>({ ...auth.profile })
|
||
const passwordForm = reactive<PasswordChangePayload>({
|
||
currentPassword: '',
|
||
newPassword: '',
|
||
confirmPassword: '',
|
||
})
|
||
|
||
const profileRules: FormRules<ProfileForm> = {
|
||
realName: [
|
||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||
{ min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' },
|
||
],
|
||
phone: [
|
||
{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' },
|
||
],
|
||
email: [
|
||
{ type: 'email', 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) => {
|
||
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(),
|
||
phone: profileForm.phone.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 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-identity">
|
||
<div class="account-avatar" aria-hidden="true"><UserRound :size="28" /></div>
|
||
<div class="account-identity-copy">
|
||
<h2>{{ auth.profile.realName }}</h2>
|
||
<p>{{ auth.profile.username }}</p>
|
||
</div>
|
||
<div class="account-context">
|
||
<span><Building2 :size="16" />{{ auth.profile.tenantName }}</span>
|
||
<span><ShieldCheck :size="16" />{{ auth.user?.roles.join('、') }}</span>
|
||
</div>
|
||
<el-tag type="success" effect="plain"><CheckCircle2 :size="14" />账号正常</el-tag>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<header class="account-section-heading">
|
||
<div><h2>基本信息</h2><p>维护当前登录人的联系方式;账号和所属企业由系统身份关系确定。</p></div>
|
||
<el-button v-if="!editing" type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
|
||
</header>
|
||
|
||
<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 /></el-form-item>
|
||
<el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></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="password">
|
||
<template #label><span class="account-tab-label"><KeyRound :size="16" />修改密码</span></template>
|
||
|
||
<div class="password-layout">
|
||
<div>
|
||
<header class="account-section-heading password-heading">
|
||
<div><h2>修改密码</h2><p>输入当前密码并设置 8–64 位、同时包含字母和数字的新密码。</p></div>
|
||
</header>
|
||
<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-identity { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 18px 22px; }
|
||
.account-avatar { display: grid; width: 52px; height: 52px; flex: 0 0 52px; place-items: center; border-radius: 14px; background: #e7f3f1; color: var(--primary); }
|
||
.account-identity-copy { min-width: 140px; }
|
||
.account-identity h2 { margin: 0 0 4px; color: var(--navy); font-size: 18px; }
|
||
.account-identity p { margin: 0; color: var(--muted); font-size: 12px; }
|
||
.account-context { display: flex; min-width: 0; flex: 1; gap: 22px; color: #52616b; font-size: 12px; }
|
||
.account-context span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-wrap: anywhere; }
|
||
.account-identity :deep(.el-tag) { display: flex; align-items: center; gap: 5px; }
|
||
.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-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 24px 0 20px; }
|
||
.account-section-heading h2 { margin: 0 0 6px; color: var(--navy); font-size: 18px; }
|
||
.account-section-heading p { max-width: 620px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||
.account-actions { display: flex; gap: 8px; }
|
||
.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; }
|
||
.password-layout { display: grid; grid-template-columns: minmax(320px, 520px) minmax(240px, 1fr); align-items: start; gap: clamp(36px, 7vw, 90px); }
|
||
.password-heading { padding-bottom: 18px; }
|
||
.password-form { max-width: 440px; }
|
||
.password-form :deep(.el-form-item) { margin-bottom: 20px; }
|
||
.password-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
||
.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: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; 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: 24px; 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) {
|
||
.account-context { align-items: flex-start; flex-direction: column; gap: 7px; }
|
||
.password-layout { grid-template-columns: 1fr; gap: 14px; }
|
||
.security-note { margin-top: 0; }
|
||
}
|
||
|
||
@media (max-width: 620px) {
|
||
.account-identity { align-items: flex-start; flex-wrap: wrap; }
|
||
.account-identity-copy { flex: 1; }
|
||
.account-context { order: 3; width: 100%; flex-basis: 100%; padding-top: 12px; border-top: 1px solid var(--border); }
|
||
.account-workspace { min-height: 0; padding: 0 16px 22px; }
|
||
.account-section-heading { align-items: stretch; flex-direction: column; gap: 14px; padding-top: 20px; }
|
||
.account-section-heading > .el-button { width: 100%; }
|
||
.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; }
|
||
}
|
||
</style>
|