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
+2 -2
View File
@@ -27,8 +27,8 @@ export interface ImpersonationExchangeResult {
expiresIn: number
}
export function loginMerchant(username: string, password: string) {
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
export function loginMerchant(loginId: string, password: string) {
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { loginId, password } })
}
export function registerMerchant(payload: RegisterMerchantRequest) {
+4 -5
View File
@@ -149,8 +149,8 @@ export const useAuthStore = defineStore('client-auth', () => {
sessionRestored.value = true
}
async function login(username: string, password: string, remember = false) {
const tokens = await loginMerchant(username, password)
async function login(loginId: string, password: string, remember = false) {
const tokens = await loginMerchant(loginId, password)
const decoded = decodeMerchantToken(tokens.accessToken)
persistTokenPair(tokens, remember)
token.value = tokens.accessToken
@@ -210,10 +210,9 @@ export const useAuthStore = defineStore('client-auth', () => {
if (user.value) user.value = { ...user.value, name: next.realName }
}
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'email'>) {
const updated = await updateMerchantPersonalProfile({
displayName: next.realName.trim(),
phone: next.phone.trim(),
email: next.email.trim(),
})
updateProfile({
@@ -237,5 +236,5 @@ export const useAuthStore = defineStore('client-auth', () => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
})
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, clearSession, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
})
+14 -6
View File
@@ -30,9 +30,12 @@ test('groups the full registration contract without adding out-of-scope sign-in
assert.doesNotMatch(loginSource + registerSource, /微信登录|企业微信登录|短信验证码|邮箱验证码|lang-switcher/)
})
test('matches the backend username login contract', () => {
assert.match(loginSource, /label="用户名"/)
assert.match(loginSource, /\^\[A-Za-z0-9_\]/)
test('matches the frozen username-or-mobile login contract', () => {
assert.match(loginSource, /label="用户名或手机号"/)
assert.match(loginSource, /const usernamePattern = \/\^\[A-Za-z\]/)
assert.match(loginSource, /const mainlandMobilePattern = \/\^1\[3-9\]\\d\{9\}\$\//)
assert.match(authApiSource, /data: \{ loginId, password \}/)
assert.doesNotMatch(authApiSource, /data: \{ username, password \}/)
assert.match(loginSource, /typeof route\.query\.account === 'string'/)
assert.doesNotMatch(loginSource, /developmentHint|demo-tip|merchant_admin|联调账号|本地后端/)
assert.match(loginSource, /form\.remember/)
@@ -59,9 +62,9 @@ test('shows immediate login after registration and separates company review from
assert.match(registerSource, /企业账号注册成功/)
assert.match(registerSource, /无需等待注册审核/)
assert.match(registerSource, /提交订单前还需完成企业审核/)
assert.match(registerSource, /营业执照和法人身份证/)
assert.match(registerSource, /货代资质证书、银行开户许可证可选填/)
assert.match(registerSource, /其他资质可选填/)
assert.match(registerSource, /首次登录需上传营业执照/)
assert.match(registerSource, /法人身份证、货代资质证书和银行开户许可证可选填/)
assert.match(registerSource, /法人身份证及其他资质可选填/)
assert.match(registerSource, /立即登录/)
})
@@ -113,6 +116,11 @@ test('shows the backend credential error on login', () => {
assert.doesNotMatch(loginSource, /登录状态已失效,请重新登录/)
})
test('shows a forced re-login notice after login identifiers change', () => {
assert.match(loginSource, /route\.query\.reason === 'identifiers-updated'/)
assert.match(loginSource, /所有会话均已退出,请重新登录/)
})
test('keeps a valid local session during temporary context and refresh outages', () => {
assert.match(authStoreSource, /function isExpiredSessionError/)
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
+25 -6
View File
@@ -18,6 +18,7 @@ const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const impersonationFailed = route.query.impersonation === 'failed'
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
const form = reactive<LoginForm>({
account: typeof route.query.account === 'string'
? route.query.account
@@ -25,12 +26,23 @@ const form = reactive<LoginForm>({
password: '',
remember: false,
})
const accountPattern = /^[A-Za-z0-9_]{4,64}$/
const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
const mainlandMobilePattern = /^1[3-9]\d{9}$/
function validLoginId(value: string) {
return usernamePattern.test(value) || mainlandMobilePattern.test(value)
}
const rules: FormRules<LoginForm> = {
account: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ pattern: accountPattern, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
{ required: true, message: '请输入用户名或手机号', trigger: 'blur' },
{
validator: (_rule, value: string, callback) => {
if (!value || validLoginId(value.trim())) callback()
else callback(new Error('请输入有效用户名或中国大陆手机号'))
},
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
@@ -76,7 +88,7 @@ function showResetHint() {
<div class="auth-form-content auth-form-content--login">
<div class="auth-form-heading">
<h2 id="login-title">登录客户工作台</h2>
<p>使用企业账号登录,继续管理您的跨境物流业务。</p>
<p>使用用户名或中国大陆手机号登录,继续管理您的跨境物流业务。</p>
</div>
<el-alert
v-if="impersonationFailed"
@@ -85,10 +97,17 @@ function showResetHint() {
:closable="false"
show-icon
/>
<el-alert
v-if="loginIdentifiersUpdated"
title="用户名或手机号已更新,所有会话均已退出,请重新登录"
type="success"
:closable="false"
show-icon
/>
<div class="auth-account-note">企业注册成功后即可登录;提交订单前需完成企业资料审核。</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="用户名" prop="account">
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名" :prefix-icon="UserRound" />
<el-form-item label="用户名或手机号" prop="account">
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名或中国大陆手机号" :prefix-icon="UserRound" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
+5 -5
View File
@@ -29,7 +29,7 @@ const form = reactive<RegisterForm>({ username: '', companyName: '', displayName
const rules: FormRules<RegisterForm> = {
username: [
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_]{4,64}$/, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
{ pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/, message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成', trigger: 'blur' },
],
companyName: [
{ required: true, message: '请输入企业名称', trigger: 'blur' },
@@ -48,7 +48,7 @@ const rules: FormRules<RegisterForm> = {
],
legalPhone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入有效的 11 位手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的中国大陆手机号', trigger: 'blur' },
],
password: [
{ required: true, message: '请设置登录密码', trigger: 'blur' },
@@ -113,7 +113,7 @@ async function submit() {
<section class="register-section" aria-labelledby="account-section-title">
<h2 id="account-section-title">账号信息</h2>
<div class="register-form-grid">
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="4–64 位字母、数字或下划线" /></el-form-item>
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="以字母开头,支持数字和 _.@-" /></el-form-item>
<el-form-item label="登录密码" prop="password" required><el-input v-model="form.password" type="password" autocomplete="new-password" maxlength="72" show-password placeholder="请设置 8–72 位密码" /></el-form-item>
</div>
</section>
@@ -143,7 +143,7 @@ async function submit() {
<span><RouterLink to="/legal/terms" target="_blank" rel="noopener">《用户服务协议》</RouterLink> 和 <RouterLink to="/legal/privacy" target="_blank" rel="noopener">《隐私政策》</RouterLink></span>
</div>
</el-form-item>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照和法人身份证,货代资质证书、银行开户许可证可选填。提交平台审核通过后才能提交订单。</p>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照,法人身份证、货代资质证书和银行开户许可证可选填。提交平台审核通过后才能提交订单。</p>
<el-button native-type="submit" class="register-submit" type="success" :loading="submitting">注册企业账号</el-button>
</el-form>
</template>
@@ -153,7 +153,7 @@ async function submit() {
<h1>企业账号注册成功</h1>
<p><strong>{{ form.username }}</strong> 现在可以直接登录客户工作台,无需等待注册审核。</p>
<dl><div><dt>企业名称</dt><dd>{{ form.companyName }}</dd></div><div><dt>登录账号</dt><dd>{{ form.username }}</dd></div><div><dt>注册时间</dt><dd>{{ submittedAt }}</dd></div><div><dt>账号状态</dt><dd><el-tag type="success" effect="light">可登录</el-tag></dd></div></dl>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成企业审核</strong><span>登录后请在“企业资料”上传营业执照和法人身份证并提交审核,其他资质可选填。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成企业审核</strong><span>登录后请在“企业资料”上传营业执照并提交审核;法人身份证及其他资质可选填。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
</section>
</div>