Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@chongchong/shared": "link:./shared",
|
||||
"@lucide/vue": "^1.27.0",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
|
||||
Generated
+3
@@ -17,6 +17,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.18.1
|
||||
version: 1.18.1(debug@4.4.3)
|
||||
dayjs:
|
||||
specifier: ^1.11.21
|
||||
version: 1.11.21
|
||||
element-plus:
|
||||
specifier: ^2.14.3
|
||||
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, createApp } from 'vue'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import { createPinia } from 'pinia'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -53,6 +53,7 @@ const router = useRouter()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const customerSelectOpen = ref(false)
|
||||
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||||
const allRows = ref<ArapRow[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -92,14 +93,40 @@ function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown
|
||||
}
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
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[]>(() => [
|
||||
{ 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: '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: 'customerId',
|
||||
type: 'select',
|
||||
label: '客户名称',
|
||||
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: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
||||
])
|
||||
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' },
|
||||
@@ -315,11 +342,15 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('scroll', closeCustomerSelectOnScroll, true)
|
||||
const feeSourceId = String(route.params.feeSourceId || '')
|
||||
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
|
||||
if (canListCustomers.value) await loadCustomers()
|
||||
await load()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', closeCustomerSelectOnScroll, true)
|
||||
})
|
||||
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
|
||||
if (!previous) return
|
||||
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 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">
|
||||
<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 #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>
|
||||
@@ -369,6 +400,25 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
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 {
|
||||
width: 100%;
|
||||
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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
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' } })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
|
||||
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 createSourcesLoading = ref(false)
|
||||
const createSourcesError = ref<ApiErrorDetails | null>(null)
|
||||
const createError = ref<ApiErrorDetails | null>(null)
|
||||
const createSourceOptions = ref<FinanceFeeSourceSummary[]>([])
|
||||
const exportBusy = ref(false)
|
||||
const actionBusy = ref(false)
|
||||
@@ -391,6 +392,7 @@ function openCreate() {
|
||||
createForm.value = defaultCreateForm()
|
||||
createSourceOptions.value = []
|
||||
createSourcesError.value = null
|
||||
createError.value = null
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
@@ -489,7 +491,13 @@ async function createStatement() {
|
||||
createOpen.value = false
|
||||
ElMessage.success('对账单已生成')
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -686,6 +694,7 @@ watch(detailRouteId, (statementId) => {
|
||||
</ListPageLayout>
|
||||
|
||||
<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-item label="生成方式">
|
||||
<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', () => {
|
||||
for (const source of [settlement, statements, arap]) {
|
||||
for (const source of [settlement, statements]) {
|
||||
expect(source).toContain("placeholder: '全部客户'")
|
||||
expect(source).not.toContain('全客户查询暂不可用')
|
||||
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', () => {
|
||||
@@ -84,6 +86,20 @@ describe('finance presentation governance', () => {
|
||||
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', () => {
|
||||
expect(arap).toContain('sourceTypeDisplay(detail.sourceType)')
|
||||
expect(arap).toContain('financeOperationActionLabel(log.action)')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
batchApprovePlatformUnifiedOrders,
|
||||
addPlatformUnifiedOrderNote,
|
||||
getPlatformUnifiedOrder,
|
||||
downloadPlatformProductImage,
|
||||
listPlatformUnifiedOrders,
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
} from './unifiedOrders'
|
||||
@@ -90,6 +91,14 @@ describe('unified orders API contract', () => {
|
||||
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 () => {
|
||||
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) }
|
||||
}
|
||||
|
||||
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) {
|
||||
return http.post<ApiEnvelope<void>, ApiEnvelope<void>>(
|
||||
`/platform/v1/orders/first-leg/${orderId}/approve`,
|
||||
|
||||
@@ -114,4 +114,20 @@ describe('UnifiedOrdersView list request coordination', () => {
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
|
||||
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 {
|
||||
getPlatformUnifiedOrder,
|
||||
downloadPlatformProductImage,
|
||||
approvePlatformUnifiedOrder,
|
||||
batchApprovePlatformUnifiedOrders,
|
||||
addPlatformUnifiedOrderNote,
|
||||
@@ -137,6 +138,7 @@ const measurementPreviewError = ref<ApiErrorDetails | null>(null)
|
||||
const measurementPreviewUrl = ref('')
|
||||
const measurementPreviewTitle = ref('测量图片')
|
||||
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
||||
const cargoImagePreviewUrls = new Set<string>()
|
||||
const measurementDialogOpen = ref(false)
|
||||
const smartLoadingOpen = ref(false)
|
||||
const measurementOrder = ref<UnifiedOrderResource | null>(null)
|
||||
@@ -296,8 +298,10 @@ function trimmed(value: string) {
|
||||
return value.trim() || undefined
|
||||
}
|
||||
|
||||
function optionalBoolean(value: string) {
|
||||
return value === '' ? undefined : value === 'true'
|
||||
function optionalBoolean(value: string | null | undefined) {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function selectStatus(status: UnifiedOrderMainStatus | '') {
|
||||
@@ -449,6 +453,29 @@ function cargoImage(cargo: Record<string, unknown>) {
|
||||
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) {
|
||||
return unifiedOrderReviewStatusLabel(value)
|
||||
}
|
||||
@@ -823,6 +850,7 @@ function runMoreAction(command: string | number | object, row: UnifiedOrderResou
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
releaseCargoImagePreviews()
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
void router.push({ name: 'unified-orders' })
|
||||
@@ -845,12 +873,14 @@ async function loadDetail(force = false) {
|
||||
measurementLoadedKey.value = ''
|
||||
measurementPreviewOpen.value = false
|
||||
releaseMeasurementPreview()
|
||||
releaseCargoImagePreviews()
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
try {
|
||||
const response = await getPlatformUnifiedOrder(tenantId, orderId.value)
|
||||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||||
detail.value = response
|
||||
await loadCargoImagePreviews(response, serial)
|
||||
} catch (error) {
|
||||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||||
detail.value = null
|
||||
@@ -886,6 +916,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
measurementRequestSerial += 1
|
||||
releaseMeasurementPreview()
|
||||
releaseCargoImagePreviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import LayoutPreferenceList from './LayoutPreferenceList.vue'
|
||||
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
||||
|
||||
const TableStub = defineComponent({
|
||||
props: { border: Boolean, stripe: Boolean },
|
||||
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)"><slot /></div>',
|
||||
props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number] },
|
||||
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)" :data-max-height="String(maxHeight)"><slot /></div>',
|
||||
methods: { doLayout() {} },
|
||||
})
|
||||
const ColumnStub = defineComponent({
|
||||
@@ -114,6 +114,20 @@ describe('DataTable column preferences', () => {
|
||||
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', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: {
|
||||
|
||||
@@ -69,6 +69,7 @@ const props = withDefaults(defineProps<{
|
||||
emptyText?: string
|
||||
total?: number | string
|
||||
pageSizes?: number[]
|
||||
maxHeight?: string | number
|
||||
preferenceKey?: string
|
||||
columnSettingsMode?: 'auto' | 'show' | 'hide'
|
||||
border?: boolean
|
||||
@@ -78,6 +79,7 @@ const props = withDefaults(defineProps<{
|
||||
emptyText: '暂无数据',
|
||||
total: undefined,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
maxHeight: 'calc(100vh - 300px)',
|
||||
columnSettingsMode: 'auto',
|
||||
border: true,
|
||||
stripe: true,
|
||||
@@ -302,7 +304,7 @@ function setPage(value: number) {
|
||||
</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
|
||||
v-for="(column, index) in visibleColumns"
|
||||
:key="columnRenderKey(column, index)"
|
||||
|
||||
Reference in New Issue
Block a user