优化构架

This commit is contained in:
2026-08-03 18:52:21 +08:00
parent 3e347dd7a5
commit 35c713a7ca
51 changed files with 0 additions and 0 deletions
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getPasswordStrength,
validateEditableAccountProfile,
validatePasswordChange,
} from './accountProfile.ts'
test('accepts a complete editable account profile', () => {
assert.deepEqual(validateEditableAccountProfile({
realName: '企业管理员',
phone: '13800006601',
email: 'admin@example.com',
}), [])
})
test('reports invalid profile fields together', () => {
const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' })
assert.equal(errors.length, 3)
})
test('validates password length, equality and confirmation', () => {
assert.deepEqual(validatePasswordChange({
currentPassword: 'OldPass123',
newPassword: 'NewPass456',
confirmPassword: 'NewPass456',
}), [])
assert.equal(validatePasswordChange({ currentPassword: '', newPassword: '123', confirmPassword: '456' }).length, 3)
assert.match(validatePasswordChange({ currentPassword: 'SamePass1', newPassword: 'SamePass1', confirmPassword: 'SamePass1' }).join('|'), /不能与当前密码相同/)
assert.match(validatePasswordChange({ currentPassword: 'OldPass1', newPassword: 'abcdefgh', confirmPassword: 'abcdefgh' }).join('|'), /同时包含字母和数字/)
})
test('grades password strength without making extra complexity mandatory', () => {
assert.equal(getPasswordStrength('short'), 'weak')
assert.equal(getPasswordStrength('Password1'), 'medium')
assert.equal(getPasswordStrength('Stronger@2026'), 'strong')
})
@@ -0,0 +1,57 @@
export interface EditableAccountProfile {
realName: string
phone: string
email: string
}
export interface PasswordChangePayload {
currentPassword: string
newPassword: string
confirmPassword: string
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] {
const errors: string[] = []
const realName = profile.realName.trim()
const phone = profile.phone.trim()
const email = profile.email.trim()
if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符')
if (phone.length > 32) errors.push('联系电话不能超过 32 个字符')
if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址')
return errors
}
export function validatePasswordChange(payload: PasswordChangePayload): string[] {
const errors: string[] = []
if (!payload.currentPassword) errors.push('请输入当前密码')
if (payload.newPassword.length < 8 || payload.newPassword.length > 64) {
errors.push('新密码需为 8–64 个字符')
}
if (payload.newPassword.length >= 8 && (!/[a-zA-Z]/.test(payload.newPassword) || !/\d/.test(payload.newPassword))) {
errors.push('新密码必须同时包含字母和数字')
}
if (payload.newPassword && payload.newPassword === payload.currentPassword) {
errors.push('新密码不能与当前密码相同')
}
if (payload.confirmPassword !== payload.newPassword) errors.push('两次输入的新密码不一致')
return errors
}
export type PasswordStrength = 'weak' | 'medium' | 'strong'
export function getPasswordStrength(password: string): PasswordStrength {
let categoryCount = 0
if (/[a-zA-Z]/.test(password)) categoryCount += 1
if (/\d/.test(password)) categoryCount += 1
if (/[^a-zA-Z\d]/.test(password)) categoryCount += 1
if (password.length >= 12 && categoryCount >= 3) return 'strong'
if (password.length >= 8 && categoryCount >= 2) return 'medium'
return 'weak'
}
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
const navigationSource = fs.readFileSync(new URL('../navigation/clientNavigation.ts', import.meta.url), 'utf8')
const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8')
const authSource = fs.readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
const authApiSource = fs.readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
test('registers the account profile route and navigation entry', () => {
assert.match(routerSource, /account\/profile'.*AccountProfileView\.vue/)
assert.match(routerSource, /permission: 'account:profile:view'/)
assert.match(navigationSource, /title: '我的信息'.*path: '\/account\/profile'/)
})
test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /基本信息/)
assert.doesNotMatch(viewSource, /account-identity/)
assert.doesNotMatch(viewSource, /<h2>基本信息<\/h2>|<h2>修改密码<\/h2>/)
assert.match(viewSource, /function startEditing\(\)/)
assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/)
assert.match(viewSource, /账号标识暂不支持在客户端修改/)
assert.match(viewSource, /企业归属由平台审核关系确定/)
assert.match(authSource, /async function savePersonalProfile\(/)
assert.match(viewSource, /await auth\.savePersonalProfile/)
})
test('implements password validation, strength feedback and double-submit protection', () => {
assert.match(viewSource, /async function changePassword\(\)/)
assert.match(viewSource, /validatePasswordChange/)
assert.match(viewSource, /callback\(new Error\('请输入新密码'\)\)/)
assert.match(viewSource, /getPasswordStrength/)
assert.match(viewSource, /passwordStrengthState/)
assert.match(viewSource, /if \(passwordSaving\.value\) return/)
assert.match(viewSource, /await changeCurrentPassword/)
assert.match(authApiSource, /method: 'PUT', url: '\/auth\/password'/)
assert.match(viewSource, /安全记录已更新/)
assert.match(viewSource, /await router\.replace\('\/login'\)/)
assert.doesNotMatch(viewSource, /DEMO_CURRENT_PASSWORD|静态演示/)
})
test('provides explicit responsive account layouts', () => {
assert.match(viewSource, /@media \(max-width: 820px\)/)
assert.match(viewSource, /@media \(max-width: 620px\)/)
assert.match(viewSource, /\.account-info-grid, \.account-form-grid \{ grid-template-columns: 1fr; \}/)
assert.match(viewSource, /min-height: 44px/)
})
@@ -0,0 +1,321 @@
<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 { 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) => {
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(),
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-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="联系电话" 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>
<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; }
.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; }
}
</style>