优化构架
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
import type { TokenPair } from '@/api/session'
|
||||
|
||||
export interface RegisterMerchantRequest {
|
||||
username: string
|
||||
password: string
|
||||
displayName: string
|
||||
companyName: string
|
||||
legalPerson: string
|
||||
legalPhone: string
|
||||
contactEmail?: string
|
||||
countryCode?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface RegisterMerchantResult {
|
||||
accountId: number | string
|
||||
tenantId: number | string
|
||||
membershipId: number | string
|
||||
tenantStatus: 'PROFILE_REQUIRED'
|
||||
nextStep: string
|
||||
}
|
||||
|
||||
export function loginMerchant(username: string, password: string) {
|
||||
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
|
||||
}
|
||||
|
||||
export function registerMerchant(payload: RegisterMerchantRequest) {
|
||||
return apiRequest<RegisterMerchantResult>({ method: 'POST', url: '/auth/register', data: payload })
|
||||
}
|
||||
|
||||
export function logoutMerchant(refreshToken: string) {
|
||||
return apiRequest<void>({ method: 'POST', url: '/auth/logout', data: { refreshToken } })
|
||||
}
|
||||
|
||||
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
|
||||
return apiRequest<void>({ method: 'PUT', url: '/auth/password', data: { oldPassword, newPassword } })
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { CurrentUser } from '@chongchong/shared'
|
||||
import { loginMerchant, logoutMerchant } from '@/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session'
|
||||
import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration'
|
||||
import { decodeMerchantToken, type MerchantJwtPayload } from '@/utils/jwt'
|
||||
|
||||
const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)))
|
||||
const backendPermissionMapping = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)
|
||||
.reduce<Record<string, string[]>>((mapping, [frontendCode, backendCodes]) => {
|
||||
for (const backendCode of backendCodes) {
|
||||
if (!mapping[backendCode]) mapping[backendCode] = []
|
||||
mapping[backendCode].push(frontendCode)
|
||||
}
|
||||
return mapping
|
||||
}, {})
|
||||
|
||||
export interface ClientAccountProfile {
|
||||
username: string
|
||||
realName: string
|
||||
phone: string
|
||||
email: string
|
||||
tenantName: string
|
||||
}
|
||||
|
||||
const emptyProfile: ClientAccountProfile = {
|
||||
username: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
tenantName: '',
|
||||
}
|
||||
|
||||
function effectiveBackendPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
|
||||
const jwtPermissions = new Set(payload.permissions ?? [])
|
||||
if (!resources) return jwtPermissions
|
||||
const activeResources = new Set(resources.filter((item) => item.status === 'ACTIVE').map((item) => item.permissionCode))
|
||||
return new Set([...jwtPermissions].filter((permission) => activeResources.has(permission)))
|
||||
}
|
||||
|
||||
function mapPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
|
||||
const backendPermissions = effectiveBackendPermissions(payload, resources)
|
||||
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
|
||||
for (const permission of backendPermissions) {
|
||||
if (frontendPermissionCodes.has(permission)) result.add(permission)
|
||||
for (const mapped of backendPermissionMapping[permission] ?? []) result.add(mapped)
|
||||
}
|
||||
return [...result]
|
||||
}
|
||||
|
||||
function currentUser(payload: MerchantJwtPayload, tenantName?: string): CurrentUser {
|
||||
return {
|
||||
id: String(payload.user_id),
|
||||
name: payload.username,
|
||||
account: payload.username,
|
||||
tenantName: tenantName || `企业租户 ${payload.tenant_id}`,
|
||||
roles: payload.roles?.length ? [...payload.roles] : ['企业成员'],
|
||||
}
|
||||
}
|
||||
|
||||
function isExpiredSessionError(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === 401 || error.code === 'A0401')
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('client-auth', () => {
|
||||
const token = ref(getAccessToken())
|
||||
const payload = ref<MerchantJwtPayload | null>(null)
|
||||
const user = ref<CurrentUser | null>(null)
|
||||
const profile = ref<ClientAccountProfile>({ ...emptyProfile })
|
||||
const permissionCodes = ref<string[]>([])
|
||||
const sessionRestored = ref(false)
|
||||
|
||||
function applyToken(accessToken: string, resources?: MerchantPermissionResource[]) {
|
||||
const decoded = decodeMerchantToken(accessToken)
|
||||
payload.value = decoded
|
||||
token.value = accessToken
|
||||
user.value = currentUser(decoded, user.value?.tenantName)
|
||||
profile.value.username = decoded.username
|
||||
profile.value.realName = decoded.username
|
||||
permissionCodes.value = mapPermissions(decoded, resources)
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
clearSessionTokens()
|
||||
token.value = ''
|
||||
payload.value = null
|
||||
user.value = null
|
||||
permissionCodes.value = []
|
||||
profile.value = { ...emptyProfile }
|
||||
sessionRestored.value = true
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
try {
|
||||
applyToken(token.value)
|
||||
} catch {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value && payload.value))
|
||||
|
||||
async function loadSessionContext() {
|
||||
if (!payload.value) return
|
||||
const [resources, tenant, personalProfile] = await Promise.all([
|
||||
getMerchantPermissionResources(),
|
||||
getTenantProfile(),
|
||||
getMerchantPersonalProfile(),
|
||||
])
|
||||
permissionCodes.value = mapPermissions(payload.value, resources)
|
||||
user.value = currentUser(payload.value, tenant.tenantName)
|
||||
profile.value = {
|
||||
...profile.value,
|
||||
realName: personalProfile.displayName || payload.value.username,
|
||||
phone: personalProfile.phone || '',
|
||||
email: personalProfile.email || '',
|
||||
tenantName: tenant.tenantName,
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
if (sessionRestored.value) return
|
||||
if (!isAuthenticated.value) {
|
||||
sessionRestored.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
await loadSessionContext()
|
||||
} catch (error) {
|
||||
if (isExpiredSessionError(error)) {
|
||||
clearSession()
|
||||
throw error
|
||||
}
|
||||
sessionRestored.value = true
|
||||
return
|
||||
}
|
||||
sessionRestored.value = true
|
||||
}
|
||||
|
||||
async function login(username: string, password: string, remember = false) {
|
||||
const tokens = await loginMerchant(username, password)
|
||||
const decoded = decodeMerchantToken(tokens.accessToken)
|
||||
persistTokenPair(tokens, remember)
|
||||
token.value = tokens.accessToken
|
||||
payload.value = decoded
|
||||
user.value = currentUser(decoded)
|
||||
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
|
||||
permissionCodes.value = mapPermissions(decoded)
|
||||
sessionRestored.value = false
|
||||
try {
|
||||
await loadSessionContext()
|
||||
sessionRestored.value = true
|
||||
} catch (error) {
|
||||
if (isExpiredSessionError(error)) {
|
||||
clearSession()
|
||||
throw error
|
||||
}
|
||||
sessionRestored.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const refreshToken = getRefreshToken()
|
||||
try {
|
||||
if (refreshToken) await logoutMerchant(refreshToken)
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
function hasPermission(permission?: string) {
|
||||
return !permission || permissionCodes.value.includes(permission)
|
||||
}
|
||||
|
||||
function updateProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
|
||||
profile.value = { ...profile.value, ...structuredClone(next) }
|
||||
if (user.value) user.value = { ...user.value, name: next.realName }
|
||||
}
|
||||
|
||||
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
|
||||
const updated = await updateMerchantPersonalProfile({
|
||||
displayName: next.realName.trim(),
|
||||
phone: next.phone.trim(),
|
||||
email: next.email.trim(),
|
||||
})
|
||||
updateProfile({
|
||||
realName: updated.displayName,
|
||||
phone: updated.phone || '',
|
||||
email: updated.email || '',
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('cc-client-session-expired', clearSession)
|
||||
|
||||
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, updateProfile, savePersonalProfile }
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { decodeMerchantToken } from './jwt.ts'
|
||||
|
||||
function tokenFor(payload) {
|
||||
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `header.${encoded}.signature`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user_id: 1001,
|
||||
username: 'merchant_admin',
|
||||
tenant_id: 2001,
|
||||
membership_id: 3001,
|
||||
app_code: 'MERCHANT',
|
||||
roles: ['MERCHANT_OWNER'],
|
||||
permissions: ['merchant:profile:view'],
|
||||
exp: 1900000000,
|
||||
}
|
||||
|
||||
test('decodes a merchant JWT payload for UI identity and permissions', () => {
|
||||
assert.deepEqual(decodeMerchantToken(tokenFor(payload)), payload)
|
||||
})
|
||||
|
||||
test('rejects tokens from the platform application domain', () => {
|
||||
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, app_code: 'PLATFORM' })), /账号不属于当前应用端/)
|
||||
})
|
||||
|
||||
test('rejects malformed tokens', () => {
|
||||
assert.throws(() => decodeMerchantToken('not-a-jwt'), /登录凭证格式无效/)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface MerchantJwtPayload {
|
||||
user_id: number | string
|
||||
username: string
|
||||
tenant_id: number | string
|
||||
operator_tenant_id?: number | string
|
||||
membership_id: number | string
|
||||
app_code: string
|
||||
client_type?: string
|
||||
roles?: string[]
|
||||
permissions?: string[]
|
||||
exp: number
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
export function decodeMerchantToken(token: string): MerchantJwtPayload {
|
||||
const payloadPart = token.split('.')[1]
|
||||
if (!payloadPart) throw new Error('登录凭证格式无效')
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(decodeBase64Url(payloadPart))
|
||||
if (!value || typeof value !== 'object') throw new Error()
|
||||
const payload = value as Partial<MerchantJwtPayload>
|
||||
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
|
||||
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
|
||||
return payload as MerchantJwtPayload
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
|
||||
throw new Error('登录凭证无法解析,请重新登录')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const loginSource = readFileSync(new URL('./LoginView.vue', import.meta.url), 'utf8')
|
||||
const registerSource = readFileSync(new URL('./RegisterView.vue', import.meta.url), 'utf8')
|
||||
const authStoreSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
|
||||
const httpSource = readFileSync(new URL('../api/http.ts', import.meta.url), 'utf8')
|
||||
const sessionSource = readFileSync(new URL('../api/session.ts', import.meta.url), 'utf8')
|
||||
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
|
||||
const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('matches the backend username login contract', () => {
|
||||
assert.match(loginSource, /label="用户名"/)
|
||||
assert.match(loginSource, /\^\[A-Za-z0-9_\]/)
|
||||
assert.match(loginSource, /typeof route\.query\.account === 'string'/)
|
||||
assert.doesNotMatch(loginSource, /developmentHint|demo-tip|merchant_admin|联调账号|本地后端/)
|
||||
assert.match(loginSource, /form\.remember/)
|
||||
assert.match(loginSource, /忘记密码/)
|
||||
})
|
||||
|
||||
test('persists both rotating tokens according to the remember choice', () => {
|
||||
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
|
||||
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
|
||||
assert.match(sessionSource, /remember \? localStorage : sessionStorage/)
|
||||
assert.match(sessionSource, /clearSessionTokens/)
|
||||
})
|
||||
|
||||
test('implements every backend registration field and real submission', () => {
|
||||
for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'legalPhone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
|
||||
assert.match(registerSource, new RegExp(`prop="${field}"`))
|
||||
}
|
||||
assert.match(registerSource, /registerMerchant\(/)
|
||||
assert.match(registerSource, /4–64 位/)
|
||||
assert.match(registerSource, /8–72 位/)
|
||||
})
|
||||
|
||||
test('shows immediate login after registration and separates company review from account activation', () => {
|
||||
assert.match(registerSource, /企业账号注册成功/)
|
||||
assert.match(registerSource, /无需等待注册审核/)
|
||||
assert.match(registerSource, /提交订单前还需完成一步/)
|
||||
assert.match(registerSource, /立即登录/)
|
||||
})
|
||||
|
||||
test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
assert.match(authApiSource, /\/auth\/login/)
|
||||
assert.match(authApiSource, /\/auth\/register/)
|
||||
assert.match(authApiSource, /\/auth\/logout/)
|
||||
assert.match(httpSource, /refreshPromise/)
|
||||
assert.match(httpSource, /\/auth\/refresh/)
|
||||
assert.match(httpSource, /envelope\.code !== '200'/)
|
||||
assert.match(httpSource, /redirectToLoginAfterSessionExpiry/)
|
||||
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
|
||||
assert.match(authStoreSource, /decodeMerchantToken/)
|
||||
assert.doesNotMatch(authStoreSource, /mock-client:/)
|
||||
})
|
||||
|
||||
test('treats only body code 200 as success', () => {
|
||||
assert.match(httpSource, /if \(envelope\.code !== '200'\)/)
|
||||
assert.doesNotMatch(httpSource, /if \(envelope\.code !== '0'\)/)
|
||||
})
|
||||
|
||||
test('keeps a valid local session during temporary context and refresh outages', () => {
|
||||
assert.match(authStoreSource, /function isExpiredSessionError/)
|
||||
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
|
||||
assert.match(authStoreSource, /sessionRestored\.value = true\s+return/)
|
||||
assert.match(httpSource, /if \(mappedError\.status === 401 \|\| mappedError\.code === 'A0401'\)/)
|
||||
assert.doesNotMatch(httpSource, /catch \(refreshError\) \{\s+clearSessionTokens\(\)/)
|
||||
})
|
||||
|
||||
test('loads styles for programmatic Element Plus messages and dialogs', () => {
|
||||
assert.match(mainSource, /element-plus\/es\/components\/message\/style\/css/)
|
||||
assert.match(mainSource, /element-plus\/es\/components\/message-box\/style\/css/)
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
|
||||
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface LoginForm {
|
||||
account: string
|
||||
password: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const form = reactive<LoginForm>({
|
||||
account: typeof route.query.account === 'string'
|
||||
? route.query.account
|
||||
: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
})
|
||||
const accountPattern = /^[A-Za-z0-9_]{4,64}$/
|
||||
|
||||
const rules: FormRules<LoginForm> = {
|
||||
account: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ pattern: accountPattern, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!formRef.value || loading.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.account.trim(), form.password, form.remember)
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace(String(route.query.redirect || '/'))
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '登录失败,请检查账号后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showResetHint() {
|
||||
ElMessage.info('请联系平台客服重置密码')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<header class="auth-header">
|
||||
<RouterLink to="/login" class="auth-brand"><img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" /></RouterLink>
|
||||
<div>还没有企业账号? <RouterLink to="/register">申请入驻</RouterLink></div>
|
||||
</header>
|
||||
<main class="auth-main">
|
||||
<section class="auth-copy">
|
||||
<h1>跨境物流业务<br />一个工作台完成</h1>
|
||||
<p>从企业成员协作到头程订单跟进,统一管理业务与权限。</p>
|
||||
<div class="auth-facts"><span>企业级权限</span><span>订单全程跟进</span><span>多端业务协同</span></div>
|
||||
</section>
|
||||
<section class="auth-card" aria-labelledby="login-title">
|
||||
<h2 id="login-title">登录客户工作台</h2>
|
||||
<p>企业注册成功后即可登录;提交订单前需完成企业资料审核</p>
|
||||
<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>
|
||||
<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" />
|
||||
</el-form-item>
|
||||
<div class="auth-form-options">
|
||||
<el-checkbox v-model="form.remember">记住我</el-checkbox>
|
||||
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||
</div>
|
||||
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowLeft, Building2, CheckCircle2, Mail, Phone } from '@lucide/vue'
|
||||
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import { registerMerchant } from '@/api/auth'
|
||||
|
||||
interface RegisterForm {
|
||||
username: string
|
||||
companyName: string
|
||||
displayName: string
|
||||
legalPerson: string
|
||||
legalPhone: string
|
||||
contactEmail: string
|
||||
countryCode: string
|
||||
address: string
|
||||
password: string
|
||||
agreement: boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
const submittedAt = ref('')
|
||||
const form = reactive<RegisterForm>({ username: '', companyName: '', displayName: '', legalPerson: '', legalPhone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
|
||||
|
||||
const rules: FormRules<RegisterForm> = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z0-9_]{4,64}$/, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
|
||||
],
|
||||
companyName: [
|
||||
{ required: true, message: '请输入企业名称', trigger: 'blur' },
|
||||
{ min: 2, max: 100, message: '企业名称长度应为 2–100 个字符', trigger: 'blur' },
|
||||
],
|
||||
displayName: [
|
||||
{ required: true, message: '请输入联系人姓名', trigger: 'blur' },
|
||||
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
legalPerson: [
|
||||
{ required: true, message: '请输入法定代表人', trigger: 'blur' },
|
||||
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
contactEmail: [
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
|
||||
],
|
||||
legalPhone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入有效的 11 位手机号', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请设置登录密码', trigger: 'blur' },
|
||||
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
|
||||
],
|
||||
agreement: [{ validator: (_rule, value, callback) => value ? callback() : callback(new Error('请先阅读并同意服务协议和隐私政策')), trigger: 'change' }],
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!formRef.value || submitting.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await registerMerchant({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
displayName: form.displayName.trim(),
|
||||
companyName: form.companyName.trim(),
|
||||
legalPerson: form.legalPerson.trim(),
|
||||
legalPhone: form.legalPhone.trim(),
|
||||
contactEmail: form.contactEmail.trim() || undefined,
|
||||
countryCode: form.countryCode,
|
||||
address: form.address.trim() || undefined,
|
||||
})
|
||||
submittedAt.value = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(new Date())
|
||||
submitted.value = true
|
||||
ElMessage.success('企业账号注册成功')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '注册失败,请检查信息后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showDocument(name: string) {
|
||||
ElMessage.info(`${name}将在正式接入前由业务方确认内容`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<header class="register-header">
|
||||
<RouterLink to="/login"><ArrowLeft :size="18" /> 返回登录</RouterLink>
|
||||
<div class="auth-brand"><img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" /></div>
|
||||
</header>
|
||||
<main class="register-main">
|
||||
<template v-if="!submitted">
|
||||
<div class="register-title"><div><Building2 :size="22" /></div><span><h1>企业注册</h1><p>注册成功后即可登录并使用客户工作台,提交订单前需完成企业资料审核。</p></span></div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="register-form" label-position="top" size="large" @submit.prevent="submit">
|
||||
<div class="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="companyName" required><el-input v-model="form.companyName" maxlength="100" placeholder="请输入企业营业执照上的名称" /></el-form-item>
|
||||
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalPerson" required><el-input v-model="form.legalPerson" maxlength="64" placeholder="请输入营业执照法定代表人" /></el-form-item>
|
||||
<el-form-item label="法人手机号" prop="legalPhone" required><el-input v-model="form.legalPhone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入手机号" :prefix-icon="Phone" /></el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item>
|
||||
<el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
|
||||
<el-form-item label="企业地址" prop="address" class="full-row"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></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>
|
||||
<el-form-item prop="agreement" class="agreement-item">
|
||||
<div class="agreement-control">
|
||||
<el-checkbox v-model="form.agreement"><span class="agreement-required" aria-hidden="true">*</span>我已阅读并同意</el-checkbox>
|
||||
<span><button type="button" @click="showDocument('《用户服务协议》')">《用户服务协议》</button> 和 <button type="button" @click="showDocument('《隐私政策》')">《隐私政策》</button></span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照并等待平台审核,审核通过后才能提交订单。</p>
|
||||
<div class="register-actions"><el-button @click="$router.push('/login')">取消</el-button><el-button native-type="submit" type="primary" :loading="submitting">注册企业账号</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<section v-else class="registration-result" aria-live="polite">
|
||||
<CheckCircle2 :size="42" />
|
||||
<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-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user