feat(client): align account and order foundations

This commit is contained in:
2026-09-01 19:34:37 +08:00
parent c1e7ba411e
commit 1305ae4669
23 changed files with 602 additions and 258 deletions
@@ -6,6 +6,7 @@ const routerSource = fs.readFileSync(new URL('../routes.ts', import.meta.url), '
const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8')
const authSource = fs.readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8')
const authApiSource = fs.readFileSync(new URL('../../auth/api/auth.ts', import.meta.url), 'utf8')
const iamApiSource = fs.readFileSync(new URL('../../tenant-management/api/merchantIam.ts', import.meta.url), 'utf8')
test('registers the account profile route and navigation entry', () => {
assert.match(routerSource, /path: 'account\/profile'[\s\S]*?component: \(\) => import\('\.\/views\/AccountProfileView\.vue'\)/)
@@ -21,12 +22,25 @@ test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /function startEditing\(\)/)
assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/)
assert.match(viewSource, /账号标识暂不支持在客户端修改/)
assert.match(viewSource, /请在“登录标识”中修改/)
assert.match(viewSource, /企业归属由平台审核关系确定/)
assert.match(authSource, /async function savePersonalProfile\(/)
assert.match(viewSource, /await auth\.savePersonalProfile/)
})
test('changes username and mobile with current-password verification then clears every local session', () => {
assert.match(viewSource, /name="identifiers"/)
assert.match(viewSource, /validateLoginIdentifierChange/)
assert.match(viewSource, /await updateMerchantLoginIdentifiers/)
assert.match(iamApiSource, /url: '\/merchant\/v1\/iam\/profile\/login-identifiers'/)
assert.match(iamApiSource, /data: payload/)
assert.match(viewSource, /error\.code === 'A0409'/)
assert.match(viewSource, /error\.code === 'A0401'/)
assert.match(viewSource, /auth\.clearSession\(\)/)
assert.match(viewSource, /reason: 'identifiers-updated'/)
assert.doesNotMatch(viewSource, /auth\.logout\(\)[\s\S]*identifiers-updated/)
})
test('implements password validation, strength feedback and double-submit protection', () => {
assert.match(viewSource, /async function changePassword\(\)/)
assert.match(viewSource, /validatePasswordChange/)
@@ -13,14 +13,18 @@ import {
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' | 'password'
type AccountTab = 'profile' | 'identifiers' | 'password'
interface ProfileForm {
username: string
@@ -35,9 +39,12 @@ 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 })
@@ -46,20 +53,38 @@ const passwordForm = reactive<PasswordChangePayload>({
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' },
],
phone: [
{ max: 32, message: '联系电话不能超过 32 个字符', 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,
@@ -129,7 +154,6 @@ async function saveProfile() {
try {
await auth.savePersonalProfile({
realName: profileForm.realName.trim(),
phone: profileForm.phone.trim(),
email: profileForm.email.trim(),
})
syncProfileForm()
@@ -142,6 +166,42 @@ async function saveProfile() {
}
}
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 = ''
@@ -190,12 +250,9 @@ async function changePassword() {
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="用户名"><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="登录手机号"><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 />
@@ -216,6 +273,20 @@ async function changePassword() {
</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>
@@ -280,6 +351,10 @@ async function changePassword() {
.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; }
@@ -316,5 +391,6 @@ async function changePassword() {
.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>