This commit is contained in:
wujinliang
2026-08-25 22:08:59 +08:00
parent f702d1b988
commit 57de615717
13 changed files with 199 additions and 15 deletions
+1
View File
@@ -14,6 +14,7 @@
"@chongchong/shared": "link:./shared", "@chongchong/shared": "link:./shared",
"@lucide/vue": "^1.27.0", "@lucide/vue": "^1.27.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"dayjs": "^1.11.21",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
+3
View File
@@ -17,6 +17,9 @@ importers:
axios: axios:
specifier: ^1.18.1 specifier: ^1.18.1
version: 1.18.1(debug@4.4.3) version: 1.18.1(debug@4.4.3)
dayjs:
specifier: ^1.11.21
version: 1.11.21
element-plus: element-plus:
specifier: ^2.14.3 specifier: ^2.14.3
version: 2.14.3(vue@3.5.40(typescript@5.9.3)) version: 2.14.3(vue@3.5.40(typescript@5.9.3))
+1
View File
@@ -1,4 +1,5 @@
import { computed, createApp } from 'vue' import { computed, createApp } from 'vue'
import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import 'element-plus/es/components/message/style/css' import 'element-plus/es/components/message/style/css'
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue' import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -53,6 +53,7 @@ const router = useRouter()
const customers = ref<PlatformTenantResource[]>([]) const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false) const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null) const customersError = ref<ApiErrorDetails | null>(null)
const customerSelectOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' }) const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
const allRows = ref<ArapRow[]>([]) const allRows = ref<ArapRow[]>([])
const loading = ref(false) const loading = ref(false)
@@ -92,14 +93,40 @@ function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown
} }
const stateError = computed(() => customersError.value || loadError.value) const stateError = computed(() => customersError.value || loadError.value)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
function onCustomerSelectVisible(visible: boolean) {
customerSelectOpen.value = visible
}
function closeCustomerSelectOnScroll() {
if (!customerSelectOpen.value || typeof document === 'undefined') return
const input = document.querySelector('.finance-customer-select input') as HTMLInputElement | null
input?.blur()
customerSelectOpen.value = false
}
const queryFields = computed<QueryField[]>(() => [ const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }, {
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] }, key: 'customerId',
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '全部状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] }, type: 'select',
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions }, label: '客户名称',
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '全部', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] }, width: 260,
placeholder: '请选择客户',
props: {
'aria-label': '客户名称',
class: 'finance-customer-select',
popperClass: 'finance-customer-arap-popper',
teleported: false,
onVisibleChange: onCustomerSelectVisible,
filterable: true,
clearable: true,
loading: customersLoading.value,
},
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
},
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 }, { key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } }, { key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' }, { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
@@ -315,11 +342,15 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
} }
onMounted(async () => { onMounted(async () => {
document.addEventListener('scroll', closeCustomerSelectOnScroll, true)
const feeSourceId = String(route.params.feeSourceId || '') const feeSourceId = String(route.params.feeSourceId || '')
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return } if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
if (canListCustomers.value) await loadCustomers() if (canListCustomers.value) await loadCustomers()
await load() await load()
}) })
onBeforeUnmount(() => {
document.removeEventListener('scroll', closeCustomerSelectOnScroll, true)
})
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => { watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
if (!previous) return if (!previous) return
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId)) if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
@@ -335,7 +366,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template> <template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template> <template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"> <section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template> <template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template> <template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template> <template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
@@ -369,6 +400,25 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
min-width: 0; min-width: 0;
} }
/* Customer names can contain an order suffix; keep the selector readable without changing the query value. */
:deep(.finance-customer-select .el-select__selected-item) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.finance-customer-arap-popper) {
width: max(260px, min(360px, calc(100vw - 32px))) !important;
max-width: calc(100vw - 32px);
}
:deep(.finance-customer-arap-popper .el-select-dropdown__item) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.finance-arap-detail { .finance-arap-detail {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -7,6 +7,7 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
@@ -292,6 +293,30 @@ describe('FinanceCustomerStatementsView', () => {
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } }) expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
}) })
it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => {
const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const { wrapper } = await mountPage(permissions)
const vm = wrapper.vm as unknown as {
createOpen: boolean
createError: { message: string } | null
createForm: { customerId: string; periodStart: string; periodEnd: string; generationMode: string; feeSourceIds: string[] }
openCreate(): void
createStatement(): Promise<void>
}
vm.openCreate()
vm.createForm.customerId = '10001'
vm.createForm.generationMode = 'BY_CUTOFF'
vm.createForm.periodStart = '2026-08-01'
vm.createForm.periodEnd = '2026-08-25'
vi.mocked(createReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('没有符合结算条件的CNY应收费用来源', 'FIN_409'))
await vm.createStatement()
await flushPromises()
expect(vm.createOpen).toBe(true)
expect(vm.createError?.message).toContain('没有可生成的应收费用')
expect(document.body.textContent).toContain('请调整账期或选择其他客户后重试')
})
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => { it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
const { wrapper } = await mountPage(['platform:finance:customer-statement:list']) const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement) await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
@@ -63,6 +63,7 @@ const createOpen = ref(false)
const createBusy = ref(false) const createBusy = ref(false)
const createSourcesLoading = ref(false) const createSourcesLoading = ref(false)
const createSourcesError = ref<ApiErrorDetails | null>(null) const createSourcesError = ref<ApiErrorDetails | null>(null)
const createError = ref<ApiErrorDetails | null>(null)
const createSourceOptions = ref<FinanceFeeSourceSummary[]>([]) const createSourceOptions = ref<FinanceFeeSourceSummary[]>([])
const exportBusy = ref(false) const exportBusy = ref(false)
const actionBusy = ref(false) const actionBusy = ref(false)
@@ -391,6 +392,7 @@ function openCreate() {
createForm.value = defaultCreateForm() createForm.value = defaultCreateForm()
createSourceOptions.value = [] createSourceOptions.value = []
createSourcesError.value = null createSourcesError.value = null
createError.value = null
createOpen.value = true createOpen.value = true
} }
@@ -489,7 +491,13 @@ async function createStatement() {
createOpen.value = false createOpen.value = false
ElMessage.success('对账单已生成') ElMessage.success('对账单已生成')
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } }) await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } })
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') } } catch (cause) {
const details = getApiErrorDetails(cause, '创建对账单失败')
const noEligibleSources = details.code === 'FIN_409' && /没有符合结算条件|没有可生成/.test(details.message)
createError.value = noEligibleSources
? { ...details, message: '当前客户在所选账期内没有可生成的应收费用,请调整账期或选择其他客户后重试' }
: details
}
finally { createBusy.value = false } finally { createBusy.value = false }
} }
@@ -686,6 +694,7 @@ watch(detailRouteId, (statementId) => {
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"> <AppDialog v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="生成方式"> <el-form-item label="生成方式">
<el-radio-group v-model="createForm.generationMode"> <el-radio-group v-model="createForm.generationMode">
@@ -67,11 +67,13 @@ describe('finance presentation governance', () => {
}) })
it('uses optional all-customer filters without a customer-selection blocker', () => { it('uses optional all-customer filters without a customer-selection blocker', () => {
for (const source of [settlement, statements, arap]) { for (const source of [settlement, statements]) {
expect(source).toContain("placeholder: '全部客户'") expect(source).toContain("placeholder: '全部客户'")
expect(source).not.toContain('全客户查询暂不可用') expect(source).not.toContain('全客户查询暂不可用')
expect(source).not.toContain('empty-text="请选择客户查询') expect(source).not.toContain('empty-text="请选择客户查询')
} }
expect(arap).toContain("placeholder: '请选择客户'")
expect(arap).not.toContain("placeholder: '全部客户'")
}) })
it('uses active-customer name selectors across all formal finance pages', () => { it('uses active-customer name selectors across all formal finance pages', () => {
@@ -84,6 +86,20 @@ describe('finance presentation governance', () => {
expect(accounts).not.toContain("key: 'keyword', type: 'input'") expect(accounts).not.toContain("key: 'keyword', type: 'input'")
}) })
it('keeps customer arap selector text within a stable dropdown layout', () => {
expect(arap).toContain("class: 'finance-customer-select'")
expect(arap).toContain("popperClass: 'finance-customer-arap-popper'")
expect(arap).toContain('teleported: false')
expect(arap).toContain('onVisibleChange: onCustomerSelectVisible')
expect(arap).toContain("document.addEventListener('scroll', closeCustomerSelectOnScroll, true)")
expect(arap).toContain('.finance-customer-arap-popper .el-select-dropdown__item')
expect(arap).toContain('text-overflow: ellipsis;')
})
it('keeps the customer arap table body independently scrollable', () => {
expect(arap).toContain('max-height="calc(100vh - 300px)"')
})
it('translates source codes and provides reason placeholders', () => { it('translates source codes and provides reason placeholders', () => {
expect(arap).toContain('sourceTypeDisplay(detail.sourceType)') expect(arap).toContain('sourceTypeDisplay(detail.sourceType)')
expect(arap).toContain('financeOperationActionLabel(log.action)') expect(arap).toContain('financeOperationActionLabel(log.action)')
@@ -5,6 +5,7 @@ import {
batchApprovePlatformUnifiedOrders, batchApprovePlatformUnifiedOrders,
addPlatformUnifiedOrderNote, addPlatformUnifiedOrderNote,
getPlatformUnifiedOrder, getPlatformUnifiedOrder,
downloadPlatformProductImage,
listPlatformUnifiedOrders, listPlatformUnifiedOrders,
listPlatformUnifiedOrderFeeDetails, listPlatformUnifiedOrderFeeDetails,
} from './unifiedOrders' } from './unifiedOrders'
@@ -90,6 +91,14 @@ describe('unified orders API contract', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } }) expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } })
}) })
it('downloads a tenant-scoped product image for platform order details', async () => {
const image = new Blob(['image'], { type: 'image/png' })
vi.mocked(http.get).mockResolvedValue(image)
await expect(downloadPlatformProductImage('208/1', '90001')).resolves.toBe(image)
expect(http.get).toHaveBeenCalledWith('/platform/v1/files/product-images/208%2F1/90001', { responseType: 'blob' })
})
it('uses the explicit first-leg approval boundary for pending review', async () => { it('uses the explicit first-leg approval boundary for pending review', async () => {
vi.mocked(http.post).mockResolvedValue({ data: null }) vi.mocked(http.post).mockResolvedValue({ data: null })
@@ -395,6 +395,13 @@ export async function getPlatformUnifiedOrder(tenantId: string, orderId: string)
return { ...result, summary: normalizeRow(result.summary) } return { ...result, summary: normalizeRow(result.summary) }
} }
export function downloadPlatformProductImage(tenantId: string, fileId: string) {
return http.get<Blob, Blob>(
`/platform/v1/files/product-images/${encodeURIComponent(tenantId)}/${encodeURIComponent(fileId)}`,
{ responseType: 'blob' },
)
}
export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) { export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) {
return http.post<ApiEnvelope<void>, ApiEnvelope<void>>( return http.post<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/orders/first-leg/${orderId}/approve`, `/platform/v1/orders/first-leg/${orderId}/approve`,
@@ -114,4 +114,20 @@ describe('UnifiedOrdersView list request coordination', () => {
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4) expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 })) expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 }))
}) })
it('does not turn a cleared full-container filter into false', async () => {
const wrapper = mountView()
await flushPromises()
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
const selected = { ...toolbar.props('modelValue'), fullContainer: 'true' }
toolbar.vm.$emit('update:modelValue', selected)
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: true }))
toolbar.vm.$emit('update:modelValue', { ...selected, fullContainer: undefined })
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: undefined }))
})
}) })
@@ -5,6 +5,7 @@ import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, Hi
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
getPlatformUnifiedOrder, getPlatformUnifiedOrder,
downloadPlatformProductImage,
approvePlatformUnifiedOrder, approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders, batchApprovePlatformUnifiedOrders,
addPlatformUnifiedOrderNote, addPlatformUnifiedOrderNote,
@@ -137,6 +138,7 @@ const measurementPreviewError = ref<ApiErrorDetails | null>(null)
const measurementPreviewUrl = ref('') const measurementPreviewUrl = ref('')
const measurementPreviewTitle = ref('测量图片') const measurementPreviewTitle = ref('测量图片')
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false }) const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
const cargoImagePreviewUrls = new Set<string>()
const measurementDialogOpen = ref(false) const measurementDialogOpen = ref(false)
const smartLoadingOpen = ref(false) const smartLoadingOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null) const measurementOrder = ref<UnifiedOrderResource | null>(null)
@@ -296,8 +298,10 @@ function trimmed(value: string) {
return value.trim() || undefined return value.trim() || undefined
} }
function optionalBoolean(value: string) { function optionalBoolean(value: string | null | undefined) {
return value === '' ? undefined : value === 'true' if (value === 'true') return true
if (value === 'false') return false
return undefined
} }
function selectStatus(status: UnifiedOrderMainStatus | '') { function selectStatus(status: UnifiedOrderMainStatus | '') {
@@ -449,6 +453,29 @@ function cargoImage(cargo: Record<string, unknown>) {
return typeof value === 'string' && value ? value : '' return typeof value === 'string' && value ? value : ''
} }
function releaseCargoImagePreviews() {
cargoImagePreviewUrls.forEach((url) => URL.revokeObjectURL(url))
cargoImagePreviewUrls.clear()
}
async function loadCargoImagePreviews(resource: UnifiedOrderDetailResource, serial: number) {
const tenantId = String(resource.summary.tenantId || '')
if (!tenantId) return
await Promise.all((resource.order?.cargoes ?? []).map(async (cargo) => {
const fileId = cargo.productImageFileId
if (fileId === null || fileId === undefined || fileId === '') return
try {
const blob = await downloadPlatformProductImage(tenantId, String(fileId))
if (serial !== detailRequestSerial || detailRouteKey.value !== `${tenantId}:${orderId.value}`) return
const url = URL.createObjectURL(blob)
cargo.imageUrl = url
cargoImagePreviewUrls.add(url)
} catch {
// Historical files may have been removed; keep the remaining cargo fields visible.
}
}))
}
function feeReviewLabel(value: string | null | undefined) { function feeReviewLabel(value: string | null | undefined) {
return unifiedOrderReviewStatusLabel(value) return unifiedOrderReviewStatusLabel(value)
} }
@@ -823,6 +850,7 @@ function runMoreAction(command: string | number | object, row: UnifiedOrderResou
} }
function closeDetail() { function closeDetail() {
releaseCargoImagePreviews()
detail.value = null detail.value = null
detailError.value = null detailError.value = null
void router.push({ name: 'unified-orders' }) void router.push({ name: 'unified-orders' })
@@ -845,12 +873,14 @@ async function loadDetail(force = false) {
measurementLoadedKey.value = '' measurementLoadedKey.value = ''
measurementPreviewOpen.value = false measurementPreviewOpen.value = false
releaseMeasurementPreview() releaseMeasurementPreview()
releaseCargoImagePreviews()
detailLoading.value = true detailLoading.value = true
detailError.value = null detailError.value = null
try { try {
const response = await getPlatformUnifiedOrder(tenantId, orderId.value) const response = await getPlatformUnifiedOrder(tenantId, orderId.value)
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = response detail.value = response
await loadCargoImagePreviews(response, serial)
} catch (error) { } catch (error) {
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = null detail.value = null
@@ -886,6 +916,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
measurementRequestSerial += 1 measurementRequestSerial += 1
releaseMeasurementPreview() releaseMeasurementPreview()
releaseCargoImagePreviews()
}) })
</script> </script>
+16 -2
View File
@@ -6,8 +6,8 @@ import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const TableStub = defineComponent({ const TableStub = defineComponent({
props: { border: Boolean, stripe: Boolean }, props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number] },
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)"><slot /></div>', template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)" :data-max-height="String(maxHeight)"><slot /></div>',
methods: { doLayout() {} }, methods: { doLayout() {} },
}) })
const ColumnStub = defineComponent({ const ColumnStub = defineComponent({
@@ -93,6 +93,20 @@ describe('DataTable column preferences', () => {
expect(plainTable.get('.table-stub').attributes()).toMatchObject({ 'data-border': 'false', 'data-stripe': 'false' }) expect(plainTable.get('.table-stub').attributes()).toMatchObject({ 'data-border': 'false', 'data-stripe': 'false' })
}) })
it('keeps table bodies within the viewport by default and allows a custom height', () => {
const defaultTable = mountTable()
expect(defaultTable.get('.table-stub').attributes('data-max-height')).toBe('calc(100vh - 300px)')
const customTable = shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, maxHeight: 420 },
global: {
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
directives: { loading: {} },
},
})
expect(customTable.get('.table-stub').attributes('data-max-height')).toBe('420')
})
it('uses key as the data property for ordinary columns only', () => { it('uses key as the data property for ordinary columns only', () => {
const wrapper = shallowMount(DataTable, { const wrapper = shallowMount(DataTable, {
props: { props: {
+3 -1
View File
@@ -62,6 +62,7 @@ const props = withDefaults(defineProps<{
emptyText?: string emptyText?: string
total?: number total?: number
pageSizes?: number[] pageSizes?: number[]
maxHeight?: string | number
preferenceKey?: string preferenceKey?: string
columnSettingsMode?: 'auto' | 'show' | 'hide' columnSettingsMode?: 'auto' | 'show' | 'hide'
border?: boolean border?: boolean
@@ -71,6 +72,7 @@ const props = withDefaults(defineProps<{
emptyText: '暂无数据', emptyText: '暂无数据',
total: undefined, total: undefined,
pageSizes: () => [10, 20, 50, 100], pageSizes: () => [10, 20, 50, 100],
maxHeight: 'calc(100vh - 300px)',
columnSettingsMode: 'auto', columnSettingsMode: 'auto',
border: true, border: true,
stripe: true, stripe: true,
@@ -298,7 +300,7 @@ function setPage(value: number) {
</div> </div>
</div> </div>
</div> </div>
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :border="border" :stripe="stripe" :aria-busy="loading"> <el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :border="border" :stripe="stripe" :max-height="maxHeight" :aria-busy="loading">
<el-table-column <el-table-column
v-for="(column, index) in visibleColumns" v-for="(column, index) in visibleColumns"
:key="columnRenderKey(column, index)" :key="columnRenderKey(column, index)"