This commit is contained in:
2026-08-03 17:44:54 +08:00
parent ccced0a35e
commit 370a3926d7
3 changed files with 499 additions and 91 deletions
+176 -62
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, FilePenLine, PackageCheck } from '@lucide/vue'
import { ArrowLeft, Eye, FilePenLine, PackageCheck } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
@@ -17,7 +18,7 @@ import {
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from '@/api/platformFirstLegOrders'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -25,6 +26,8 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/components/com
import { useAccessPreviewStore } from '@/stores/accessPreview'
const PHASE_ONE_TENANT_ID = '10001'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const loadedTenantId = ref(PHASE_ONE_TENANT_ID)
const orders = ref<PlatformFirstLegOrderResource[]>([])
@@ -34,7 +37,6 @@ const pageSize = ref(20)
const loading = ref(false)
const actionLoadingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformFirstLegOrderDetailResource | null>(null)
const noteDialogOpen = ref(false)
@@ -90,7 +92,10 @@ const orderColumns: DataTableColumn[] = [
const expressRules: FormRules = {
expressCompany: [{ required: true, message: '请填写快递公司', trigger: 'blur' }],
expressTrackingNo: [{ required: true, message: '请填写快递单号', trigger: 'blur' }],
expressTrackingNo: [
{ required: true, message: '请填写快递单号', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9-]{4,128}$/, message: '快递单号格式不正确', trigger: 'blur' },
],
}
const noteRules: FormRules = {
@@ -98,6 +103,11 @@ const noteRules: FormRules = {
}
const tenantReady = computed(() => loadedTenantId.value === PHASE_ONE_TENANT_ID)
const detailOrderId = computed(() => {
const orderId = route.query.orderId
return typeof orderId === 'string' && orderId ? orderId : ''
})
const isDetailPage = computed(() => Boolean(detailOrderId.value))
const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }> = [
{ value: 'DRAFT', label: '草稿' },
@@ -181,11 +191,16 @@ watch([page, pageSize], () => {
async function openDetail(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
selectedOrder.value = order
await router.push({ path: route.path, query: { ...route.query, orderId: String(order.id) } })
}
async function loadDetail(orderId: string) {
if (!tenantReady.value) return
detail.value = null
detailOpen.value = true
detailLoading.value = true
try {
detail.value = await getPlatformFirstLegOrder(loadedTenantId.value, String(order.id))
detail.value = await getPlatformFirstLegOrder(loadedTenantId.value, orderId)
selectedOrder.value = detail.value.order
} catch (error) {
const details = getApiErrorDetails(error, '订单详情加载失败')
ElMessage.error(details.message)
@@ -196,11 +211,27 @@ async function openDetail(order: PlatformFirstLegOrderResource) {
async function refreshAfterAction(orderId: string) {
await loadOrders()
if (detailOpen.value && selectedOrder.value?.id === orderId) {
detail.value = await getPlatformFirstLegOrder(loadedTenantId.value, orderId)
if (detailOrderId.value === orderId) {
await loadDetail(orderId)
}
}
function closeDetailPage() {
const { orderId: _orderId, ...query } = route.query
void router.replace({ path: route.path, query })
}
function getFieldValidationMessage(error: unknown, field: string, fallback: string) {
const details = getApiErrorDetails(error, fallback)
if (error instanceof ApiResponseError && error.details && typeof error.details === 'object') {
const fieldMessage = (error.details as Record<string, unknown>)[field]
if (typeof fieldMessage === 'string' && fieldMessage) {
return `${details.message}:${fieldMessage}`
}
}
return details.message
}
async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
const label = actionLabel(order)
@@ -270,7 +301,7 @@ async function saveExpress() {
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('履约快递信息已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '履约快递信息保存失败').message)
ElMessage.error(getFieldValidationMessage(error, 'expressTrackingNo', '履约快递信息保存失败'))
} finally {
expressSaving.value = false
}
@@ -300,66 +331,138 @@ async function saveNote() {
}
}
onMounted(() => {
watch(detailOrderId, (orderId) => {
if (orderId) {
void loadDetail(orderId)
return
}
detail.value = null
selectedOrder.value = null
void loadOrders()
})
}, { immediate: true })
</script>
<template>
<div class="business-page">
<QueryToolbar
v-model="filters"
ariaLabel="头程订单筛选"
:fields="queryFields"
search-text="查询订单"
:search-on-reset="true"
@search="searchOrders"
/>
<template v-if="isDetailPage">
<section class="surface-panel detail-page" v-loading="detailLoading">
<header class="detail-page-header">
<div>
<h1>订单详情</h1>
<p>查看订单信息、履约快递、内部备注和状态历史。</p>
</div>
<el-button @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button>
</header>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
</el-alert>
<section class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="orders"
:columns="orderColumns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
empty-text="当前没有符合条件的头程订单"
row-key="id"
>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).customerName }}</strong><span>租户 {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #empty><el-empty description="当前没有符合条件的头程订单" /></template>
</DataTable>
</section>
<el-drawer v-model="detailOpen" title="订单详情" size="760px" class="business-drawer">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="order-detail-head"><div><span>{{ transportLabel(detail.order) }}</span><h2>{{ detail.order.orderNo || detail.order.id }}</h2><p>{{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p></div><StatusBadge :status="statusLabel(detail.order.status)" /></div>
<div class="detail-section"><h3>订单概要</h3><el-descriptions :column="2" border><el-descriptions-item label="目标仓 / 国家">{{ detail.order.destinationWarehouseName || '-' }} · {{ detail.order.destinationCountryCode || '-' }}</el-descriptions-item><el-descriptions-item label="平台收货仓">{{ detail.order.receivingWarehouseName || '-' }}</el-descriptions-item><el-descriptions-item label="收货地址" :span="2">{{ detail.order.receivingWarehouseAddress || '-' }}</el-descriptions-item><el-descriptions-item label="渠道">{{ detail.order.channelName || '-' }}({{ detail.order.channelCode || '-' }})</el-descriptions-item><el-descriptions-item label="预计出运">{{ detail.order.expectedShipDate || '-' }}</el-descriptions-item><el-descriptions-item label="客户备注" :span="2">{{ detail.order.customerRemark || '-' }}</el-descriptions-item></el-descriptions></div>
<div class="detail-section"><h3>货物明细</h3><el-table :data="detail.cargoes" size="small" empty-text="暂无货物明细"><el-table-column prop="itemNo" label="#" width="52" /><el-table-column prop="cargoName" label="品名" min-width="150" /><el-table-column prop="packageType" label="包装" min-width="100" /><el-table-column prop="packageCount" label="件/箱" width="86" /><el-table-column label="毛重 / 体积" min-width="150"><template #default="{ row }">{{ row.grossWeightKg }} kg / {{ row.volumeCbm }} m³</template></el-table-column></el-table></div>
<div class="detail-section"><h3>收发货方</h3><div class="party-grid"><div v-for="party in detail.parties" :key="party.id"><span>{{ party.partyType === 'SENDER' ? '发货方' : '收货方' }}</span><strong>{{ party.companyName }}</strong><p>{{ party.contactName }} · {{ party.phone }}</p><p>{{ [party.province, party.city, party.district, party.addressLine].filter(Boolean).join(' ') }}</p></div></div></div>
<div class="detail-section"><h3>履约快递</h3><el-descriptions :column="2" border><el-descriptions-item label="快递公司">{{ detail.order.expressCompany || '-' }}</el-descriptions-item><el-descriptions-item label="快递单号">{{ detail.order.expressTrackingNo || '-' }}</el-descriptions-item></el-descriptions></div>
<div class="detail-section"><h3>内部备注</h3><el-empty v-if="!detail.internalNotes.length" description="暂无内部备注" :image-size="52" /><el-timeline v-else><el-timeline-item v-for="note in detail.internalNotes" :key="note.id" :timestamp="formattedDate(note.createdAt)" placement="top"><p>{{ note.content }}</p></el-timeline-item></el-timeline></div>
<div class="detail-section"><h3>状态历史</h3><el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline></div>
<div class="order-detail-head">
<div>
<span>{{ transportLabel(detail.order) }}</span>
<h2>{{ detail.order.orderNo || detail.order.id }}</h2>
<p>{{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p>
</div>
<StatusBadge :status="statusLabel(detail.order.status)" />
</div>
<div class="detail-section">
<h3>订单概要</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="目标仓 / 国家">{{ detail.order.destinationWarehouseName || '-' }} · {{ detail.order.destinationCountryCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="平台收货仓">{{ detail.order.receivingWarehouseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">{{ detail.order.receivingWarehouseAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ detail.order.channelName || '-' }}({{ detail.order.channelCode || '-' }})</el-descriptions-item>
<el-descriptions-item label="预计出运">{{ detail.order.expectedShipDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户备注" :span="2">{{ detail.order.customerRemark || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h3>货物明细</h3>
<div class="detail-table-scroll">
<el-table :data="detail.cargoes" size="small" empty-text="暂无货物明细">
<el-table-column prop="itemNo" label="#" width="52" />
<el-table-column prop="cargoName" label="品名" min-width="150" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="packageCount" label="件/箱" width="86" />
<el-table-column label="毛重 / 体积" min-width="150"><template #default="{ row }">{{ row.grossWeightKg }} kg / {{ row.volumeCbm }} m³</template></el-table-column>
</el-table>
</div>
</div>
<div class="detail-section">
<h3>收发货方</h3>
<div class="party-grid">
<div v-for="party in detail.parties" :key="party.id">
<span>{{ party.partyType === 'SENDER' ? '发货方' : '收货方' }}</span>
<strong>{{ party.companyName }}</strong>
<p>{{ party.contactName }} · {{ party.phone }}</p>
<p>{{ [party.province, party.city, party.district, party.addressLine].filter(Boolean).join(' ') }}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>履约快递</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="快递公司">{{ detail.order.expressCompany || '-' }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detail.order.expressTrackingNo || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h3>内部备注</h3>
<el-empty v-if="!detail.internalNotes.length" description="暂无内部备注" :image-size="52" />
<el-timeline v-else><el-timeline-item v-for="note in detail.internalNotes" :key="note.id" :timestamp="formattedDate(note.createdAt)" placement="top"><p>{{ note.content }}</p></el-timeline-item></el-timeline>
</div>
<div class="detail-section">
<h3>状态历史</h3>
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline>
</div>
</template>
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用" />
</div>
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button></div></template>
</el-drawer>
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage">返回订单列表</el-button></el-empty>
</section>
</template>
<template v-else>
<QueryToolbar
v-model="filters"
ariaLabel="头程订单筛选"
:fields="queryFields"
search-text="查询"
:search-on-reset="true"
@search="searchOrders"
/>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
</el-alert>
<section class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="orders"
:columns="orderColumns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
empty-text="当前没有符合条件的头程订单"
row-key="id"
>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).customerName }}</strong><span>租户 {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #empty><el-empty description="当前没有符合条件的头程订单" /></template>
</DataTable>
</section>
</template>
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="520px">
<el-alert title="当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type="info" :closable="false" show-icon />
@@ -375,5 +478,16 @@ onMounted(() => {
</template>
<style scoped>
.detail-page { overflow: hidden; }
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.detail-page-header h1 { margin: 0; font-size: 20px; line-height: 1.35; }
.detail-page-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.detail-table-scroll { overflow-x: auto; }
.party-grid p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.detail-page-header .el-button { width: 100%; min-height: 40px; }
.detail-table-scroll :deep(.el-table) { min-width: 560px; }
}
</style>
+81
View File
@@ -3,9 +3,12 @@ import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadTenantQualification,
getPlatformTenant,
listPlatformTenants,
listTenantQualifications,
updatePlatformTenant,
uploadPlatformTenantQualification,
type PlatformTenantResource,
} from '@/api/platformIam'
import { useAccessPreviewStore } from '@/stores/accessPreview'
@@ -15,10 +18,12 @@ import CustomersView from './CustomersView.vue'
vi.mock('@/api/platformIam', () => ({
changePlatformTenantStatus: vi.fn(),
createPlatformTenant: vi.fn(),
downloadTenantQualification: vi.fn(),
getPlatformTenant: vi.fn(),
listPlatformTenantOrderCounts: vi.fn(),
listPlatformTenants: vi.fn(),
listTenantQualifications: vi.fn(),
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
submitPlatformTenantReview: vi.fn(),
updatePlatformTenant: vi.fn(),
uploadPlatformTenantQualification: vi.fn(),
@@ -51,7 +56,10 @@ describe('CustomersView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
document.body.innerHTML = ''
pinia = createPinia()
setActivePinia(pinia)
@@ -65,6 +73,10 @@ describe('CustomersView', () => {
})
vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([])
vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({
qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png',
})
})
it('loads the complete customer detail before editing masked list fields', async () => {
@@ -80,4 +92,73 @@ describe('CustomersView', () => {
expect(phone?.value).toBe(fullTenant.contactPhone)
expect(email?.value).toBe(fullTenant.contactEmail)
})
it('shows an authenticated image preview for an uploaded qualification', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-1', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png',
status: 'PENDING', reviewComment: null, validTo: null,
}])
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
await flushPromises()
expect(downloadTenantQualification).toHaveBeenCalledWith(fullTenant.id, '/files/license.png')
const preview = document.querySelector<HTMLImageElement>('img[alt="营业执照预览"]')
expect(preview?.src).toBe('blob:qualification-preview')
})
it('shows uploaded qualifications in customer detail and opens the full preview', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-1', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png',
status: 'PENDING', reviewComment: null, validTo: null,
}])
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('详情')?.click()
await flushPromises()
await flushPromises()
const detailPreview = document.querySelector<HTMLButtonElement>('button[aria-label="查看营业执照:license.png"]')
expect(detailPreview?.querySelector('img[alt="营业执照缩略图"]')).not.toBeNull()
detailPreview?.click()
await flushPromises()
expect(document.querySelector('.qualification-preview img[alt="license.png预览"]')).not.toBeNull()
})
it('saves customer data and selected qualifications with one action', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const licenseFile = new File(['license'], 'license.png', { type: 'image/png' })
const licenseInput = document.querySelector<HTMLInputElement>('.qualification-file input[type="file"]')
Object.defineProperty(licenseInput, 'files', { configurable: true, value: [licenseFile] })
licenseInput?.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const buttons = [...document.querySelectorAll<HTMLButtonElement>('button')]
expect(buttons.some((button) => button.textContent?.trim() === '上传已选资质')).toBe(false)
buttons.find((button) => button.textContent?.trim() === '保存资料和资质')?.click()
await flushPromises()
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ tenantName: fullTenant.tenantName }))
expect(uploadPlatformTenantQualification).toHaveBeenCalledWith(
fullTenant.id,
'BUSINESS_LICENSE',
licenseFile,
undefined,
fullTenant.unifiedSocialCreditCode,
false,
)
})
})
+242 -29
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Building2, CheckCircle2, Download, Eye, FileCheck2, FileUp, Pencil, Plus, Power } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Building2, CheckCircle2, Download, Eye, FileCheck2, FileText, FileUp, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import {
changePlatformTenantStatus,
createPlatformTenant,
downloadTenantQualification,
getPlatformTenant,
listPlatformTenantOrderCounts,
listPlatformTenants,
listTenantQualifications,
qualificationFileName,
submitPlatformTenantReview,
updatePlatformTenant,
uploadPlatformTenantQualification,
@@ -33,9 +35,20 @@ type QualificationDraft = {
validTo: string
uploaded: boolean
uploadedName: string
documentUrl: string
previewUrl: string
previewKind: 'image' | 'pdf' | ''
previewLoading: boolean
previewFailed: boolean
uploading: boolean
error: string
}
type DetailQualificationPreview = {
url: string
kind: 'image' | 'pdf'
loading: boolean
failed: boolean
}
const access = useAccessPreviewStore()
const page = ref(1)
@@ -57,7 +70,12 @@ const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformTenantResource | null>(null)
const detailQualifications = ref<PlatformTenantQualification[]>([])
const detailQualificationPreviews = ref<Record<string, DetailQualificationPreview>>({})
const qualificationLoadError = ref('')
const qualificationPreviewOpen = ref(false)
const qualificationPreviewName = ref('')
const qualificationPreviewUrl = ref('')
const qualificationPreviewKind = ref<'image' | 'pdf' | ''>('')
const filters = ref<CustomerFilters>({ keyword: '', status: '' })
const queryFields: QueryField[] = [
@@ -97,7 +115,20 @@ function newQualificationDrafts(): QualificationDraft[] {
{ type: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', required: false },
{ type: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', required: false },
]
return definitions.map((item) => ({ ...item, file: null, validTo: '', uploaded: false, uploadedName: '', uploading: false, error: '' }))
return definitions.map((item) => ({
...item,
file: null,
validTo: '',
uploaded: false,
uploadedName: '',
documentUrl: '',
previewUrl: '',
previewKind: '',
previewLoading: false,
previewFailed: false,
uploading: false,
error: '',
}))
}
const qualifications = ref<QualificationDraft[]>(newQualificationDrafts())
@@ -151,7 +182,22 @@ async function loadCustomers() {
}
function applyFilters() { if (page.value === 1) void loadCustomers(); else page.value = 1 }
function clearDraftPreview(draft: QualificationDraft) {
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
draft.previewUrl = ''
draft.previewKind = ''
draft.previewLoading = false
draft.previewFailed = false
}
function clearQualificationPreviews() {
for (const draft of qualifications.value) clearDraftPreview(draft)
qualificationPreviewOpen.value = false
qualificationPreviewName.value = ''
qualificationPreviewUrl.value = ''
qualificationPreviewKind.value = ''
}
function resetEditor() {
clearQualificationPreviews()
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = ''
}
function openCreate() {
@@ -175,7 +221,15 @@ async function loadEditorQualifications(tenantId: number | string) {
const records = await listTenantQualifications(tenantId)
for (const draft of qualifications.value) {
const item = [...records].reverse().find((record) => record.qualificationType === draft.type && record.status !== 'REJECTED')
if (item) { draft.uploaded = true; draft.validTo = item.validTo || ''; draft.uploadedName = '已登记文件' }
if (item) {
draft.uploaded = true
draft.validTo = item.validTo || ''
draft.documentUrl = item.documentUrl
try { draft.uploadedName = qualificationFileName(item.documentUrl) }
catch { draft.uploadedName = '已登记文件' }
draft.previewKind = draft.uploadedName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
if (draft.previewKind === 'image') void loadQualificationPreview(draft, tenantId, false)
}
}
} catch (error) { qualificationLoadError.value = getApiErrorMessage(error, '资质信息加载失败,可稍后重试') }
}
@@ -201,13 +255,116 @@ async function openEdit(row: PlatformTenantResource) {
function selectQualificationFile(event: Event, draft: QualificationDraft) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
draft.error = ''; draft.file = null
draft.error = ''; draft.file = null; clearDraftPreview(draft)
if (!file) return
const extensionValid = /\.(pdf|jpe?g|png)$/i.test(file.name)
const mimeValid = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)
if (!extensionValid || !mimeValid) { draft.error = '仅支持 PDF、JPG、PNG 文件'; input.value = ''; return }
if (file.size > 10 * 1024 * 1024) { draft.error = '单个文件不能超过 10MB'; input.value = ''; return }
draft.file = file
draft.previewKind = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
draft.previewUrl = URL.createObjectURL(file)
}
async function loadQualificationPreview(draft: QualificationDraft, tenantId: number | string, showError = true) {
if (!draft.documentUrl || draft.previewLoading) return false
draft.previewLoading = true
try {
const blob = await downloadTenantQualification(tenantId, draft.documentUrl)
if (!blob.size) throw new Error('资质文件内容为空')
if (!editorOpen.value || !qualifications.value.includes(draft)) return false
clearDraftPreview(draft)
draft.previewKind = blob.type === 'application/pdf' || draft.uploadedName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
draft.previewUrl = URL.createObjectURL(blob)
return true
} catch (error) {
if (showError) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败,请稍后重试'))
return false
} finally { draft.previewLoading = false }
}
async function openQualificationPreview(draft: QualificationDraft) {
if (draft.previewFailed) {
if (!draft.uploaded || !editingId.value) {
ElMessage.warning('该文件无法预览,请重新选择有效文件')
return
}
clearDraftPreview(draft)
draft.previewKind = draft.uploadedName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
}
if (!draft.previewUrl && draft.uploaded && editingId.value) {
await loadQualificationPreview(draft, editingId.value)
}
if (!draft.previewUrl || !draft.previewKind) return
qualificationPreviewName.value = draft.file?.name || draft.uploadedName || draft.label
qualificationPreviewUrl.value = draft.previewUrl
qualificationPreviewKind.value = draft.previewKind
qualificationPreviewOpen.value = true
}
function handleQualificationPreviewError(draft: QualificationDraft) {
draft.previewFailed = true
if (qualificationPreviewUrl.value === draft.previewUrl) qualificationPreviewOpen.value = false
}
function detailQualificationName(item: PlatformTenantQualification) {
try { return qualificationFileName(item.documentUrl) }
catch { return `${qualifications.value.find((draft) => draft.type === item.qualificationType)?.label || '资质'}文件` }
}
function clearDetailQualificationPreviews() {
for (const preview of Object.values(detailQualificationPreviews.value)) {
if (preview.url) URL.revokeObjectURL(preview.url)
}
detailQualificationPreviews.value = {}
qualificationPreviewOpen.value = false
}
async function loadDetailQualificationPreview(item: PlatformTenantQualification, showError = true) {
const key = String(item.id)
const preview = detailQualificationPreviews.value[key]
const tenantId = detail.value?.id
if (!preview || !tenantId || !item.documentUrl || preview.loading) return false
preview.loading = true
try {
const blob = await downloadTenantQualification(tenantId, item.documentUrl)
if (!blob.size) throw new Error('资质文件内容为空')
if (!detailOpen.value || detailQualificationPreviews.value[key] !== preview) return false
if (preview.url) URL.revokeObjectURL(preview.url)
preview.url = URL.createObjectURL(blob)
preview.kind = blob.type === 'application/pdf' || detailQualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
preview.failed = false
return true
} catch (error) {
preview.failed = true
if (showError) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败,请稍后重试'))
return false
} finally {
if (detailQualificationPreviews.value[key] === preview) preview.loading = false
}
}
async function openDetailQualificationPreview(item: PlatformTenantQualification) {
const preview = detailQualificationPreviews.value[String(item.id)]
if (!preview) return
if (preview.failed) {
if (preview.url) URL.revokeObjectURL(preview.url)
preview.url = ''
preview.failed = false
}
if (!preview.url) await loadDetailQualificationPreview(item)
if (!preview.url) return
qualificationPreviewName.value = detailQualificationName(item)
qualificationPreviewUrl.value = preview.url
qualificationPreviewKind.value = preview.kind
qualificationPreviewOpen.value = true
}
function handleDetailQualificationPreviewError(item: PlatformTenantQualification) {
const preview = detailQualificationPreviews.value[String(item.id)]
if (!preview) return
preview.failed = true
if (qualificationPreviewUrl.value === preview.url) qualificationPreviewOpen.value = false
}
async function uploadSelectedQualifications(tenantId: string) {
@@ -215,12 +372,15 @@ async function uploadSelectedQualifications(tenantId: string) {
for (const draft of qualifications.value.filter((item) => item.file)) {
draft.uploading = true; draft.error = ''
try {
await uploadPlatformTenantQualification(
const uploaded = await uploadPlatformTenantQualification(
tenantId, draft.type, draft.file as File, draft.validTo || undefined,
draft.type === 'BUSINESS_LICENSE' ? form.value.unifiedSocialCreditCode : undefined,
draft.uploaded,
)
draft.uploaded = true; draft.uploadedName = draft.file?.name || '已登记文件'; draft.file = null
draft.uploaded = true
draft.uploadedName = draft.file?.name || '已登记文件'
draft.documentUrl = uploaded.documentUrl
draft.file = null
} catch (error) {
draft.error = getApiErrorMessage(error, '上传失败,请重试'); failures.push(draft.label)
} finally { draft.uploading = false }
@@ -228,18 +388,11 @@ async function uploadSelectedQualifications(tenantId: string) {
return failures
}
async function uploadQualifications() {
if (!editingId.value) { ElMessage.warning('请先保存客户资料,再上传资质文件'); return }
if (!hasSelectedQualifications.value) { ElMessage.warning('请先选择需要上传的资质文件'); return }
const failures = await uploadSelectedQualifications(editingId.value)
if (failures.length) ElMessage.warning(`${failures.join('、')}上传失败,请根据提示重试`)
else ElMessage.success('已选资质上传成功,尚未提交审核')
}
async function saveCustomer() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
saving.value = true
try {
const hadSelectedQualifications = hasSelectedQualifications.value
let saved: PlatformTenantResource
if (editingId.value) {
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
@@ -250,7 +403,14 @@ async function saveCustomer() {
}
editorStatus.value = saved.status
savedFormSnapshot.value = JSON.stringify(form.value)
ElMessage.success('客户资料已保存为草稿,尚未提交审核')
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadSelectedQualifications) {
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
} else {
ElMessage.success('客户资料已保存,尚未提交审核')
}
await loadCustomers()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
finally { saving.value = false }
@@ -261,7 +421,7 @@ async function submitReview() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
if (hasUnsavedChanges.value) { ElMessage.warning('客户资料有未保存的修改,请先保存资料'); return }
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未上传的已选文件,请先点击“上传已选资质”'); return }
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return }
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
if (missing.length) { ElMessage.warning('请先上传营业执照和法人身份证'); return }
@@ -277,10 +437,19 @@ async function submitReview() {
}
async function showDetail(row: PlatformTenantResource) {
clearDetailQualificationPreviews()
detail.value = row; detailQualifications.value = []; detailOpen.value = true; detailLoading.value = true
try {
const [tenant, records] = await Promise.all([getPlatformTenant(row.id), listTenantQualifications(row.id)])
detail.value = tenant; detailQualifications.value = records
detail.value = tenant
detailQualifications.value = records
detailQualificationPreviews.value = Object.fromEntries(records.map((item) => {
const kind = detailQualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
return [String(item.id), { url: '', kind, loading: false, failed: false } satisfies DetailQualificationPreview]
}))
for (const item of records) {
if (detailQualificationPreviews.value[String(item.id)]?.kind === 'image') void loadDetailQualificationPreview(item, false)
}
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户详情加载失败')) }
finally { detailLoading.value = false }
}
@@ -312,6 +481,10 @@ async function exportCustomers() {
}
watch([page, pageSize], () => { void loadCustomers() })
watch(editorOpen, (open) => { if (!open) clearQualificationPreviews() })
watch(detailOpen, (open) => { if (!open) clearDetailQualificationPreviews() })
onBeforeUnmount(clearQualificationPreviews)
onBeforeUnmount(clearDetailQualificationPreviews)
onMounted(loadCustomers)
</script>
@@ -356,16 +529,31 @@ onMounted(loadCustomers)
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
<div v-for="draft in qualifications" :key="draft.type" class="qualification-row" role="row">
<div class="qualification-label" role="cell"><span v-if="draft.required" aria-hidden="true">*</span>{{ draft.label }}<CheckCircle2 v-if="draft.uploaded" :size="16" aria-label="已上传" /></div>
<div class="qualification-file" role="cell"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><FileUp :size="16" /><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
<div class="qualification-file" role="cell">
<button v-if="draft.file || draft.uploaded" type="button" class="qualification-preview-trigger" :class="{ 'is-loading': draft.previewLoading }" :disabled="draft.previewLoading" :aria-label="`预览${draft.label}:${draft.file?.name || draft.uploadedName}`" @click="openQualificationPreview(draft)">
<img v-if="draft.previewUrl && draft.previewKind === 'image' && !draft.previewFailed" :src="draft.previewUrl" :alt="`${draft.label}预览`" @error="handleQualificationPreviewError(draft)" />
<span v-else class="qualification-preview-placeholder"><FileText :size="20" /><small>{{ draft.previewKind === 'pdf' ? 'PDF' : '文件' }}</small></span>
<span class="qualification-preview-action"><Eye :size="14" />{{ draft.previewLoading ? '读取中' : (draft.previewFailed ? '重试' : '查看') }}</span>
</button>
<div class="qualification-file-control"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><FileUp :size="16" /><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name" :title="draft.file?.name || draft.uploadedName">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
</div>
<div role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
</div>
</div>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 10MB;<span>*</span> 为提交审核必填项。</p>
</div>
</el-form>
<template #footer><div class="drawer-footer customer-editor-footer"><span>{{ editingId ? '保存资料和上传资质均不会送审,提交审核需要再次确认' : '请先保存资料生成客户草稿,再上传资质' }}</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料</el-button><el-button v-if="canUploadQualifications" :loading="uploadingQualifications" :disabled="editorLoading || !editingId || saving || submitting || !hasSelectedQualifications" @click="uploadQualifications">上传已选资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
<template #footer><div class="drawer-footer customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
</el-drawer>
<el-dialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close>
<div class="qualification-preview">
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
</div>
<template #footer><el-button @click="qualificationPreviewOpen = false">关闭预览</el-button></template>
</el-dialog>
<el-drawer v-model="detailOpen" title="客户详情" size="620px" class="business-drawer">
<div v-loading="detailLoading" class="customer-detail">
<template v-if="detail">
@@ -394,11 +582,15 @@ onMounted(loadCustomers)
<section class="customer-detail-section detail-qualification-list" aria-labelledby="customer-qualification-heading">
<div class="detail-section-heading"><h4 id="customer-qualification-heading">资质文件</h4><span>共 {{ detailQualifications.length }} 份</span></div>
<div v-if="detailQualifications.length" class="detail-qualification-items">
<article v-for="item in detailQualifications" :key="String(item.id)" class="detail-qualification-item">
<span class="detail-qualification-icon"><FileCheck2 :size="18" /></span>
<div><strong>{{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}</strong><small>{{ item.validTo ? `有效期至 ${item.validTo}` : '长期有效或未设置有效期' }}</small></div>
<el-tag :type="item.status === 'APPROVED' ? 'success' : item.status === 'REJECTED' ? 'danger' : 'warning'" size="small" effect="light">{{ item.status === 'APPROVED' ? '已通过' : item.status === 'REJECTED' ? '已驳回' : '待审核' }}</el-tag>
</article>
<button v-for="item in detailQualifications" :key="String(item.id)" type="button" class="detail-qualification-item" :disabled="detailQualificationPreviews[String(item.id)]?.loading" :aria-label="`查看${qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType}:${detailQualificationName(item)}`" @click="openDetailQualificationPreview(item)">
<span class="detail-qualification-icon">
<img v-if="detailQualificationPreviews[String(item.id)]?.url && detailQualificationPreviews[String(item.id)]?.kind === 'image' && !detailQualificationPreviews[String(item.id)]?.failed" :src="detailQualificationPreviews[String(item.id)]?.url" :alt="`${qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType}缩略图`" @error="handleDetailQualificationPreviewError(item)" />
<FileText v-else :size="20" />
<small v-if="detailQualificationPreviews[String(item.id)]?.kind === 'pdf'">PDF</small>
</span>
<span class="detail-qualification-copy"><strong>{{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}</strong><small class="detail-qualification-file-name">{{ detailQualificationName(item) }}</small><small>{{ item.validTo ? `有效期至 ${item.validTo}` : '长期有效或未设置有效期' }}</small></span>
<span class="detail-qualification-meta"><el-tag :type="item.status === 'APPROVED' ? 'success' : item.status === 'REJECTED' ? 'danger' : 'warning'" size="small" effect="light">{{ item.status === 'APPROVED' ? '已通过' : item.status === 'REJECTED' ? '已驳回' : '待审核' }}</el-tag><span><Eye :size="14" />{{ detailQualificationPreviews[String(item.id)]?.loading ? '读取中' : (detailQualificationPreviews[String(item.id)]?.failed ? '重试' : '查看') }}</span></span>
</button>
</div>
<div v-else class="detail-empty-qualification"><FileCheck2 :size="20" /><span>暂未上传资质文件</span></div>
</section>
@@ -417,9 +609,20 @@ onMounted(loadCustomers)
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span,.detail-title > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(260px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-file { flex-wrap:wrap; gap:8px; } .file-picker { position:relative; display:inline-flex; min-height:36px; align-items:center; gap:7px; overflow:hidden; padding:0 12px; border:1px solid var(--cc-border); border-radius:6px; background:#fff; color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
.qualification-file { gap:12px; } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:8px; } .file-picker { position:relative; display:inline-flex; min-height:36px; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 12px; border:1px solid var(--cc-border); border-radius:6px; background:#fff; color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; max-width:180px; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
.qualification-preview-trigger { position:relative; display:grid; width:68px; height:68px; flex:0 0 68px; overflow:hidden; place-items:center; padding:0; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; color:var(--cc-muted); cursor:pointer; }
.qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); }
.qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
.qualification-preview-trigger:disabled { cursor:wait; opacity:.72; }
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:#fff; }
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
.qualification-preview-placeholder small { font-size:10px; font-weight:700; }
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:#fff; font-size:10px; }
.qualification-preview { display:grid; max-height:min(70vh,760px); min-height:240px; place-items:center; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; }
.qualification-preview img { display:block; max-width:100%; height:auto; }
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:#fff; }
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:12px; padding:10px 12px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:12px; }
.customer-editor :deep(.el-drawer__body) { min-height:0; overflow-y:auto; overscroll-behavior:contain; }
.customer-editor :deep(.el-drawer__footer) { flex:0 0 auto; }
@@ -442,12 +645,22 @@ onMounted(loadCustomers)
.detail-section-heading h4 { margin-bottom:12px; }
.detail-section-heading span { color:var(--cc-muted); font-size:12px; }
.detail-qualification-items { display:flex; flex-direction:column; gap:8px; }
.detail-qualification-item { display:grid; grid-template-columns:36px minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }
.detail-qualification-icon { display:grid; width:36px; height:36px; place-items:center; border-radius:8px; background:#eef4ff; color:var(--cc-primary); }
.detail-qualification-item { display:grid; width:100%; grid-template-columns:52px minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; color:inherit; text-align:left; cursor:pointer; }
.detail-qualification-item:hover { border-color:color-mix(in srgb,var(--cc-primary) 48%,var(--cc-border)); background:#fbfdff; }
.detail-qualification-item:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
.detail-qualification-item:disabled { cursor:wait; opacity:.75; }
.detail-qualification-icon { display:grid; width:52px; height:52px; overflow:hidden; place-items:center; border-radius:8px; background:#eef4ff; color:var(--cc-primary); }
.detail-qualification-icon img { width:100%; height:100%; object-fit:contain; background:#fff; }
.detail-qualification-icon small { font-size:9px; font-weight:700; }
.detail-qualification-copy { min-width:0; }
.detail-qualification-item strong,.detail-qualification-item small { display:block; }
.detail-qualification-item strong { color:var(--cc-text); font-size:13px; }
.detail-qualification-item small { margin-top:4px; color:var(--cc-muted); font-size:11px; line-height:1.4; }
.detail-qualification-file-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.detail-qualification-meta { display:flex; align-items:flex-end; gap:8px; flex-direction:column; }
.detail-qualification-meta > span { display:flex; align-items:center; gap:4px; color:var(--cc-primary); font-size:11px; font-weight:600; }
.detail-empty-qualification { display:flex; min-height:88px; align-items:center; justify-content:center; gap:8px; border:1px dashed var(--cc-border); border-radius:8px; background:#fafbfc; color:var(--cc-muted); font-size:12px; }
@media (max-width:720px) { .customers-error,.customers-table-header,.customer-editor-footer { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .customer-detail { padding:16px; } .customer-detail-grid { grid-template-columns:1fr; } .customer-detail-item,.customer-detail-item:nth-child(odd):not(.is-wide) { grid-column:1; border-right:0; } .customer-detail-item:last-child { border-bottom:0; } }
@media (max-width:480px) { .detail-qualification-item { grid-template-columns:44px minmax(0,1fr); } .detail-qualification-icon { width:44px; height:44px; } .detail-qualification-meta { grid-column:2; align-items:center; flex-direction:row; justify-content:space-between; } }
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
</style>