优化
This commit is contained in:
@@ -55,6 +55,13 @@ test('source contract: keeps route context and user identity in the compact head
|
||||
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', () => {
|
||||
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\)/)
|
||||
|
||||
@@ -8,7 +8,7 @@ import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
|
||||
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
|
||||
import { useAuthStore } from '@/modules/auth/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 {
|
||||
MAX_CACHED_WORKSPACE_VIEWS,
|
||||
@@ -25,6 +25,7 @@ const workspace = useWorkspaceTabsStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const pageTitle = computed(() => route.meta.title || '业务总览')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const breadcrumbs = computed(() => getRouteBreadcrumbs(route.path, String(pageTitle.value)))
|
||||
const mobileMenuOpen = ref(false)
|
||||
const desktopViewport = ref(true)
|
||||
const menuButtonRef = ref<HTMLButtonElement>()
|
||||
@@ -259,6 +260,13 @@ async function handleLogout() {
|
||||
:inert="mobileMenuOpen"
|
||||
: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 }">
|
||||
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
|
||||
<component
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface RegisterMerchantRequest {
|
||||
password: string
|
||||
displayName: string
|
||||
companyName: string
|
||||
legalPerson: string
|
||||
legalPerson?: string
|
||||
phone: string
|
||||
contactEmail?: string
|
||||
countryCode?: string
|
||||
|
||||
@@ -67,6 +67,15 @@ test('implements every backend registration field and real submission', () => {
|
||||
assert.match(registerSource, /registerMerchant\(/)
|
||||
assert.match(registerSource, /4–64 位/)
|
||||
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', () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ function showResetHint() {
|
||||
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
||||
@@ -40,7 +40,6 @@ const rules: FormRules<RegisterForm> = {
|
||||
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
legalPerson: [
|
||||
{ required: true, message: '请输入法定代表人', trigger: 'blur' },
|
||||
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
contactEmail: [
|
||||
@@ -68,12 +67,13 @@ async function submit() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const legalPerson = form.legalPerson.trim()
|
||||
await registerMerchant({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
displayName: form.displayName.trim(),
|
||||
companyName: form.companyName.trim(),
|
||||
legalPerson: form.legalPerson.trim(),
|
||||
...(legalPerson ? { legalPerson } : {}),
|
||||
phone: form.phone.trim(),
|
||||
contactEmail: form.contactEmail.trim() || undefined,
|
||||
countryCode: form.countryCode,
|
||||
@@ -122,7 +122,7 @@ async function submit() {
|
||||
<h2 id="company-section-title">企业信息</h2>
|
||||
<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="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>
|
||||
</div>
|
||||
</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:member:list'\).*tenant\.loadMembers\(\)/)
|
||||
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', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const orders = useFirstLegOrdersStore()
|
||||
const tenant = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const qualificationReminderOpen = ref(false)
|
||||
type DashboardDataSource = 'company' | 'members' | 'orders'
|
||||
const loadErrors = ref<Partial<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]) {
|
||||
if (auth.hasPermission(business.permission)) {
|
||||
void router.push(business.route)
|
||||
@@ -133,11 +148,18 @@ function showSupportMessage(type: 'service' | 'faq' | 'quote') {
|
||||
ElMessage.info(messages[type])
|
||||
}
|
||||
|
||||
onMounted(() => void loadDashboardData())
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
showQualificationReminderOnce()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<AlertTriangle :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
@@ -237,6 +259,8 @@ onMounted(() => void loadDashboardData())
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qualification-reminder-copy { margin: 0; color: #475467; font-size: 14px; line-height: 1.75; }
|
||||
|
||||
.dashboard-load-error ul {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface MerchantQualification {
|
||||
qualificationType: MerchantQualificationType
|
||||
certificateNo: string | null
|
||||
documentUrl?: string | null
|
||||
originalFileName?: string | null
|
||||
updatedAt?: string | null
|
||||
validTo: string | null
|
||||
status: QualificationStatus
|
||||
reviewComment: string | null
|
||||
@@ -119,6 +121,11 @@ export interface MerchantPersonalProfile {
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface QualificationDeletionResult {
|
||||
qualificationId: number | string
|
||||
deletedDocumentUrl: string | null
|
||||
}
|
||||
|
||||
export interface ChangeMerchantLoginIdentifiersRequest {
|
||||
username: string
|
||||
phone: string
|
||||
@@ -157,6 +164,13 @@ export function getTenantQualifications() {
|
||||
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() {
|
||||
return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
changeMerchantRoleStatus,
|
||||
createMerchantMember,
|
||||
createMerchantRole,
|
||||
deleteTenantQualification,
|
||||
deleteMerchantRole,
|
||||
getMerchantMembers,
|
||||
getMerchantPermissionResources,
|
||||
@@ -91,6 +92,13 @@ function formatLastLoginAt(value: string | null) {
|
||||
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 = {
|
||||
companyName: '',
|
||||
creditCode: '',
|
||||
@@ -145,10 +153,10 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书',
|
||||
BANK_ACCOUNT_LICENSE: '银行开户许可证',
|
||||
} as const)[item.qualificationType] || '企业资质',
|
||||
fileName: documentUrl.split('/').pop() || '资质文件',
|
||||
fileName: item.originalFileName?.trim() || '—',
|
||||
status: normalizeQualificationStatus(item.status),
|
||||
expiresAt: item.validTo || '长期有效',
|
||||
updatedAt: '-',
|
||||
updatedAt: formatQualificationUpdatedAt(item.updatedAt),
|
||||
documentUrl,
|
||||
}
|
||||
})
|
||||
@@ -198,6 +206,11 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
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() {
|
||||
members.value = []
|
||||
members.value = (await getMerchantMembers()).map(mapMerchantMember)
|
||||
@@ -335,6 +348,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
roles,
|
||||
activeMemberCount,
|
||||
loadCompanyContext,
|
||||
deleteQualification,
|
||||
loadRoles,
|
||||
loadMembers,
|
||||
updateCompany,
|
||||
|
||||
@@ -2,10 +2,12 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
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: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
|
||||
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', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'pdf'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'application/pdf'])
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'bmp', 'pdf'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/bmp', 'image/x-ms-bmp', 'application/pdf'])
|
||||
|
||||
export interface QualificationFileLike {
|
||||
name: string
|
||||
@@ -11,7 +11,7 @@ export interface QualificationFileLike {
|
||||
|
||||
export function validateQualificationFile(file: QualificationFileLike) {
|
||||
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.size <= 0) return '文件内容为空,请重新选择'
|
||||
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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 { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
|
||||
const store = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
|
||||
const auditStatusCopy = computed(() => ({
|
||||
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照并提交审核后,才能提交头程订单;法人身份证可按需补充。' },
|
||||
@@ -41,6 +43,7 @@ const qualificationPage = ref(1)
|
||||
const qualificationPageSize = ref(10)
|
||||
const qualificationSubmitting = ref(false)
|
||||
const reviewSubmitting = ref(false)
|
||||
const deletingQualificationId = ref('')
|
||||
const profileSubmitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
@@ -60,7 +63,7 @@ type QualificationDraftItem = QualificationTypeDefinition & {
|
||||
|
||||
const qualificationTypes: QualificationTypeDefinition[] = [
|
||||
{ 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: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
|
||||
]
|
||||
@@ -154,7 +157,7 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
|
||||
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
|
||||
}
|
||||
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: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
@@ -184,7 +187,13 @@ async function loadCompanyContext() {
|
||||
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))
|
||||
|
||||
function startEdit() {
|
||||
@@ -299,7 +308,7 @@ function handleQualificationFileChange(type: MerchantQualificationType, file: Up
|
||||
item.fileName = raw.name
|
||||
item.fileSize = raw.size
|
||||
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) {
|
||||
@@ -329,19 +338,19 @@ function resetQualificationDraft() {
|
||||
qualificationCertificateNo.value = store.company.creditCode
|
||||
}
|
||||
|
||||
async function submitQualification() {
|
||||
if (qualificationSubmitting.value) return
|
||||
async function submitQualification(closeDialog = true) {
|
||||
if (qualificationSubmitting.value) return false
|
||||
if (!canUploadQualification.value) {
|
||||
qualificationOpen.value = false
|
||||
await loadCompanyContext()
|
||||
ElMessage.warning(qualificationUploadDisabledReason.value)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!requiredPackageComplete.value) {
|
||||
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
|
||||
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
|
||||
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
const optionalItems = qualificationDrafts.filter((item) => !item.required
|
||||
&& item.file
|
||||
@@ -418,20 +427,32 @@ async function submitQualification() {
|
||||
if (!canUploadQualification.value) qualificationOpen.value = false
|
||||
const uploadedCopy = uploadedCount ? `已保存 ${uploadedCount} 项;` : ''
|
||||
ElMessage.error(`${uploadedCopy}${failures[0]}`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
qualificationOpen.value = false
|
||||
if (closeDialog) qualificationOpen.value = false
|
||||
if (refreshed) ElMessage.success(includedLegalPersonId
|
||||
? (requiredPackageChangeSelected.value
|
||||
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
|
||||
: '法人身份证已保存,尚未提交审核')
|
||||
: '资质文件已保存,尚未提交审核')
|
||||
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||
return refreshed
|
||||
} finally {
|
||||
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() {
|
||||
if (!canSubmitQualificationReview.value || reviewSubmitting.value) return
|
||||
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) {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewRecord.value = record
|
||||
@@ -548,7 +597,10 @@ function closeQualificationPreview() {
|
||||
:total="store.qualifications.length"
|
||||
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>
|
||||
</DataTable>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
|
||||
</div>
|
||||
@@ -612,11 +667,11 @@ function closeQualificationPreview() {
|
||||
:key="`${item.value}-${item.inputVersion}`"
|
||||
:auto-upload="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)"
|
||||
>
|
||||
<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>
|
||||
<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}图片预览`">
|
||||
@@ -634,7 +689,7 @@ function closeQualificationPreview() {
|
||||
<small class="qualification-credit-code-hint">{{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}</small>
|
||||
</el-form-item>
|
||||
<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 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 .full-row { grid-column: 1 / -1; }
|
||||
.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 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; }
|
||||
|
||||
@@ -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 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 apiSource = fs.readFileSync(new URL('../api/merchantIam.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('routes tenant administration modules to implemented views instead of the placeholder', () => {
|
||||
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, /async function saveProfile\(\)/)
|
||||
assert.match(profileSource, /function submitQualification\(\)/)
|
||||
assert.match(profileSource, /function submitQualification\(closeDialog = true\)/)
|
||||
assert.match(profileSource, /validateQualificationFile/)
|
||||
assert.match(profileSource, /function removeSelectedFile\(type: MerchantQualificationType\)/)
|
||||
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, /class="qualification-upload-grid"/)
|
||||
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, /const requiredPackageFilesSelected = computed/)
|
||||
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, /货代资质证书'.*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, /requiredQualificationTypes\.every/)
|
||||
assert.doesNotMatch(profileSource, /四类必填资质/)
|
||||
@@ -79,6 +84,17 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /法人身份证已保存,尚未提交审核/)
|
||||
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
|
||||
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, /资质文件已保存,但最新状态刷新失败/)
|
||||
@@ -105,6 +121,8 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /pattern: \/\^\[0-9-\]\{4,32\}\$\//)
|
||||
assert.doesNotMatch(profileSource, /<dt>联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
|
||||
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', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getFirstAccessiblePath,
|
||||
getNavigationLeaves,
|
||||
getNavigationParentKeys,
|
||||
getRouteBreadcrumbs,
|
||||
openNavigationParentKeys,
|
||||
} from './clientNavigation.ts'
|
||||
|
||||
@@ -207,6 +208,31 @@ test('returns navigation parent keys from outermost to innermost', () => {
|
||||
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', () => {
|
||||
const openedKeys = []
|
||||
|
||||
|
||||
@@ -62,6 +62,41 @@ export function getFirstAccessiblePath(permissions: readonly string[]) {
|
||||
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(
|
||||
nodes: readonly ClientNavigationNode[],
|
||||
path: string,
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
.auth-form-options button:focus-visible { outline: 2px solid #155eaa; outline-offset: 3px; }
|
||||
.auth-submit { width: 100%; min-height: 46px; }
|
||||
.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-section { padding: 22px 0 2px; border-top: 1px solid #e5eaec; }
|
||||
|
||||
@@ -61,3 +61,8 @@
|
||||
.client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; }
|
||||
.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-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; }
|
||||
|
||||
Reference in New Issue
Block a user