优化构架

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
+38
View File
@@ -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 } })
}
+199
View File
@@ -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 }
})
+31
View File
@@ -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'), /登录凭证格式无效/)
})
+37
View File
@@ -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('登录凭证无法解析,请重新登录')
}
}
+74
View File
@@ -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/)
})
+93
View File
@@ -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>
+137
View File
@@ -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>