This commit is contained in:
2026-09-11 16:17:24 +08:00
parent 80e83a6668
commit ec7f76fede
18 changed files with 263 additions and 31 deletions
+7
View File
@@ -55,6 +55,13 @@ test('source contract: keeps route context and user identity in the compact head
assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/) assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/)
}) })
test('source contract: renders the complete route breadcrumb before page content', () => {
assert.match(layoutSource, /import \{ getFirstAccessiblePath, getRouteBreadcrumbs \} from '@\/navigation\/clientNavigation'/)
assert.match(layoutSource, /const breadcrumbs = computed\(\(\) => getRouteBreadcrumbs\(route\.path, String\(pageTitle\.value\)\)\)/)
assert.match(layoutSource, /<nav class="client-breadcrumb" aria-label="页面路径">[\s\S]*?v-for="\(item, index\) in breadcrumbs"[\s\S]*?<RouterLink v-if="item\.path" :to="item\.path">[\s\S]*?<strong v-else aria-current="page">/)
assert.match(shellStyles, /\.client-breadcrumb \{[^}]*margin: 0 0 14px;/)
})
test('source contract: aligns the light top bar beside the prototype-inspired dark sidebar and bounds the mobile header', () => { test('source contract: aligns the light top bar beside the prototype-inspired dark sidebar and bounds the mobile header', () => {
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 250px;[^}]*max-width: calc\(100vw - 250px\);[^}]*padding: 0 24px;/) assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 250px;[^}]*max-width: calc\(100vw - 250px\);[^}]*padding: 0 24px;/)
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/) assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/)
+9 -1
View File
@@ -8,7 +8,7 @@ import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
import WorkspaceTabs from '@/components/WorkspaceTabs.vue' import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
import { useAuthStore } from '@/modules/auth/public' import { useAuthStore } from '@/modules/auth/public'
import { useNotificationsStore } from '@/modules/notifications/public' import { useNotificationsStore } from '@/modules/notifications/public'
import { getFirstAccessiblePath } from '@/navigation/clientNavigation' import { getFirstAccessiblePath, getRouteBreadcrumbs } from '@/navigation/clientNavigation'
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork' import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
import { import {
MAX_CACHED_WORKSPACE_VIEWS, MAX_CACHED_WORKSPACE_VIEWS,
@@ -25,6 +25,7 @@ const workspace = useWorkspaceTabsStore()
const unsaved = useUnsavedWorkStore() const unsaved = useUnsavedWorkStore()
const pageTitle = computed(() => route.meta.title || '业务总览') const pageTitle = computed(() => route.meta.title || '业务总览')
const pageDescription = computed(() => route.meta.description || '') const pageDescription = computed(() => route.meta.description || '')
const breadcrumbs = computed(() => getRouteBreadcrumbs(route.path, String(pageTitle.value)))
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
const desktopViewport = ref(true) const desktopViewport = ref(true)
const menuButtonRef = ref<HTMLButtonElement>() const menuButtonRef = ref<HTMLButtonElement>()
@@ -259,6 +260,13 @@ async function handleLogout() {
:inert="mobileMenuOpen" :inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined" :aria-hidden="mobileMenuOpen ? 'true' : undefined"
> >
<nav class="client-breadcrumb" aria-label="页面路径">
<template v-for="(item, index) in breadcrumbs" :key="`${item.title}-${index}`">
<span v-if="index" aria-hidden="true">/</span>
<RouterLink v-if="item.path" :to="item.path">{{ item.title }}</RouterLink>
<strong v-else aria-current="page">{{ item.title }}</strong>
</template>
</nav>
<RouterView v-slot="{ Component, route: viewRoute }"> <RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS"> <KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
<component <component
+1 -1
View File
@@ -6,7 +6,7 @@ export interface RegisterMerchantRequest {
password: string password: string
displayName: string displayName: string
companyName: string companyName: string
legalPerson: string legalPerson?: string
phone: string phone: string
contactEmail?: string contactEmail?: string
countryCode?: string countryCode?: string
@@ -67,6 +67,15 @@ test('implements every backend registration field and real submission', () => {
assert.match(registerSource, /registerMerchant\(/) assert.match(registerSource, /registerMerchant\(/)
assert.match(registerSource, /4–64 位/) assert.match(registerSource, /4–64 位/)
assert.match(registerSource, /8–72 位/) assert.match(registerSource, /8–72 位/)
assert.match(registerSource, /label="法定代表人(选填)" prop="legalPerson"/)
assert.doesNotMatch(registerSource, /label="法定代表人[^\"]*" prop="legalPerson" required/)
assert.doesNotMatch(registerSource, /required: true, message: '请输入法定代表人'/)
assert.match(authApiSource, /legalPerson\?: string/)
})
test('shows an explicit registration entry directly below the login button', () => {
assert.match(loginSource, /<el-button[^>]*class="auth-submit"[\s\S]*?<p class="login-register-entry">还没有企业账号?<RouterLink to="\/register">立即注册<\/RouterLink><\/p>/)
assert.match(stylesSource, /\.login-register-entry \{[^}]*text-align: center;/)
}) })
test('shows immediate login after registration and separates company review from account activation', () => { test('shows immediate login after registration and separates company review from account activation', () => {
+1
View File
@@ -119,6 +119,7 @@ function showResetHint() {
<button type="button" @click="showResetHint">忘记密码?</button> <button type="button" @click="showResetHint">忘记密码?</button>
</div> </div>
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button> <el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
<p class="login-register-entry">还没有企业账号?<RouterLink to="/register">立即注册</RouterLink></p>
</el-form> </el-form>
</div> </div>
</AuthShell> </AuthShell>
+3 -3
View File
@@ -40,7 +40,6 @@ const rules: FormRules<RegisterForm> = {
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' }, { max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
], ],
legalPerson: [ legalPerson: [
{ required: true, message: '请输入法定代表人', trigger: 'blur' },
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' }, { max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
], ],
contactEmail: [ contactEmail: [
@@ -68,12 +67,13 @@ async function submit() {
submitting.value = true submitting.value = true
try { try {
const legalPerson = form.legalPerson.trim()
await registerMerchant({ await registerMerchant({
username: form.username.trim(), username: form.username.trim(),
password: form.password, password: form.password,
displayName: form.displayName.trim(), displayName: form.displayName.trim(),
companyName: form.companyName.trim(), companyName: form.companyName.trim(),
legalPerson: form.legalPerson.trim(), ...(legalPerson ? { legalPerson } : {}),
phone: form.phone.trim(), phone: form.phone.trim(),
contactEmail: form.contactEmail.trim() || undefined, contactEmail: form.contactEmail.trim() || undefined,
countryCode: form.countryCode, countryCode: form.countryCode,
@@ -122,7 +122,7 @@ async function submit() {
<h2 id="company-section-title">企业信息</h2> <h2 id="company-section-title">企业信息</h2>
<div class="register-form-grid"> <div class="register-form-grid">
<el-form-item label="企业名称" prop="companyName" class="register-form-full" required><el-input v-model="form.companyName" maxlength="100" placeholder="请输入营业执照上的企业名称" /></el-form-item> <el-form-item label="企业名称" prop="companyName" class="register-form-full" required><el-input v-model="form.companyName" maxlength="100" 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="legalPerson"><el-input v-model="form.legalPerson" maxlength="64" placeholder="可在企业资料中后续补充" /></el-form-item>
<el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode" class="register-select"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item> <el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode" class="register-select"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
</div> </div>
</section> </section>
@@ -8,7 +8,16 @@ test('loads real dashboard data allowed by the current permissions', () => {
assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/) assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/)
assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/) assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/)
assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/) assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/)
assert.match(source, /onMounted\(\(\) => void loadDashboardData\(\)\)/) assert.match(source, /onMounted\(async \(\) => \{\s*await loadDashboardData\(\)\s*showQualificationReminderOnce\(\)\s*\}\)/)
})
test('prompts once per session for incomplete company qualification and opens the upload dialog', () => {
assert.match(source, /\['PROFILE_REQUIRED', 'REJECTED'\]\.includes\(tenant\.company\.auditStatus\)/)
assert.match(source, /sessionStorage\.getItem\(reminderKey\)/)
assert.match(source, /sessionStorage\.setItem\(reminderKey, 'shown'\)/)
assert.match(source, /title="完善企业认证资料"/)
assert.match(source, /立即上传认证资料/)
assert.match(source, /router\.push\(\{ path: '\/company\/profile', query: \{ qualification: 'upload' \} \}\)/)
}) })
test('isolates recoverable dashboard failures without hiding successful sections', () => { test('isolates recoverable dashboard failures without hiding successful sections', () => {
+25 -1
View File
@@ -22,6 +22,7 @@ const orders = useFirstLegOrdersStore()
const tenant = useTenantAdministrationStore() const tenant = useTenantAdministrationStore()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
const qualificationReminderOpen = ref(false)
type DashboardDataSource = 'company' | 'members' | 'orders' type DashboardDataSource = 'company' | 'members' | 'orders'
const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({}) const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({})
const dataSourceLabels: Record<DashboardDataSource, string> = { const dataSourceLabels: Record<DashboardDataSource, string> = {
@@ -116,6 +117,20 @@ async function loadDashboardData() {
} }
} }
function showQualificationReminderOnce() {
if (companyUnavailable.value || !auth.hasPermission('company:qualification:upload')) return
if (!['PROFILE_REQUIRED', 'REJECTED'].includes(tenant.company.auditStatus)) return
const reminderKey = `client-qualification-reminder:${auth.user?.id ?? 'current'}`
if (sessionStorage.getItem(reminderKey)) return
sessionStorage.setItem(reminderKey, 'shown')
qualificationReminderOpen.value = true
}
async function openQualificationUpload() {
qualificationReminderOpen.value = false
await router.push({ path: '/company/profile', query: { qualification: 'upload' } })
}
function openBusinessOrder(business: typeof businessCards[number]) { function openBusinessOrder(business: typeof businessCards[number]) {
if (auth.hasPermission(business.permission)) { if (auth.hasPermission(business.permission)) {
void router.push(business.route) void router.push(business.route)
@@ -133,11 +148,18 @@ function showSupportMessage(type: 'service' | 'faq' | 'quote') {
ElMessage.info(messages[type]) ElMessage.info(messages[type])
} }
onMounted(() => void loadDashboardData()) onMounted(async () => {
await loadDashboardData()
showQualificationReminderOnce()
})
</script> </script>
<template> <template>
<div v-loading="loading" class="client-dashboard" :aria-busy="loading"> <div v-loading="loading" class="client-dashboard" :aria-busy="loading">
<el-dialog v-model="qualificationReminderOpen" title="完善企业认证资料" width="min(520px, 92vw)" append-to-body>
<p class="qualification-reminder-copy">请先上传营业执照和统一社会信用代码并提交审核;法人身份证及其他资质可选填。审核通过后即可提交头程订单。</p>
<template #footer><el-button @click="qualificationReminderOpen = false">稍后处理</el-button><el-button type="primary" @click="openQualificationUpload">立即上传认证资料</el-button></template>
</el-dialog>
<div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert"> <div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
<AlertTriangle :size="20" aria-hidden="true" /> <AlertTriangle :size="20" aria-hidden="true" />
<div> <div>
@@ -237,6 +259,8 @@ onMounted(() => void loadDashboardData())
margin-bottom: 20px; margin-bottom: 20px;
} }
.qualification-reminder-copy { margin: 0; color: #475467; font-size: 14px; line-height: 1.75; }
.dashboard-load-error ul { .dashboard-load-error ul {
display: grid; display: grid;
gap: 3px; gap: 3px;
@@ -37,6 +37,8 @@ export interface MerchantQualification {
qualificationType: MerchantQualificationType qualificationType: MerchantQualificationType
certificateNo: string | null certificateNo: string | null
documentUrl?: string | null documentUrl?: string | null
originalFileName?: string | null
updatedAt?: string | null
validTo: string | null validTo: string | null
status: QualificationStatus status: QualificationStatus
reviewComment: string | null reviewComment: string | null
@@ -119,6 +121,11 @@ export interface MerchantPersonalProfile {
email: string | null email: string | null
} }
export interface QualificationDeletionResult {
qualificationId: number | string
deletedDocumentUrl: string | null
}
export interface ChangeMerchantLoginIdentifiersRequest { export interface ChangeMerchantLoginIdentifiersRequest {
username: string username: string
phone: string phone: string
@@ -157,6 +164,13 @@ export function getTenantQualifications() {
return apiRequest<MerchantQualification[]>({ method: 'GET', url: '/merchant/v1/iam/tenant/qualifications' }) return apiRequest<MerchantQualification[]>({ method: 'GET', url: '/merchant/v1/iam/tenant/qualifications' })
} }
export function deleteTenantQualification(qualificationId: number | string) {
return apiRequest<QualificationDeletionResult>({
method: 'DELETE',
url: `/merchant/v1/iam/tenant/qualifications/${qualificationId}`,
})
}
export function getMerchantPermissionResources() { export function getMerchantPermissionResources() {
return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' }) return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' })
} }
@@ -5,6 +5,7 @@ import {
changeMerchantRoleStatus, changeMerchantRoleStatus,
createMerchantMember, createMerchantMember,
createMerchantRole, createMerchantRole,
deleteTenantQualification,
deleteMerchantRole, deleteMerchantRole,
getMerchantMembers, getMerchantMembers,
getMerchantPermissionResources, getMerchantPermissionResources,
@@ -91,6 +92,13 @@ function formatLastLoginAt(value: string | null) {
return date.toLocaleString('zh-CN', { hour12: false }) return date.toLocaleString('zh-CN', { hour12: false })
} }
function formatQualificationUpdatedAt(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
const emptyCompany: TenantCompanyProfile = { const emptyCompany: TenantCompanyProfile = {
companyName: '', companyName: '',
creditCode: '', creditCode: '',
@@ -145,10 +153,10 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书',
BANK_ACCOUNT_LICENSE: '银行开户许可证', BANK_ACCOUNT_LICENSE: '银行开户许可证',
} as const)[item.qualificationType] || '企业资质', } as const)[item.qualificationType] || '企业资质',
fileName: documentUrl.split('/').pop() || '资质文件', fileName: item.originalFileName?.trim() || '—',
status: normalizeQualificationStatus(item.status), status: normalizeQualificationStatus(item.status),
expiresAt: item.validTo || '长期有效', expiresAt: item.validTo || '长期有效',
updatedAt: '-', updatedAt: formatQualificationUpdatedAt(item.updatedAt),
documentUrl, documentUrl,
} }
}) })
@@ -198,6 +206,11 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
roles.value = roleRecords.map(mapMerchantRole) roles.value = roleRecords.map(mapMerchantRole)
} }
async function deleteQualification(id: string) {
await deleteTenantQualification(id)
qualifications.value = qualifications.value.filter((item) => item.id !== id)
}
async function loadMembers() { async function loadMembers() {
members.value = [] members.value = []
members.value = (await getMerchantMembers()).map(mapMerchantMember) members.value = (await getMerchantMembers()).map(mapMerchantMember)
@@ -335,6 +348,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
roles, roles,
activeMemberCount, activeMemberCount,
loadCompanyContext, loadCompanyContext,
deleteQualification,
loadRoles, loadRoles,
loadMembers, loadMembers,
updateCompany, updateCompany,
@@ -2,10 +2,12 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { formatQualificationFileSize, qualificationUploadErrorMessage, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts' import { formatQualificationFileSize, qualificationUploadErrorMessage, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
test('accepts the backend-confirmed PDF, JPG and PNG formats up to 5MB', () => { test('accepts PDF, JPG, PNG and BMP formats up to 5MB', () => {
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '') assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '') assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
assert.equal(validateQualificationFile({ name: '货代资质.pdf', size: 2048, type: 'application/pdf' }), '') assert.equal(validateQualificationFile({ name: '货代资质.pdf', size: 2048, type: 'application/pdf' }), '')
assert.equal(validateQualificationFile({ name: '法人身份证.bmp', size: 2048, type: 'image/bmp' }), '')
assert.equal(validateQualificationFile({ name: '营业执照.bmp', size: 2048, type: 'image/x-ms-bmp' }), '')
}) })
test('rejects unsupported, empty, mismatched, and oversized files', () => { test('rejects unsupported, empty, mismatched, and oversized files', () => {
@@ -1,7 +1,7 @@
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024 export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'pdf']) const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'bmp', 'pdf'])
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'application/pdf']) const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/bmp', 'image/x-ms-bmp', 'application/pdf'])
export interface QualificationFileLike { export interface QualificationFileLike {
name: string name: string
@@ -11,7 +11,7 @@ export interface QualificationFileLike {
export function validateQualificationFile(file: QualificationFileLike) { export function validateQualificationFile(file: QualificationFileLike) {
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : '' const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 PDF、JPG 或 PNG 文件' if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 PDF、JPG、PNG 或 BMP 文件'
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致' if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
if (file.size <= 0) return '文件内容为空,请重新选择' if (file.size <= 0) return '文件内容为空,请重新选择'
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择' if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadFile } from 'element-plus' import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue' import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -18,6 +19,7 @@ import {
const store = useTenantAdministrationStore() const store = useTenantAdministrationStore()
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE') const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({ const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照并提交审核后,才能提交头程订单;法人身份证可按需补充。' }, PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照并提交审核后,才能提交头程订单;法人身份证可按需补充。' },
@@ -41,6 +43,7 @@ const qualificationPage = ref(1)
const qualificationPageSize = ref(10) const qualificationPageSize = ref(10)
const qualificationSubmitting = ref(false) const qualificationSubmitting = ref(false)
const reviewSubmitting = ref(false) const reviewSubmitting = ref(false)
const deletingQualificationId = ref('')
const profileSubmitting = ref(false) const profileSubmitting = ref(false)
const loading = ref(false) const loading = ref(false)
const loadError = ref('') const loadError = ref('')
@@ -60,7 +63,7 @@ type QualificationDraftItem = QualificationTypeDefinition & {
const qualificationTypes: QualificationTypeDefinition[] = [ const qualificationTypes: QualificationTypeDefinition[] = [
{ value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true }, { value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true },
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '选填;如上传,正反面请合并为一个 PDF、JPG 或 PNG 文件。', required: false }, { value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '选填;如上传,正反面请合并为一个 PDF、JPG、PNG 或 BMP 文件。', required: false },
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false }, { value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false },
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false }, { value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
] ]
@@ -154,7 +157,7 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }], contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
} }
const qualificationColumns: DataTableColumn[] = [ const qualificationColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
{ key: 'type', prop: 'type', label: '文件类型', minWidth: 150 }, { key: 'type', prop: 'type', label: '文件类型', minWidth: 150 },
{ key: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true }, { key: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'status', label: '状态', width: 110, slotName: 'status' },
@@ -184,7 +187,13 @@ async function loadCompanyContext() {
loading.value = false loading.value = false
} }
onMounted(() => void loadCompanyContext()) onMounted(async () => {
await loadCompanyContext()
if (route.query.qualification === 'upload' && canUploadQualification.value) {
activeTab.value = 'qualification'
openQualificationDialog()
}
})
onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview)) onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview))
function startEdit() { function startEdit() {
@@ -299,7 +308,7 @@ function handleQualificationFileChange(type: MerchantQualificationType, file: Up
item.fileName = raw.name item.fileName = raw.name
item.fileSize = raw.size item.fileSize = raw.size
item.fileType = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件' item.fileType = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
if (raw.type.startsWith('image/') || /\.(jpe?g|png)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw) if (raw.type.startsWith('image/') || /\.(jpe?g|png|bmp)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw)
} }
function removeSelectedFile(type: MerchantQualificationType) { function removeSelectedFile(type: MerchantQualificationType) {
@@ -329,19 +338,19 @@ function resetQualificationDraft() {
qualificationCertificateNo.value = store.company.creditCode qualificationCertificateNo.value = store.company.creditCode
} }
async function submitQualification() { async function submitQualification(closeDialog = true) {
if (qualificationSubmitting.value) return if (qualificationSubmitting.value) return false
if (!canUploadQualification.value) { if (!canUploadQualification.value) {
qualificationOpen.value = false qualificationOpen.value = false
await loadCompanyContext() await loadCompanyContext()
ElMessage.warning(qualificationUploadDisabledReason.value) ElMessage.warning(qualificationUploadDisabledReason.value)
return return false
} }
if (!requiredPackageComplete.value) { if (!requiredPackageComplete.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE') const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`) ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
return return false
} }
const optionalItems = qualificationDrafts.filter((item) => !item.required const optionalItems = qualificationDrafts.filter((item) => !item.required
&& item.file && item.file
@@ -418,20 +427,32 @@ async function submitQualification() {
if (!canUploadQualification.value) qualificationOpen.value = false if (!canUploadQualification.value) qualificationOpen.value = false
const uploadedCopy = uploadedCount ? `已保存 ${uploadedCount} 项;` : '' const uploadedCopy = uploadedCount ? `已保存 ${uploadedCount} 项;` : ''
ElMessage.error(`${uploadedCopy}${failures[0]}`) ElMessage.error(`${uploadedCopy}${failures[0]}`)
return return false
} }
qualificationOpen.value = false if (closeDialog) qualificationOpen.value = false
if (refreshed) ElMessage.success(includedLegalPersonId if (refreshed) ElMessage.success(includedLegalPersonId
? (requiredPackageChangeSelected.value ? (requiredPackageChangeSelected.value
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核' ? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
: '法人身份证已保存,尚未提交审核') : '法人身份证已保存,尚未提交审核')
: '资质文件已保存,尚未提交审核') : '资质文件已保存,尚未提交审核')
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态') else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
return refreshed
} finally { } finally {
qualificationSubmitting.value = false qualificationSubmitting.value = false
} }
} }
async function submitQualificationAndReview() {
if (qualificationSubmitting.value || reviewSubmitting.value) return
if (selectedQualificationCount.value > 0 && !await submitQualification(false)) return
if (!canSubmitQualificationReview.value) {
ElMessage.warning(qualificationReviewDisabledReason.value)
return
}
await submitQualificationReview()
if (store.company.auditStatus === 'PENDING') qualificationOpen.value = false
}
async function submitQualificationReview() { async function submitQualificationReview() {
if (!canSubmitQualificationReview.value || reviewSubmitting.value) return if (!canSubmitQualificationReview.value || reviewSubmitting.value) return
reviewSubmitting.value = true reviewSubmitting.value = true
@@ -450,6 +471,34 @@ async function submitQualificationReview() {
} }
} }
function qualificationDeleteDisabled(record: TenantQualification) {
return deletingQualificationId.value === record.id
|| !auth.hasPermission('company:qualification:upload')
|| !canUploadQualification.value
}
async function deleteQualification(record: TenantQualification) {
if (qualificationDeleteDisabled(record)) return
try {
await ElMessageBox.confirm(
`确认删除资质文件“${record.fileName}”?删除后如需送审必须重新上传。`,
'删除资质文件',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
deletingQualificationId.value = record.id
try {
await store.deleteQualification(record.id)
ElMessage.success('资质文件已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '资质文件删除失败')
} finally {
deletingQualificationId.value = ''
}
}
async function previewQualification(record: TenantQualification) { async function previewQualification(record: TenantQualification) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewRecord.value = record previewRecord.value = record
@@ -548,7 +597,10 @@ function closeQualificationPreview() {
:total="store.qualifications.length" :total="store.qualifications.length"
row-key="id" row-key="id"
> >
<template #actions="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template> <template #actions="{ row }">
<el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button>
<el-button link type="danger" :loading="deletingQualificationId === (row as TenantQualification).id" :disabled="qualificationDeleteDisabled(row as TenantQualification)" @click="deleteQualification(row as TenantQualification)">删除</el-button>
</template>
<template #status="{ row }"><el-tag :type="qualificationStatusMeta(row.status).type" effect="light">{{ qualificationStatusMeta(row.status).label }}</el-tag></template> <template #status="{ row }"><el-tag :type="qualificationStatusMeta(row.status).type" effect="light">{{ qualificationStatusMeta(row.status).label }}</el-tag></template>
</DataTable> </DataTable>
<div class="mobile-qualification-list"> <div class="mobile-qualification-list">
@@ -556,7 +608,10 @@ function closeQualificationPreview() {
<header><strong>{{ record.type }}</strong><el-tag :type="qualificationStatusMeta(record.status).type" effect="light">{{ qualificationStatusMeta(record.status).label }}</el-tag></header> <header><strong>{{ record.type }}</strong><el-tag :type="qualificationStatusMeta(record.status).type" effect="light">{{ qualificationStatusMeta(record.status).label }}</el-tag></header>
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div> <div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl> <dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button> <div class="mobile-qualification-actions">
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
<el-button link type="danger" :loading="deletingQualificationId === record.id" :disabled="qualificationDeleteDisabled(record)" @click="deleteQualification(record)">删除</el-button>
</div>
</article> </article>
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div> <div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
</div> </div>
@@ -612,11 +667,11 @@ function closeQualificationPreview() {
:key="`${item.value}-${item.inputVersion}`" :key="`${item.value}-${item.inputVersion}`"
:auto-upload="false" :auto-upload="false"
:show-file-list="false" :show-file-list="false"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" accept=".pdf,.jpg,.jpeg,.png,.bmp,application/pdf,image/jpeg,image/png,image/bmp,image/x-ms-bmp"
@change="handleQualificationFileChange(item.value, $event)" @change="handleQualificationFileChange(item.value, $event)"
> >
<el-button :disabled="qualificationSubmitting"><Upload :size="16" />{{ qualificationRecord(item.value) ? '替换文件' : '选择文件' }}</el-button> <el-button :disabled="qualificationSubmitting"><Upload :size="16" />{{ qualificationRecord(item.value) ? '替换文件' : '选择文件' }}</el-button>
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG,单个文件不超过 5MB。</div></template> <template #tip><div class="upload-tip">支持 PDF、JPG、PNG、BMP,单个文件不超过 5MB。</div></template>
</el-upload> </el-upload>
<div v-if="item.fileName" class="selected-qualification-file" aria-live="polite"> <div v-if="item.fileName" class="selected-qualification-file" aria-live="polite">
<a v-if="item.previewUrl" class="selected-file-preview" :href="item.previewUrl" target="_blank" rel="noopener" :aria-label="`查看${item.label}图片预览`"> <a v-if="item.previewUrl" class="selected-file-preview" :href="item.previewUrl" target="_blank" rel="noopener" :aria-label="`查看${item.label}图片预览`">
@@ -634,7 +689,7 @@ function closeQualificationPreview() {
<small class="qualification-credit-code-hint">{{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}</small> <small class="qualification-credit-code-hint">{{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}</small>
</el-form-item> </el-form-item>
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p> <p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch" @click="submitQualification">保存全部资质</el-button></template> <template #footer><el-button :disabled="qualificationSubmitting || reviewSubmitting" @click="qualificationOpen = false">取消</el-button><el-button :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch || reviewSubmitting" @click="submitQualification()">保存草稿</el-button><el-button type="primary" :loading="qualificationSubmitting || reviewSubmitting" :disabled="selectedQualificationCount > 0 ? !canSaveQualificationBatch : !canSubmitQualificationReview" @click="submitQualificationAndReview">提交审核</el-button></template>
</el-dialog> </el-dialog>
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview"> <el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
@@ -692,6 +747,7 @@ function closeQualificationPreview() {
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; } .form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.form-grid-two .full-row { grid-column: 1 / -1; } .form-grid-two .full-row { grid-column: 1 / -1; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; } .drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
.mobile-qualification-actions { display: flex; align-items: center; gap: 8px; }
.qualification-dialog-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; padding: 13px 15px; background: #f4f7f7; } .qualification-dialog-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; padding: 13px 15px; background: #f4f7f7; }
.qualification-dialog-intro strong { color: var(--navy); font-size: 13px; } .qualification-dialog-intro strong { color: var(--navy); font-size: 13px; }
.qualification-dialog-intro span { max-width: 420px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: right; } .qualification-dialog-intro span { max-width: 420px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: right; }
@@ -8,6 +8,7 @@ const membersSource = fs.readFileSync(new URL('./MembersView.vue', import.meta.u
const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8') const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8')
const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8') const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8')
const storeSource = fs.readFileSync(new URL('../store/tenantAdministration.ts', import.meta.url), 'utf8') const storeSource = fs.readFileSync(new URL('../store/tenantAdministration.ts', import.meta.url), 'utf8')
const apiSource = fs.readFileSync(new URL('../api/merchantIam.ts', import.meta.url), 'utf8')
test('routes tenant administration modules to implemented views instead of the placeholder', () => { test('routes tenant administration modules to implemented views instead of the placeholder', () => {
assert.match(routerSource, /path: 'company\/profile'[\s\S]*?CompanyProfileView\.vue/) assert.match(routerSource, /path: 'company\/profile'[\s\S]*?CompanyProfileView\.vue/)
@@ -23,7 +24,7 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /联系信息/) assert.match(profileSource, /联系信息/)
assert.match(profileSource, /资质文件/) assert.match(profileSource, /资质文件/)
assert.match(profileSource, /async function saveProfile\(\)/) assert.match(profileSource, /async function saveProfile\(\)/)
assert.match(profileSource, /function submitQualification\(\)/) assert.match(profileSource, /function submitQualification\(closeDialog = true\)/)
assert.match(profileSource, /validateQualificationFile/) assert.match(profileSource, /validateQualificationFile/)
assert.match(profileSource, /function removeSelectedFile\(type: MerchantQualificationType\)/) assert.match(profileSource, /function removeSelectedFile\(type: MerchantQualificationType\)/)
assert.match(profileSource, /qualificationSubmitting/) assert.match(profileSource, /qualificationSubmitting/)
@@ -32,7 +33,9 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /:key="`\$\{item\.value\}-\$\{item\.inputVersion\}`"/) assert.match(profileSource, /:key="`\$\{item\.value\}-\$\{item\.inputVersion\}`"/)
assert.match(profileSource, /class="qualification-upload-grid"/) assert.match(profileSource, /class="qualification-upload-grid"/)
assert.match(profileSource, /v-for="item in qualificationDrafts"/) assert.match(profileSource, /v-for="item in qualificationDrafts"/)
assert.match(profileSource, /保存全部资质/) assert.match(profileSource, /保存草稿/)
assert.match(profileSource, /提交审核/)
assert.match(profileSource, /async function submitQualificationAndReview\(\)/)
assert.match(profileSource, /uploadMerchantRequiredQualificationPackage/) assert.match(profileSource, /uploadMerchantRequiredQualificationPackage/)
assert.match(profileSource, /const requiredPackageFilesSelected = computed/) assert.match(profileSource, /const requiredPackageFilesSelected = computed/)
assert.match(profileSource, /item\.uploadState = 'uploading'/) assert.match(profileSource, /item\.uploadState = 'uploading'/)
@@ -69,6 +72,8 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /const requiredQualificationTypes = qualificationTypes\.filter\(\(type\) => type\.required\)/) assert.match(profileSource, /const requiredQualificationTypes = qualificationTypes\.filter\(\(type\) => type\.required\)/)
assert.match(profileSource, /货代资质证书'.*required: false/) assert.match(profileSource, /货代资质证书'.*required: false/)
assert.match(profileSource, /法人身份证'.*required: false/) assert.match(profileSource, /法人身份证'.*required: false/)
assert.match(profileSource, /accept="\.pdf,\.jpg,\.jpeg,\.png,\.bmp,application\/pdf,image\/jpeg,image\/png,image\/bmp,image\/x-ms-bmp"/)
assert.match(profileSource, /支持 PDF、JPG、PNG、BMP/)
assert.match(profileSource, /银行开户许可证'.*required: false/) assert.match(profileSource, /银行开户许可证'.*required: false/)
assert.match(profileSource, /requiredQualificationTypes\.every/) assert.match(profileSource, /requiredQualificationTypes\.every/)
assert.doesNotMatch(profileSource, /四类必填资质/) assert.doesNotMatch(profileSource, /四类必填资质/)
@@ -79,6 +84,17 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /法人身份证已保存,尚未提交审核/) assert.match(profileSource, /法人身份证已保存,尚未提交审核/)
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/) assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
assert.match(profileSource, /submitTenantQualificationReview/) assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(apiSource, /function deleteTenantQualification\(qualificationId: number \| string\)/)
assert.match(apiSource, /method: 'DELETE'/)
assert.match(apiSource, /`\/merchant\/v1\/iam\/tenant\/qualifications\/\$\{qualificationId\}`/)
assert.match(profileSource, /function qualificationDeleteDisabled\(record: TenantQualification\)/)
assert.match(profileSource, /!canUploadQualification\.value/)
assert.match(profileSource, /store\.deleteQualification\(record\.id\)/)
assert.match(profileSource, /资质文件已删除/)
assert.match(profileSource, /:disabled="qualificationDeleteDisabled\(row as TenantQualification\)"/)
assert.match(profileSource, /:disabled="qualificationDeleteDisabled\(record\)"/)
assert.match(storeSource, /originalFileName\?\.trim\(\) \|\| '—'/)
assert.match(storeSource, /formatQualificationUpdatedAt\(item\.updatedAt\)/)
assert.match(profileSource, /提交企业审核/) assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /资质文件已保存,尚未提交审核/) assert.match(profileSource, /资质文件已保存,尚未提交审核/)
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/) assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
@@ -105,6 +121,8 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /pattern: \/\^\[0-9-\]\{4,32\}\$\//) assert.match(profileSource, /pattern: \/\^\[0-9-\]\{4,32\}\$\//)
assert.doesNotMatch(profileSource, /<dt>联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/) assert.doesNotMatch(profileSource, /<dt>联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/) assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/)
assert.match(storeSource, /fileName: item\.originalFileName\?\.trim\(\) \|\| '—'/)
assert.match(storeSource, /updatedAt: formatQualificationUpdatedAt\(item\.updatedAt\)/)
}) })
test('implements member list, role assignment, password reset and status control', () => { test('implements member list, role assignment, password reset and status control', () => {
+26
View File
@@ -17,6 +17,7 @@ import {
getFirstAccessiblePath, getFirstAccessiblePath,
getNavigationLeaves, getNavigationLeaves,
getNavigationParentKeys, getNavigationParentKeys,
getRouteBreadcrumbs,
openNavigationParentKeys, openNavigationParentKeys,
} from './clientNavigation.ts' } from './clientNavigation.ts'
@@ -207,6 +208,31 @@ test('returns navigation parent keys from outermost to innermost', () => {
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), []) assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), [])
}) })
test('builds complete breadcrumbs without repeating the current navigation page', () => {
assert.deepEqual(getRouteBreadcrumbs('/', '业务总览'), [{ title: '首页' }])
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg', '订单列表'), [
{ title: '首页', path: '/' },
{ title: '头程物流' },
{ title: '订单列表' },
])
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg/create', '创建订单'), [
{ title: '首页', path: '/' },
{ title: '头程物流' },
{ title: '订单列表', path: '/orders/first-leg' },
{ title: '创建订单' },
])
assert.deepEqual(getRouteBreadcrumbs('/notifications/NOTICE-001', '消息详情'), [
{ title: '首页', path: '/' },
{ title: '消息中心' },
{ title: '我的消息', path: '/notifications' },
{ title: '消息详情' },
])
assert.deepEqual(getRouteBreadcrumbs('/forbidden', '无权访问'), [
{ title: '首页', path: '/' },
{ title: '无权访问' },
])
})
test('opens the company parent group for a company route', () => { test('opens the company parent group for a company route', () => {
const openedKeys = [] const openedKeys = []
+35
View File
@@ -62,6 +62,41 @@ export function getFirstAccessiblePath(permissions: readonly string[]) {
return getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path ?? '/forbidden' return getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path ?? '/forbidden'
} }
export interface ClientBreadcrumbItem {
title: string
path?: string
}
function findNavigationTrail(
nodes: readonly ClientNavigationNode[],
path: string,
parents: readonly ClientBreadcrumbItem[] = [],
): ClientBreadcrumbItem[] | undefined {
for (const node of nodes) {
if (isNavigationGroup(node)) {
const result = findNavigationTrail(node.children, path, [...parents, { title: node.title }])
if (result) return result
continue
}
if (node.path === path) return [...parents, { title: node.title, path: node.path }]
}
}
export function getRouteBreadcrumbs(path: string, currentTitle: string): ClientBreadcrumbItem[] {
const home: ClientBreadcrumbItem = { title: '首页', path: '/' }
if (path === '/') return [{ title: '首页' }]
const leaves = getNavigationLeaves(clientNavigation)
.filter((leaf) => leaf.path !== '/' && (path === leaf.path || path.startsWith(`${leaf.path}/`)))
.sort((left, right) => right.path.length - left.path.length)
const parentLeaf = leaves[0]
const trail = parentLeaf ? findNavigationTrail(clientNavigation, parentLeaf.path) ?? [] : []
const items = [home, ...trail]
if (items.at(-1)?.title !== currentTitle) items.push({ title: currentTitle })
if (items.length > 1) delete items.at(-1)!.path
return items
}
function findNavigationParentKeys( function findNavigationParentKeys(
nodes: readonly ClientNavigationNode[], nodes: readonly ClientNavigationNode[],
path: string, path: string,
+4
View File
@@ -40,6 +40,10 @@
.auth-form-options button:focus-visible { outline: 2px solid #155eaa; outline-offset: 3px; } .auth-form-options button:focus-visible { outline: 2px solid #155eaa; outline-offset: 3px; }
.auth-submit { width: 100%; min-height: 46px; } .auth-submit { width: 100%; min-height: 46px; }
.auth-submit span { display: flex; align-items: center; gap: 7px; } .auth-submit span { display: flex; align-items: center; gap: 7px; }
.login-register-entry { margin: 16px 0 0; color: #667085; font-size: 13px; text-align: center; }
.login-register-entry a { margin-left: 5px; color: #155eaa; font-weight: 650; text-decoration: none; }
.login-register-entry a:hover { text-decoration: underline; }
.login-register-entry a:focus-visible { border-radius: 3px; outline: 2px solid #155eaa; outline-offset: 3px; }
.register-form { margin-top: 4px; } .register-form { margin-top: 4px; }
.register-section { padding: 22px 0 2px; border-top: 1px solid #e5eaec; } .register-section { padding: 22px 0 2px; border-top: 1px solid #e5eaec; }
+5
View File
@@ -61,3 +61,8 @@
.client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; } .client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; }
.client-sidebar-logout svg { flex: 0 0 18px; } .client-sidebar-logout svg { flex: 0 0 18px; }
.client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 134px 20px 40px; } .client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 134px 20px 40px; }
.client-breadcrumb { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0 0 14px; color: #98a2b3; font-size: 12px; line-height: 1.5; }
.client-breadcrumb a { color: #667085; text-decoration: none; }
.client-breadcrumb a:hover { color: #1565c0; }
.client-breadcrumb a:focus-visible { border-radius: 3px; outline: 2px solid #6db9ff; outline-offset: 2px; }
.client-breadcrumb strong { min-width: 0; overflow: hidden; color: #344054; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }