优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user