Files
client-web/src/views/AccountProfileView.vue
T
2026-07-30 17:59:42 +08:00

351 lines
16 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 {
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>