feat(client): align account and order foundations
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user