This commit is contained in:
2026-08-06 15:32:02 +08:00
parent 9e7e1afac8
commit 33a0c48b78
22 changed files with 685 additions and 402 deletions
@@ -32,7 +32,7 @@ describe('CarrierRatesView source contract', () => {
expect(source.indexOf("key: 'customerId'")).toBeLessThan(source.indexOf("key: 'rateCode'"))
expect(source).toContain("'aria-label': '所属企业'")
expect(source).toContain('listPlatformTenants')
expect(source).toContain('loadedCustomerId')
expect(source).toContain('masterDataLoaded')
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('selectedTenantId')
expect(source).not.toContain('租户')
@@ -69,6 +69,13 @@ describe('CarrierRatesView source contract', () => {
expect(source).toContain('平台仓库或船司资料加载失败')
expect(source).not.toContain('listPlatformPartners(customerId,')
expect(source).not.toContain('所属企业的仓库或船司资料加载失败')
expect(source).toContain('async function loadPlatformMasterData()')
expect(source).not.toContain('loadCustomerMasterData')
})
it('keeps customer rate queries independent from edit-only master data', () => {
expect(source).toContain('await loadRateData(customerId)')
expect(source).not.toContain('loadPlatformMasterData(customerId)')
})
it('inherits QueryToolbar wrapping and full-width mobile controls', () => {
@@ -54,7 +54,7 @@ const access = useAccessPreviewStore()
const customers = ref<PlatformCustomerResource[]>([])
const customersLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const loadedCustomerId = ref<string | null>(null)
const masterDataLoaded = ref(false)
const rows = ref<PlatformCarrierRateResource[]>([])
const warehouses = ref<PlatformWarehouseResource[]>([])
const carriers = ref<PlatformPartnerResource[]>([])
@@ -188,12 +188,7 @@ const rules: FormRules<RateForm> = {
effectiveTo: [{ validator: validateEffectiveTo, trigger: 'change' }],
}
watch(() => filters.value.customerId, (customerId) => {
if (customerId === loadedCustomerId.value) return
masterDataSequence += 1
warehouses.value = []
carriers.value = []
loadedCustomerId.value = null
watch(() => filters.value.customerId, () => {
page.value = 1
})
@@ -269,8 +264,8 @@ async function loadRateData(customerId = filters.value.customerId) {
}
}
async function loadCustomerMasterData(customerId: string) {
if (!customerId) return false
async function loadPlatformMasterData() {
if (masterDataLoaded.value) return true
const sequence = ++masterDataSequence
masterDataLoading.value = true
try {
@@ -281,13 +276,13 @@ async function loadCustomerMasterData(customerId: string) {
if (sequence !== masterDataSequence) return false
warehouses.value = warehouseResult.records.map((item) => ({ ...item, id: String(item.id), tenantId: String(item.tenantId) }))
carriers.value = partnerResult.records.map((item) => ({ ...item, id: String(item.id), tenantId: String(item.tenantId), dataScopeId: String(item.dataScopeId) }))
loadedCustomerId.value = customerId
masterDataLoaded.value = true
return true
} catch (error) {
if (sequence === masterDataSequence) {
warehouses.value = []
carriers.value = []
loadedCustomerId.value = null
masterDataLoaded.value = false
ElMessage.error(getApiErrorDetails(error, '平台仓库或船司资料加载失败').message)
}
return false
@@ -307,10 +302,7 @@ async function applyQuery(model: QueryModel) {
status: (model.status || '') as CarrierRateStatus | '',
}
page.value = 1
await Promise.all([
loadRateData(customerId),
customerId && loadedCustomerId.value !== customerId ? loadCustomerMasterData(customerId) : Promise.resolve(true),
])
await loadRateData(customerId)
}
function resetQuery(model: QueryModel) {
@@ -349,8 +341,8 @@ async function openCreate(rate?: PlatformCarrierRateResource) {
ElMessage.warning('新增价格版本需要先选择所属企业')
return
}
if (loadedCustomerId.value !== customerId) {
const loaded = await loadCustomerMasterData(customerId)
if (!masterDataLoaded.value) {
const loaded = await loadPlatformMasterData()
if (!loaded) return
}
editorCustomerId.value = customerId
@@ -65,7 +65,7 @@ describe('platform partner API', () => {
it('normalizes legacy detail types and uses import contracts', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: {
id: '19001', tenantId: 10001, partnerCode: 'CUS-001', partnerName: '测试单位',
id: '19001', tenantId: 0, dataScopeId: 0, partnerCode: 'CUS-001', partnerName: '测试单位',
partnerType: 'BOTH', contactName: '张三', contactPhone: '13800000000', contactEmail: null,
countryCode: 'CN', province: null, city: '深圳市', district: null, addressLine: '测试地址',
postalCode: null, status: 'ACTIVE',
@@ -81,7 +81,7 @@ describe('platform partner API', () => {
const result = await importPlatformPartners(file)
expect(detail.types).toEqual(['CUSTOMER', 'SUPPLIER'])
expect(detail.dataScopeId).toBe(10001)
expect(detail.dataScopeId).toBe(0)
expect(template).toBeInstanceOf(Blob)
expect(result.success).toBe(true)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/partners/19001', { params: { tenantId: 0 } })
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { Building2, Download, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Building2, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
@@ -15,7 +16,6 @@ import {
importPlatformPartners,
listPlatformPartners,
updatePlatformPartner,
type PlatformPartnerImportResult,
type PlatformPartnerResource,
type PlatformPartnerStatus,
type PlatformPartnerLegacyType,
@@ -33,10 +33,6 @@ const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformPartnerResource | null>(null)
const importOpen = ref(false)
const importFile = ref<File | null>(null)
const importing = ref(false)
const downloadingTemplate = ref(false)
const importResult = ref<PlatformPartnerImportResult | null>(null)
const formRef = ref<FormInstance>()
const rows = ref<PlatformPartnerResource[]>([])
const page = ref(1)
@@ -278,74 +274,9 @@ async function openDetail(row: PlatformPartnerResource) {
}
function openImport() {
importFile.value = null
importResult.value = null
importOpen.value = true
}
function selectImportFile(uploadFile: UploadFile) {
importFile.value = uploadFile.raw ?? null
importResult.value = null
}
function removeImportFile() {
importFile.value = null
}
async function downloadTemplate() {
downloadingTemplate.value = true
try {
const blob = await downloadPlatformPartnerImportTemplate()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '往来单位导入模板-v1.xlsx'
link.click()
URL.revokeObjectURL(url)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '模板下载失败,请重试').message)
} finally {
downloadingTemplate.value = false
}
}
async function submitImport() {
if (!importFile.value) {
ElMessage.warning('请先选择 XLSX 文件')
return
}
importing.value = true
importResult.value = null
try {
importResult.value = await importPlatformPartners(importFile.value)
if (importResult.value.success) {
ElMessage.success(`成功导入 ${importResult.value.importedRows} 条往来单位`)
await loadPartners()
}
} catch (error) {
const meta = getApiErrorDetails(error, '往来单位导入失败,请重试')
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
} finally {
importing.value = false
}
}
function downloadImportErrors() {
if (!importResult.value?.errors.length) return
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const rows = [
['行号', '字段', '原因'],
...importResult.value.errors.map((error) => [error.row || '-', error.field, error.message]),
]
const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\r\n')
const url = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `${importResult.value.fileName.replace(/\.xlsx$/i, '')}-错误报告.csv`
link.click()
URL.revokeObjectURL(url)
}
async function savePartner() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
@@ -507,23 +438,15 @@ onMounted(() => {
</div>
</el-drawer>
<el-dialog v-model="importOpen" title="批量导入往来单位" width="min(720px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
<div class="import-content">
<div class="import-guide"><strong>使用标准模板导入</strong><p>仅新增档案,最多 1000 行。服务端会整批校验;任一行有误时不会写入任何数据。</p><el-button :loading="downloadingTemplate" @click="downloadTemplate"><Download :size="16" />下载 XLSX 模板</el-button></div>
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
<el-button><Upload :size="16" />选择 XLSX 文件</el-button>
<template #tip><div class="el-upload__tip">文件不得超过 5MB,请勿修改模板版本或表头。</div></template>
</el-upload>
<div v-if="importResult" :class="['import-result', importResult.success ? 'is-success' : 'is-error']" role="status">
<div class="import-result-heading"><strong>{{ importResult.success ? `已成功导入 ${importResult.importedRows} 条记录` : `发现 ${importResult.errors.length} 个问题,未写入数据` }}</strong><el-button v-if="importResult.errors.length" size="small" @click="downloadImportErrors"><Download :size="15" />下载错误报告</el-button></div>
<el-table v-if="importResult.errors.length" :data="importResult.errors" max-height="260" size="small">
<el-table-column prop="row" label="行号" width="70"><template #default="{ row }">{{ row.row || '-' }}</template></el-table-column>
<el-table-column prop="field" label="字段" width="120" /><el-table-column prop="message" label="原因" min-width="240" />
</el-table>
</div>
</div>
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button type="primary" :loading="importing" :disabled="!importFile" @click="submitImport">开始导入</el-button></template>
</el-dialog>
<BatchImportDialog
v-model="importOpen"
title="批量导入往来单位"
entity-label="往来单位"
template-file-name="往来单位导入模板-v1.xlsx"
:download-template="downloadPlatformPartnerImportTemplate"
:import-file="importPlatformPartners"
@success="loadPartners"
/>
</div>
</template>
@@ -551,14 +474,6 @@ onMounted(() => {
.detail-grid .detail-span { grid-column: 1 / -1; }
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
.import-content { display: grid; gap: 18px; }
.import-guide { padding: 16px; border: 1px solid #d8e2f0; border-radius: 8px; background: #f8fbff; }
.import-guide p { margin: 6px 0 14px; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
.import-result { display: grid; gap: 12px; padding: 14px; border: 1px solid; border-radius: 8px; }
.import-result-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.import-result.is-success { border-color: #a6f4c5; background: #f6fef9; color: #067647; }
.import-result.is-error { border-color: #fecdca; background: #fffafa; color: #b42318; }
@media (max-width: 720px) {
.partners-error { align-items: stretch; flex-direction: column; }
.partners-error .el-button { min-height: 44px; }
@@ -568,6 +483,5 @@ onMounted(() => {
.partner-detail { padding: 16px; }
.detail-grid { grid-template-columns: minmax(0, 1fr); }
.detail-grid .detail-span { grid-column: auto; }
.import-result-heading { align-items: stretch; flex-direction: column; }
}
</style>
@@ -10,6 +10,8 @@ import ExpressCompaniesView from './ExpressCompaniesView.vue'
vi.mock('@/modules/master-data/express-companies/api/platformExpressCompanies', () => ({
listPlatformExpressCompanies: vi.fn(),
createPlatformExpressCompany: vi.fn(),
downloadPlatformExpressCompanyImportTemplate: vi.fn(),
importPlatformExpressCompanies: vi.fn(),
updatePlatformExpressCompany: vi.fn(),
changePlatformExpressCompanyStatus: vi.fn(),
}))
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Download, ExternalLink, FileSpreadsheet, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
import { ExternalLink, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
@@ -15,7 +16,6 @@ import {
importPlatformExpressCompanies,
listPlatformExpressCompanies,
updatePlatformExpressCompany,
type PlatformExpressCompanyImportResult,
type PlatformExpressCompanyResource,
type PlatformExpressCompanyStatus,
type SavePlatformExpressCompanyRequest,
@@ -28,7 +28,6 @@ const saving = ref(false)
const editorOpen = ref(false)
const importOpen = ref(false)
const formRef = ref<FormInstance>()
const fileInputRef = ref<HTMLInputElement>()
const rows = ref<PlatformExpressCompanyResource[]>([])
const page = ref(1)
const pageSize = ref(20)
@@ -36,11 +35,6 @@ const total = ref(0)
const loadError = ref<ApiErrorDetails | null>(null)
const statusLoadingIds = ref(new Set<string>())
const editLoadingIds = ref(new Set<string>())
const downloadingTemplate = ref(false)
const importing = ref(false)
const importFile = ref<File | null>(null)
const importResult = ref<PlatformExpressCompanyImportResult | null>(null)
const importError = ref<ApiErrorDetails | null>(null)
type ExpressCompanyFilters = QueryModel & {
keyword: string
status: PlatformExpressCompanyStatus | ''
@@ -81,7 +75,6 @@ const rules: FormRules<typeof form> = {
}
const editorTitle = computed(() => form.id ? '编辑快递公司' : '新增快递公司')
const editorPermission = computed(() => form.id ? 'platform:express:edit' : 'platform:express:create')
const displayedImportErrors = computed(() => importResult.value?.errors.slice(0, 100) || [])
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'express-company-editor',
label: editorTitle,
@@ -207,92 +200,9 @@ async function openEdit(row: PlatformExpressCompanyResource) {
}
function openImport() {
importFile.value = null
importResult.value = null
importError.value = null
if (fileInputRef.value) fileInputRef.value.value = ''
importOpen.value = true
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadImportTemplate() {
downloadingTemplate.value = true
try {
const blob = await downloadPlatformExpressCompanyImportTemplate()
saveBlob(blob, '快递公司批量导入模板-v1.xlsx')
} catch (error) {
const details = getApiErrorDetails(error, '导入模板下载失败,请稍后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
downloadingTemplate.value = false
}
}
function selectImportFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0] || null
importResult.value = null
importError.value = null
if (!file) {
importFile.value = null
return
}
if (!file.name.toLowerCase().endsWith('.xlsx')) {
importFile.value = null
ElMessage.error('请选择 .xlsx 格式的标准模板文件')
if (fileInputRef.value) fileInputRef.value.value = ''
return
}
if (file.size > 5 * 1024 * 1024) {
importFile.value = null
ElMessage.error('导入文件不能超过 5MB')
if (fileInputRef.value) fileInputRef.value.value = ''
return
}
importFile.value = file
}
async function submitImport() {
if (!importFile.value) {
ElMessage.warning('请先选择填写完成的 XLSX 文件')
return
}
importing.value = true
importError.value = null
importResult.value = null
try {
const result = await importPlatformExpressCompanies(importFile.value)
importResult.value = result
if (result.success) {
ElMessage.success(`成功导入 ${result.importedRows} 家快递公司`)
importFile.value = null
if (fileInputRef.value) fileInputRef.value.value = ''
await loadExpressCompanies()
}
} catch (error) {
importError.value = getApiErrorDetails(error, '快递公司导入失败,请稍后重试')
} finally {
importing.value = false
}
}
function downloadImportErrors() {
if (!importResult.value?.errors.length) return
const csvCell = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const lines = [
['行号', '字段', '错误原因'].map(csvCell).join(','),
...importResult.value.errors.map((item) => [item.row || '文件', item.field, item.message].map(csvCell).join(',')),
]
saveBlob(new Blob([`\ufeff${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), '快递公司导入错误报告.csv')
}
function buildPayload(): SavePlatformExpressCompanyRequest {
return {
companyCode: form.companyCode.trim(),
@@ -432,60 +342,17 @@ onMounted(() => {
</DataTable>
</section>
<el-dialog
<BatchImportDialog
v-model="importOpen"
title="批量导入快递公司"
width="min(680px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!importing"
:show-close="!importing"
class="import-dialog"
>
<div class="import-flow">
<section class="import-step">
<div class="step-heading"><span>1</span><div><strong>下载标准模板</strong><p>请勿修改模板版本或表头,仅填写模板中的数据行。</p></div></div>
<el-button :loading="downloadingTemplate" @click="downloadImportTemplate"><Download :size="16" />下载模板</el-button>
</section>
<section class="import-step">
<div class="step-heading"><span>2</span><div><strong>选择填写后的文件</strong><p>仅支持 .xlsx,最大 5MB、1000 行;本期只新增,不更新已有档案。</p></div></div>
<label class="file-picker" :class="{ disabled: importing }">
<input ref="fileInputRef" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" :disabled="importing" @change="selectImportFile" />
<FileSpreadsheet :size="20" />
<span>{{ importFile?.name || '选择 XLSX 文件' }}</span>
</label>
</section>
<el-alert type="info" :closable="false" show-icon title="整批校验通过后才会写入;任一行有误时整批不写入。" />
<el-alert v-if="importError" type="error" :closable="false" show-icon title="导入请求失败">
<p>{{ importError.message }}</p>
<ApiErrorMeta :details="importError" />
</el-alert>
<el-alert v-if="importResult?.success" type="success" :closable="false" show-icon :title="`导入完成:成功新增 ${importResult.importedRows} 家快递公司`" />
<section v-if="importResult && !importResult.success" class="import-errors" aria-live="polite">
<div class="error-heading">
<div><strong>校验未通过,未写入任何数据</strong><p>发现 {{ importResult.errors.length }} 项问题,请修正后重新上传。</p></div>
<el-button size="small" @click="downloadImportErrors"><Download :size="14" />下载错误报告</el-button>
</div>
<div class="error-list">
<div v-for="(item, index) in displayedImportErrors" :key="`${item.row}-${item.field}-${index}`" class="error-item">
<span>{{ item.row > 0 ? `第 ${item.row} 行` : '文件' }}</span>
<strong>{{ item.field }}</strong>
<p>{{ item.message }}</p>
</div>
<p v-if="importResult.errors.length > displayedImportErrors.length" class="error-overflow-note">
仅展示前 {{ displayedImportErrors.length }} 项,完整内容请下载错误报告。
</p>
</div>
</section>
</div>
<template #footer>
<div class="dialog-footer">
<el-button :disabled="importing" @click="importOpen = false">关闭</el-button>
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="submitImport"><Upload :size="16" />开始导入</el-button>
</div>
</template>
</el-dialog>
entity-label="快递公司"
success-unit="家"
template-file-name="快递公司批量导入模板-v1.xlsx"
error-report-file-name="快递公司导入错误报告.csv"
:download-template="downloadPlatformExpressCompanyImportTemplate"
:import-file="importPlatformExpressCompanies"
@success="loadExpressCompanies"
/>
<el-drawer v-model="editorOpen" :title="editorTitle" size="580px" class="business-drawer editor-drawer" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
@@ -528,32 +395,7 @@ onMounted(() => {
.form-help code { color: #475467; font-family: "Cascadia Code", Consolas, monospace; }
.panel-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.panel-actions .el-button { margin-left: 0; }
.import-flow { display: grid; gap: 16px; }
.import-step { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fafbfc; }
.step-heading { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
.step-heading > span { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--cc-primary); color: #fff; font-size: 12px; font-weight: 700; }
.step-heading strong, .error-heading strong { color: var(--cc-text); font-size: 14px; }
.step-heading p, .error-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.file-picker { display: inline-flex; min-width: 180px; max-width: 260px; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-primary); cursor: pointer; }
.file-picker:hover { border-color: var(--cc-primary); }
.file-picker:focus-within { outline: 2px solid color-mix(in srgb, var(--cc-primary) 28%, transparent); outline-offset: 2px; }
.file-picker.disabled { cursor: not-allowed; opacity: .6; }
.file-picker input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.file-picker span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.import-flow .el-alert p { margin: 5px 0 0; }
.import-errors { overflow: hidden; border: 1px solid #f4c7c7; border-radius: 8px; }
.error-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #fff5f5; }
.error-list { max-height: 220px; overflow: auto; }
.error-item { display: grid; grid-template-columns: 72px 130px minmax(0, 1fr); gap: 10px; padding: 10px 16px; border-top: 1px solid #f4dddd; font-size: 12px; }
.error-item span { color: #b42318; font-weight: 600; }
.error-item p { margin: 0; overflow-wrap: anywhere; }
.error-overflow-note { margin: 0; padding: 10px 16px; border-top: 1px solid #f4dddd; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) {
.panel-actions, .panel-actions .el-button { width: 100%; }
.import-step, .error-heading { align-items: stretch; flex-direction: column; }
.file-picker, .import-step > .el-button { width: 100%; max-width: none; }
.error-item { grid-template-columns: 64px minmax(0, 1fr); }
.error-item p { grid-column: 1 / -1; }
}
</style>
@@ -14,16 +14,32 @@ describe('unified orders API contract', () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
await expect(listPlatformUnifiedOrders({
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
receivingWarehouseId: '11', destinationWarehouseId: '22', inboundCode: 'RK-1', internalOrderStatus: 'ACCEPTED',
expectedArrivalDate: '2026-08-10', feeReviewStatus: 'APPROVED', channelName: '海运', containerNo: 'CONT-1',
cabinetNo: 'CAB-1', sailingDate: '2026-08-12', loaded: true, fullContainer: false,
measurementReviewStatus: 'PENDING', fbaCode: 'FBA-1', createdFrom: '2026-08-01T00:00:00',
createdTo: '2026-08-31T23:59:59', page: 2, size: 20,
})).resolves.toMatchObject({ page: 2, size: 20, total: 0, pages: 0 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
params: {
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
receivingWarehouseId: '11', destinationWarehouseId: '22', inboundCode: 'RK-1', internalOrderStatus: 'ACCEPTED',
expectedArrivalDate: '2026-08-10', feeReviewStatus: 'APPROVED', channelName: '海运', containerNo: 'CONT-1',
cabinetNo: 'CAB-1', sailingDate: '2026-08-12', loaded: true, fullContainer: false,
measurementReviewStatus: 'PENDING', fbaCode: 'FBA-1', createdFrom: '2026-08-01T00:00:00',
createdTo: '2026-08-31T23:59:59', page: 2, size: 20,
},
})
})
it('omits empty optional filters while preserving false boolean values', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listPlatformUnifiedOrders({ keyword: '', loaded: false, fullContainer: false, page: 1, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
params: { loaded: false, fullContainer: false, page: 1, size: 20 },
})
})
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', measurement: null, preload: null, loading: null, delivery: null, transport: null }],
@@ -168,6 +168,18 @@ export interface UnifiedOrderQuery {
customerId?: string
receivingWarehouseId?: string
destinationWarehouseId?: string
inboundCode?: string
internalOrderStatus?: string
expectedArrivalDate?: string
feeReviewStatus?: string
channelName?: string
containerNo?: string
cabinetNo?: string
sailingDate?: string
loaded?: boolean
fullContainer?: boolean
measurementReviewStatus?: string
fbaCode?: string
createdFrom?: string
createdTo?: string
page: number
@@ -15,6 +15,32 @@ describe('UnifiedOrdersView contract', () => {
}
})
it('renders accessible current-status tabs with keyboard navigation', () => {
expect(source).toContain('role="tablist"')
expect(source).toContain('role="tab"')
expect(source).toContain(':aria-selected="filters.mainStatus === tab.value"')
expect(source).toContain("event.key === 'ArrowRight'")
expect(source).toContain("event.key === 'ArrowLeft'")
expect(source).toContain("event.key === 'Home'")
expect(source).toContain("event.key === 'End'")
})
it('provides labeled prototype-aligned advanced filters backed by real query fields', () => {
for (const key of [
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate',
'feeReviewStatus', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded', 'fullContainer',
'measurementReviewStatus', 'fbaCode', 'createdRange',
]) expect(source).toContain(`key: '${key}'`)
for (const label of ['定位仓库', '交货仓', '费用审核状态', '是否已配载', '是否整柜', '测量数据审核状态', '商品 FBA 编码']) {
expect(source).toContain(`label: '${label}'`)
}
expect(source).toContain('listAllPlatformWarehouses')
expect(source).toContain("{ status: 'ACTIVE' }")
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
expect(source).toContain("loaded: optionalBoolean(filters.value.loaded)")
expect(source).toContain("fullContainer: optionalBoolean(filters.value.fullContainer)")
})
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
expect(source).toContain('title="订单列表暂时无法加载"')
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ClipboardList, Eye, RefreshCw } from '@lucide/vue'
import { ChevronDown, ChevronUp, ClipboardList, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -12,6 +12,7 @@ import {
} from '@/modules/unified-orders/api/unifiedOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -23,7 +24,22 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
type FilterModel = QueryModel & {
tenantId: string
keyword: string
orderNo: string
mainStatus: UnifiedOrderMainStatus | ''
receivingWarehouseId: string
destinationWarehouseId: string
inboundCode: string
internalOrderStatus: string
expectedArrivalDate: string
feeReviewStatus: string
channelName: string
containerNo: string
cabinetNo: string
sailingDate: string
loaded: string
fullContainer: string
measurementReviewStatus: string
fbaCode: string
createdRange: string[]
}
@@ -32,6 +48,7 @@ const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -39,19 +56,36 @@ const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customerLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const warehouseLoading = ref(false)
const warehouseLoadError = ref<ApiErrorDetails | null>(null)
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const filters = ref<FilterModel>({ tenantId: '', keyword: '', mainStatus: '', createdRange: [] })
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const queryFields = computed<QueryField[]>(() => [
const statusTabs = computed(() => [
{ value: '' as const, label: '全部' },
...unifiedOrderStatusOptions,
])
const warehouseOptions = computed(() => warehouses.value.map((warehouse) => ({
value: String(warehouse.id),
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
})))
const receivingWarehouseOptions = computed(() => warehouses.value
.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
.map((warehouse) => ({ value: String(warehouse.id), label: `${warehouse.warehouseName}(${warehouse.warehouseCode})` })))
const primaryQueryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 220,
label: '公司',
placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '全部客户',
options: customers.value.map((customer) => ({
value: String(customer.id),
@@ -59,18 +93,39 @@ const queryFields = computed<QueryField[]>(() => [
})),
props: { 'aria-label': '客户', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) },
},
{
key: 'mainStatus',
type: 'select',
width: 160,
placeholder: '全部主状态',
options: unifiedOrderStatusOptions,
props: { 'aria-label': '订单主状态' },
},
{ key: 'keyword', type: 'input', width: 260, placeholder: '订单号、入仓码或客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'createdRange', type: 'daterange', width: 300, placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
{ key: 'keyword', type: 'input', width: 260, label: '综合搜索', placeholder: '订单号、入仓码或客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'orderNo', type: 'input', width: 220, label: '订单号', placeholder: '请输入订单号', props: { 'aria-label': '订单号' } },
{ key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
])
const advancedQueryFields = computed<QueryField[]>(() => [
{ key: 'receivingWarehouseId', type: 'select', width: 220, label: '定位仓库', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择定位仓库', options: receivingWarehouseOptions.value, props: { 'aria-label': '定位仓库', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
{ key: 'destinationWarehouseId', type: 'select', width: 220, label: '交货仓', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '请输入入仓码', props: { 'aria-label': '入仓码' } },
{ key: 'internalOrderStatus', type: 'select', width: 190, label: '订单状态', placeholder: '请选择订单状态', options: [
{ value: 'DRAFT', label: '草稿' }, { value: 'SUBMITTED', label: '待审核' }, { value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' }, { value: 'PROCESSING', label: '处理中' }, { value: 'COMPLETED', label: '已完成' }, { value: 'CANCELLED', label: '已取消' },
], props: { 'aria-label': '订单状态' } },
{ key: 'expectedArrivalDate', type: 'date', width: 190, label: '预计到达时间', placeholder: '请选择预计到达日期', props: { 'aria-label': '预计到达时间' } },
{ key: 'feeReviewStatus', type: 'select', width: 190, label: '费用审核状态', placeholder: '请选择费用审核状态', options: [
{ value: 'PENDING', label: '待审核' }, { value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
], props: { 'aria-label': '费用审核状态' } },
{ key: 'containerNo', type: 'input', width: 210, label: '集装箱号', placeholder: '请输入集装箱号', props: { 'aria-label': '集装箱号' } },
{ key: 'cabinetNo', type: 'input', width: 190, label: '柜号', placeholder: '请输入柜号', props: { 'aria-label': '柜号' } },
{ key: 'channelName', type: 'input', width: 210, label: '渠道', placeholder: '请输入渠道名称', props: { 'aria-label': '渠道' } },
{ key: 'sailingDate', type: 'date', width: 190, label: '船期', placeholder: '请选择船期', props: { 'aria-label': '船期' } },
{ key: 'loaded', type: 'select', width: 180, label: '是否已配载', placeholder: '请选择配载状态', options: [{ value: 'true', label: '已配载' }, { value: 'false', label: '未配载' }], props: { 'aria-label': '是否已配载' } },
{ key: 'fullContainer', type: 'select', width: 180, label: '是否整柜', placeholder: '请选择装柜方式', options: [{ value: 'true', label: '整柜' }, { value: 'false', label: '拼箱' }], props: { 'aria-label': '是否整柜' } },
{ key: 'measurementReviewStatus', type: 'select', width: 220, label: '测量数据审核状态', placeholder: '请选择测量审核状态', options: [
{ value: 'PENDING', label: '待审核' }, { value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
], props: { 'aria-label': '测量数据审核状态' } },
{ key: 'fbaCode', type: 'input', width: 220, label: '商品 FBA 编码', placeholder: '请输入商品 FBA 编码', props: { 'aria-label': '商品 FBA 编码' } },
])
const queryFields = computed(() => showAdvancedFilters.value
? [...primaryQueryFields.value, ...advancedQueryFields.value]
: primaryQueryFields.value)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
@@ -84,6 +139,43 @@ const columns: DataTableColumn[] = [
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
]
function emptyFilters(): FilterModel {
return {
tenantId: '', keyword: '', orderNo: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
inboundCode: '', internalOrderStatus: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
fbaCode: '', createdRange: [],
}
}
function trimmed(value: string) {
return value.trim() || undefined
}
function optionalBoolean(value: string) {
return value === '' ? undefined : value === 'true'
}
function selectStatus(status: UnifiedOrderMainStatus | '') {
filters.value = { ...filters.value, mainStatus: status }
search()
}
function handleTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.value.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.value.length) % statusTabs.value.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.value.length - 1
else return
event.preventDefault()
const tab = statusTabs.value[nextIndex]
if (!tab) return
selectStatus(tab.value)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
function can(permission: string) {
return access.can(permission)
}
@@ -140,6 +232,19 @@ async function loadCustomers() {
}
}
async function loadWarehouses() {
warehouseLoading.value = true
warehouseLoadError.value = null
try {
warehouses.value = await listAllPlatformWarehouses({ status: 'ACTIVE' })
} catch (error) {
warehouses.value = []
warehouseLoadError.value = getApiErrorDetails(error, '平台仓库加载失败,暂时无法按仓库筛选')
} finally {
warehouseLoading.value = false
}
}
async function loadOrders() {
loading.value = true
loadError.value = null
@@ -147,8 +252,23 @@ async function loadOrders() {
const range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || undefined,
keyword: trimmed(filters.value.keyword),
orderNo: trimmed(filters.value.orderNo),
mainStatus: filters.value.mainStatus || undefined,
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
inboundCode: trimmed(filters.value.inboundCode),
internalOrderStatus: filters.value.internalOrderStatus || undefined,
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
feeReviewStatus: filters.value.feeReviewStatus || undefined,
channelName: trimmed(filters.value.channelName),
containerNo: trimmed(filters.value.containerNo),
cabinetNo: trimmed(filters.value.cabinetNo),
sailingDate: filters.value.sailingDate || undefined,
loaded: optionalBoolean(filters.value.loaded),
fullContainer: optionalBoolean(filters.value.fullContainer),
measurementReviewStatus: filters.value.measurementReviewStatus || undefined,
fbaCode: trimmed(filters.value.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
@@ -174,7 +294,7 @@ function search() {
}
function reset() {
filters.value = { tenantId: '', keyword: '', mainStatus: '', createdRange: [] }
filters.value = emptyFilters()
page.value = 1
void loadOrders()
}
@@ -236,6 +356,7 @@ watch(orderId, (value) => {
}, { immediate: true })
onMounted(() => {
void loadCustomers()
void loadWarehouses()
void loadOrders()
})
</script>
@@ -250,12 +371,34 @@ onMounted(() => {
<el-button :loading="loading" @click="loadOrders"><RefreshCw :size="16" />刷新</el-button>
</div>
<QueryToolbar v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="filters.mainStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else class="table-card">
<div v-else id="unified-orders-results" class="table-card" role="tabpanel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #actions="{ row }">
<div class="row-actions">
@@ -297,6 +440,14 @@ onMounted(() => {
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.status-tabs + :deep(.query-toolbar) { border-top: 0; }
.orders-load-error { margin-top: 12px; }
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
@@ -323,5 +474,5 @@ onMounted(() => {
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
</style>
@@ -47,23 +47,22 @@ describe('platform location API', () => {
await expect(listPlatformLocations({
page: 2,
size: 20,
tenantId: '9223372036854775806',
warehouseId: '190000000000000101',
zoneCode: ' A ',
locationCode: ' A1 ',
locationType: 'SHELF',
status: 'AVAILABLE',
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 21, pages: 2 })
await expect(getPlatformLocation('9223372036854775806', '190000000000000101', '19001')).resolves.toEqual(detail)
await expect(getPlatformLocation('190000000000000101', '19001')).resolves.toEqual(detail)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/locations', {
params: {
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '190000000000000101',
page: 2, size: 20, warehouseId: '190000000000000101',
zoneCode: 'A', locationCode: 'A1', locationType: 'SHELF', status: 'AVAILABLE',
},
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/locations/19001', {
params: { tenantId: '9223372036854775806', warehouseId: '190000000000000101' },
params: { tenantId: 0, warehouseId: '190000000000000101' },
})
})
@@ -74,7 +73,6 @@ describe('platform location API', () => {
await expect(listPlatformLocations({
page: 1,
size: 20,
tenantId: '',
warehouseId: '',
zoneCode: '',
locationCode: '',
@@ -93,20 +91,20 @@ describe('platform location API', () => {
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(createPlatformLocation(10001, payload)).resolves.toBe('19001')
await expect(updatePlatformLocation(10001, payload.warehouseId, '19001', payload)).resolves.toBeNull()
await expect(changePlatformLocationStatus(10001, payload.warehouseId, '19001', 'DISABLED', 3)).resolves.toBeNull()
await expect(deletePlatformLocation(10001, payload.warehouseId, '19001', 4)).resolves.toBeNull()
await expect(createPlatformLocation(payload)).resolves.toBe('19001')
await expect(updatePlatformLocation(payload.warehouseId, '19001', payload)).resolves.toBeNull()
await expect(changePlatformLocationStatus(payload.warehouseId, '19001', 'DISABLED', 3)).resolves.toBeNull()
await expect(deletePlatformLocation(payload.warehouseId, '19001', 4)).resolves.toBeNull()
expect(http.post).toHaveBeenCalledWith('/platform/v1/locations', payload, { params: { tenantId: 10001 } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/locations', payload, { params: { tenantId: 0 } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/locations/19001', payload, {
params: { tenantId: 10001, warehouseId: payload.warehouseId },
params: { tenantId: 0, warehouseId: payload.warehouseId },
})
expect(http.patch).toHaveBeenCalledWith('/platform/v1/locations/19001/status', { status: 'DISABLED', version: 3 }, {
params: { tenantId: 10001, warehouseId: payload.warehouseId },
params: { tenantId: 0, warehouseId: payload.warehouseId },
})
expect(http.delete).toHaveBeenCalledWith('/platform/v1/locations/19001', {
params: { tenantId: 10001, warehouseId: payload.warehouseId, version: 4 },
params: { tenantId: 0, warehouseId: payload.warehouseId, version: 4 },
})
})
})
@@ -41,7 +41,6 @@ export interface PlatformLocationPage {
export interface PlatformLocationQuery {
page?: number
size?: number
tenantId?: number | string
warehouseId?: number | string
zoneCode?: string
locationCode?: string
@@ -63,6 +62,7 @@ export interface SavePlatformLocationRequest {
}
const basePath = '/platform/v1/locations'
const platformResourceScope = 0
export async function listPlatformLocations(query: PlatformLocationQuery) {
const zoneCode = query.zoneCode?.trim()
@@ -71,7 +71,6 @@ export async function listPlatformLocations(query: PlatformLocationQuery) {
params: {
page: query.page ?? 1,
size: query.size ?? 20,
...(query.tenantId ? { tenantId: query.tenantId } : {}),
...(query.warehouseId ? { warehouseId: query.warehouseId } : {}),
...(zoneCode ? { zoneCode } : {}),
...(locationCode ? { locationCode } : {}),
@@ -90,38 +89,35 @@ export async function listPlatformLocations(query: PlatformLocationQuery) {
}
export async function getPlatformLocation(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
) {
const response = await http.get<ApiEnvelope<PlatformLocationDetail>, ApiEnvelope<PlatformLocationDetail>>(
`${basePath}/${id}`,
{ params: { tenantId, warehouseId } },
{ params: { tenantId: platformResourceScope, warehouseId } },
)
return unwrapApiResponse(response)
}
export async function createPlatformLocation(tenantId: number | string, payload: SavePlatformLocationRequest) {
export async function createPlatformLocation(payload: SavePlatformLocationRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
params: { tenantId },
params: { tenantId: platformResourceScope },
})
return unwrapApiResponse(response)
}
export async function updatePlatformLocation(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
payload: SavePlatformLocationRequest,
) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
params: { tenantId, warehouseId },
params: { tenantId: platformResourceScope, warehouseId },
})
return unwrapApiResponse(response)
}
export async function changePlatformLocationStatus(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
status: EditablePlatformLocationStatus,
@@ -130,19 +126,18 @@ export async function changePlatformLocationStatus(
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(
`${basePath}/${id}/status`,
{ status, version },
{ params: { tenantId, warehouseId } },
{ params: { tenantId: platformResourceScope, warehouseId } },
)
return unwrapApiResponse(response)
}
export async function deletePlatformLocation(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
version: number,
) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
params: { tenantId, warehouseId, version },
params: { tenantId: platformResourceScope, warehouseId, version },
})
return unwrapApiResponse(response)
}
@@ -151,7 +151,7 @@ describe('LocationsView', () => {
vi.mocked(createPlatformLocation).mockReset()
})
it('loads all warehouse candidates for querying while restricting write scope to domestic warehouses', async () => {
it('loads only domestic warehouse candidates for querying and writes', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const fields = toolbar.props('fields') as QueryField[]
@@ -160,14 +160,12 @@ describe('LocationsView', () => {
expect(fields[0]?.placeholder).toBe('全部仓库')
expect(fields[0]?.options).toEqual([
{ value: warehouseId, label: '义乌收货仓(CN-YW-01)' },
{ value: fbaWarehouse.id, label: 'ONT8 FBA 仓(FBA-ONT8)' },
{ value: overseasWarehouse.id, label: '洛杉矶海外仓(US-LAX-01)' },
])
expect(fields.map(({ key, width }) => [key, width])).toEqual([
['warehouseId', 300], ['zoneCode', 210],
['locationCode', 220], ['locationType', 170], ['status', 160],
])
expect(listAllPlatformWarehouses).toHaveBeenCalledWith()
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC' })
expect(listPlatformLocations).toHaveBeenCalledWith({
page: 1,
size: 20,
@@ -196,7 +194,7 @@ describe('LocationsView', () => {
expect(panelHeading.text()).toContain('共 2 个库位')
expect(panelHeading.text()).toContain('新增库位')
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
expect(listAllPlatformWarehouses).toHaveBeenCalledWith()
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC' })
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1,
size: 20,
@@ -274,7 +272,7 @@ describe('LocationsView', () => {
await detailButton.trigger('click')
await flushPromises()
expect(getPlatformLocation).toHaveBeenCalledWith(tenantId, warehouseId, availableLocation.id)
expect(getPlatformLocation).toHaveBeenCalledWith(warehouseId, availableLocation.id)
expect(document.body.textContent).toContain('库位详情')
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
expect(document.body.textContent).toContain('资源作用域平台公共资源')
@@ -105,7 +105,6 @@ const queryFields = computed<QueryField[]>(() => [
const emptyForm = () => ({
id: '',
customerId: '',
warehouseId: '',
zoneCode: '',
zoneName: '',
@@ -236,11 +235,11 @@ function requireCreateScope() {
ElMessage.warning('请选择国内仓')
return null
}
return { customerId: apiId(String(selectedWarehouse.value.tenantId)), warehouseId: apiId(form.warehouseId) }
return { warehouseId: apiId(form.warehouseId) }
}
function recordScope(row: PlatformLocationResource) {
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
return { warehouseId: apiId(String(row.warehouseId)) }
}
async function loadWarehouses() {
@@ -249,9 +248,11 @@ async function loadWarehouses() {
warehouseError.value = null
warehouses.value = []
try {
const records = await listAllPlatformWarehouses()
// 库位只属于国内仓,查询候选仓库时直接限制仓库类型,避免把 FBA/海外仓带入页面。
const records = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC' })
if (sequence !== warehouseLoadSequence) return
warehouses.value = records
// 保留前端兜底过滤,避免旧接口或异常响应返回其他仓库类型。
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
} catch (error) {
if (sequence !== warehouseLoadSequence) return
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
@@ -303,7 +304,6 @@ function applyFilters(model: QueryModel) {
function assignForm(resource: PlatformLocationResource) {
Object.assign(form, {
id: String(resource.id),
customerId: String(resource.tenantId),
warehouseId: String(resource.warehouseId),
zoneCode: resource.zoneCode,
zoneName: resource.zoneName,
@@ -320,7 +320,6 @@ function assignForm(resource: PlatformLocationResource) {
function openCreate() {
Object.assign(form, emptyForm())
form.warehouseId = isActiveDomesticWarehouse(selectedWarehouseId.value) ? selectedWarehouseId.value : ''
form.customerId = selectedWarehouse.value ? String(selectedWarehouse.value.tenantId) : ''
form.status = selectedWarehouse.value?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
detail.value = null
drawerError.value = null
@@ -342,7 +341,7 @@ async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit')
drawerError.value = null
detail.value = null
try {
const result = await getPlatformLocation(scope.customerId, scope.warehouseId, row.id)
const result = await getPlatformLocation(scope.warehouseId, row.id)
detail.value = result
if (mode === 'edit') {
assignForm(result.location)
@@ -381,15 +380,13 @@ async function saveLocation() {
ElMessage.error(form.id ? '库位仅支持归属国内仓' : '新增库位仅支持选择启用的国内仓')
return
}
const scope = form.id
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
: requireCreateScope()
const scope = form.id ? { warehouseId: apiId(form.warehouseId) } : requireCreateScope()
if (!scope) return
saving.value = true
try {
const payload = buildPayload()
if (form.id) await updatePlatformLocation(scope.customerId, scope.warehouseId, form.id, payload)
else await createPlatformLocation(scope.customerId, payload)
if (form.id) await updatePlatformLocation(scope.warehouseId, form.id, payload)
else await createPlatformLocation(payload)
drawerOpen.value = false
ElMessage.success(form.id ? '库位资料已更新' : '库位已创建')
await loadLocations()
@@ -426,7 +423,7 @@ async function toggleStatus(row: PlatformLocationResource) {
}
setActionLoading(row.id, true)
try {
await changePlatformLocationStatus(scope.customerId, scope.warehouseId, row.id, nextStatus, row.version)
await changePlatformLocationStatus(scope.warehouseId, row.id, nextStatus, row.version)
ElMessage.success(`库位 ${row.locationCode} 已${action}`)
await loadLocations()
} catch (error) {
@@ -451,7 +448,7 @@ async function removeLocation(row: PlatformLocationResource) {
}
setActionLoading(row.id, true)
try {
await deletePlatformLocation(scope.customerId, scope.warehouseId, row.id, row.version)
await deletePlatformLocation(scope.warehouseId, row.id, row.version)
ElMessage.success(`库位 ${row.locationCode} 已删除`)
await loadLocations()
} catch (error) {
@@ -480,7 +477,6 @@ watch([page, pageSize], () => { void loadLocations() })
watch(() => form.warehouseId, (warehouseId) => {
if (drawerMode.value !== 'create') return
const warehouse = warehouses.value.find((item) => String(item.id) === warehouseId)
form.customerId = warehouse ? String(warehouse.tenantId) : ''
form.status = warehouse?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
})
@@ -37,11 +37,11 @@ describe('platform pallet API', () => {
})
await expect(listPlatformPallets({
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: ' TP ', palletType: ' 木托盘 ',
page: 2, size: 20, warehouseId: '19001', palletCode: ' TP ', palletType: ' 木托盘 ',
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 41, pages: 3 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
params: {
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: 'TP', palletType: '木托盘',
page: 2, size: 20, warehouseId: '19001', palletCode: 'TP', palletType: '木托盘',
},
})
})
@@ -65,14 +65,14 @@ describe('platform pallet API', () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: null })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: null })
await expect(getPlatformPallet(10001, '19001', '20001')).resolves.toEqual({ id: '20001' })
await expect(createPlatformPallet(10001, payload)).resolves.toBe('20001')
await expect(updatePlatformPallet(10001, '19001', '20001', payload)).resolves.toBeNull()
await expect(deletePlatformPallet(10001, '19001', '20001')).resolves.toBeNull()
await expect(getPlatformPallet('19001', '20001')).resolves.toEqual({ id: '20001' })
await expect(createPlatformPallet(payload)).resolves.toBe('20001')
await expect(updatePlatformPallet('19001', '20001', payload)).resolves.toBeNull()
await expect(deletePlatformPallet('19001', '20001')).resolves.toBeNull()
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/pallets', payload, { params: { tenantId: 10001 } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/pallets/20001', payload, { params: { tenantId: 10001, warehouseId: '19001' } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 0, warehouseId: '19001' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/pallets', payload, { params: { tenantId: 0 } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/pallets/20001', payload, { params: { tenantId: 0, warehouseId: '19001' } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 0, warehouseId: '19001' } })
})
})
@@ -8,7 +8,7 @@ export interface PlatformPalletResource {
palletType: string
capacityCbm: number | string
weightKg: number | string | null
color: string | null
color: string
remark: string | null
createdAt: string | null
updatedAt: string | null
@@ -23,7 +23,6 @@ export interface PlatformPalletPage {
}
export interface PlatformPalletQuery {
tenantId?: number | string
page: number
size: number
warehouseId?: number | string
@@ -36,16 +35,16 @@ export interface SavePlatformPalletRequest {
palletType: string
capacityCbm: number
weightKg: number
color: string | null
color: string
remark: string | null
}
const basePath = '/platform/v1/pallets'
const platformResourceScope = 0
export async function listPlatformPallets(query: PlatformPalletQuery) {
const response = await http.get<ApiEnvelope<PlatformPalletPage>, ApiEnvelope<PlatformPalletPage>>(basePath, {
params: {
...(query.tenantId === undefined || query.tenantId === '' ? {} : { tenantId: query.tenantId }),
page: query.page,
size: query.size,
...(query.warehouseId === undefined || query.warehouseId === '' ? {} : { warehouseId: query.warehouseId }),
@@ -64,43 +63,40 @@ export async function listPlatformPallets(query: PlatformPalletQuery) {
}
export async function getPlatformPallet(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
) {
const response = await http.get<ApiEnvelope<PlatformPalletResource>, ApiEnvelope<PlatformPalletResource>>(
`${basePath}/${id}`,
{ params: { tenantId, warehouseId } },
{ params: { tenantId: platformResourceScope, warehouseId } },
)
return unwrapApiResponse(response)
}
export async function createPlatformPallet(tenantId: number | string, payload: SavePlatformPalletRequest) {
export async function createPlatformPallet(payload: SavePlatformPalletRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
params: { tenantId },
params: { tenantId: platformResourceScope },
})
return unwrapApiResponse(response)
}
export async function updatePlatformPallet(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
payload: SavePlatformPalletRequest,
) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
params: { tenantId, warehouseId },
params: { tenantId: platformResourceScope, warehouseId },
})
return unwrapApiResponse(response)
}
export async function deletePlatformPallet(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
params: { tenantId, warehouseId },
params: { tenantId: platformResourceScope, warehouseId },
})
return unwrapApiResponse(response)
}
@@ -117,7 +117,7 @@ describe('PalletsView', () => {
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
})
it('starts with all warehouse candidates for querying using compact filters', async () => {
it('starts with enabled domestic warehouse candidates for querying using compact filters', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const fields = toolbar.props('fields')
@@ -132,8 +132,6 @@ describe('PalletsView', () => {
expect(fields.find((field) => field.key === 'warehouseId')?.placeholder).toBe('全部仓库')
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([
{ label: '义乌收货仓(CN-YW-01)', value: '19001' },
{ label: 'ONT8 FBA 仓(FBA-ONT8)', value: '19002' },
{ label: '洛杉矶海外仓(US-LAX-01)', value: '19003' },
])
expect(listAllPlatformWarehouses).toHaveBeenCalledWith()
expect(listPlatformPallets).toHaveBeenCalledWith({
@@ -205,8 +203,6 @@ describe('PalletsView', () => {
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(warehouseField?.options).toEqual([
{ label: '义乌收货仓(CN-YW-01)', value: '19001' },
{ label: 'ONT8 FBA 仓(FBA-ONT8)', value: '19002' },
{ label: '洛杉矶海外仓(US-LAX-01)', value: '19003' },
])
})
@@ -256,7 +252,7 @@ describe('PalletsView', () => {
await saveButton?.trigger('click')
await flushPromises()
expect(createPlatformPallet).toHaveBeenCalledWith(0, {
expect(createPlatformPallet).toHaveBeenCalledWith({
warehouseId: 19001, palletType: '塑料托盘', capacityCbm: 1.5, weightKg: 10, color: '黑色', remark: '标准托盘',
})
})
@@ -322,7 +318,7 @@ describe('PalletsView', () => {
await viewButton?.trigger('click')
await flushPromises()
expect(getPlatformPallet).toHaveBeenCalledWith(0, 19001, '20001')
expect(getPlatformPallet).toHaveBeenCalledWith(19001, '20001')
expect(document.body.textContent).toContain('资源作用域')
expect(document.body.textContent).toContain('平台公共资源')
expect(document.body.textContent).not.toContain('所属企业')
@@ -50,7 +50,7 @@ const queryFields = computed<QueryField[]>(() => [
type: 'select',
width: 300,
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
options: warehouses.value.map((warehouse) => ({
options: domesticWarehouses.value.map((warehouse) => ({
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
value: String(warehouse.id),
})),
@@ -71,7 +71,7 @@ const queryFields = computed<QueryField[]>(() => [
},
])
const emptyForm = () => ({ id: '', customerId: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, weightKg: null as number | null, color: '', remark: '' })
const emptyForm = () => ({ id: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, weightKg: null as number | null, color: '', remark: '' })
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }],
@@ -226,7 +226,6 @@ function resetFilters() {
function openCreate() {
Object.assign(form, emptyForm())
form.warehouseId = isActiveDomesticWarehouse(filters.value.warehouseId) ? filters.value.warehouseId : ''
form.customerId = editorWarehouse.value ? String(editorWarehouse.value.tenantId) : ''
editorOpen.value = true
captureBaseline()
void formRef.value?.clearValidate()
@@ -235,7 +234,6 @@ function openCreate() {
function assignForm(resource: PlatformPalletResource) {
Object.assign(form, {
id: String(resource.id),
customerId: String(resource.tenantId),
warehouseId: String(resource.warehouseId),
palletCode: resource.palletCode,
palletType: resource.palletType,
@@ -250,7 +248,7 @@ function assignForm(resource: PlatformPalletResource) {
}
async function fetchDetail(row: PlatformPalletResource) {
return getPlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
return getPlatformPallet(apiId(String(row.warehouseId)), row.id)
}
async function openDetail(row: PlatformPalletResource) {
@@ -289,20 +287,18 @@ async function savePallet() {
ElMessage.error(form.id ? '托盘仅支持归属国内仓' : '新增托盘仅支持选择启用的国内仓')
return
}
form.customerId = String(warehouse.tenantId)
saving.value = true
const payload: SavePlatformPalletRequest = {
warehouseId: apiId(form.warehouseId),
palletType: form.palletType.trim(),
capacityCbm: form.capacityCbm,
weightKg: form.weightKg,
color: form.color.trim() || null,
color: form.color.trim(),
remark: form.remark.trim() || null,
}
try {
const customerId = apiId(form.customerId)
if (form.id) await updatePlatformPallet(customerId, apiId(form.warehouseId), form.id, payload)
else await createPlatformPallet(customerId, payload)
if (form.id) await updatePlatformPallet(apiId(form.warehouseId), form.id, payload)
else await createPlatformPallet(payload)
ElMessage.success(form.id ? '托盘已更新' : '托盘已新增')
editorOpen.value = false
await loadPallets()
@@ -326,7 +322,7 @@ async function removePallet(row: PlatformPalletResource) {
const id = String(row.id)
deletingIds.value = new Set([...deletingIds.value, id])
try {
await deletePlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
await deletePlatformPallet(apiId(String(row.warehouseId)), row.id)
ElMessage.success('托盘已删除')
if (rows.value.length === 1 && page.value > 1) page.value -= 1
else await loadPallets()
@@ -0,0 +1,90 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import BatchImportDialog, { type BatchImportResult } from './BatchImportDialog.vue'
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function button(label: string) {
return [...document.body.querySelectorAll('button')].find((item) => item.textContent?.trim() === label)
}
async function chooseFile(file: File) {
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
Object.defineProperty(input, 'files', { configurable: true, value: [file] })
input.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
}
enableAutoUnmount(afterEach)
describe('BatchImportDialog', () => {
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
it('uses the shared two-step flow and submits a validated XLSX file', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const result: BatchImportResult = {
fileName: 'partners.xlsx', totalRows: 2, importedRows: 2, success: true, errors: [],
}
const importFile = vi.fn().mockResolvedValue(result)
const wrapper = mount(BatchImportDialog, {
attachTo: document.body,
props: {
modelValue: true,
title: '批量导入往来单位',
entityLabel: '往来单位',
templateFileName: '往来单位导入模板-v1.xlsx',
downloadTemplate: vi.fn(),
importFile,
},
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(document.body.textContent).toContain('下载标准模板')
expect(document.body.textContent).toContain('选择填写后的文件')
expect(button('开始导入')?.hasAttribute('disabled')).toBe(true)
const file = new File(['xlsx'], 'partners.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
await chooseFile(file)
expect(button('开始导入')?.hasAttribute('disabled')).toBe(false)
button('开始导入')?.click()
await flushPromises()
expect(importFile).toHaveBeenCalledWith(file)
expect(wrapper.emitted('success')?.[0]).toEqual([result])
expect(document.body.textContent).toContain('导入完成:成功新增 2 条往来单位')
})
it('rejects files that do not use the XLSX template format', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const importFile = vi.fn()
mount(BatchImportDialog, {
attachTo: document.body,
props: {
modelValue: true,
title: '批量导入快递公司',
entityLabel: '快递公司',
templateFileName: '快递公司批量导入模板-v1.xlsx',
downloadTemplate: vi.fn(),
importFile,
},
global: { plugins: [ElementPlus] },
})
await flushPromises()
await chooseFile(new File(['csv'], 'companies.csv', { type: 'text/csv' }))
expect(button('开始导入')?.hasAttribute('disabled')).toBe(true)
expect(importFile).not.toHaveBeenCalled()
})
})
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Download, FileSpreadsheet, Upload } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
export interface BatchImportError {
row: number
field: string
message: string
}
export interface BatchImportResult {
fileName: string
totalRows: number
importedRows: number
success: boolean
errors: BatchImportError[]
}
const props = withDefaults(defineProps<{
title: string
entityLabel: string
successUnit?: string
templateFileName: string
errorReportFileName?: string
maxFileSizeMb?: number
maxRows?: number
downloadTemplate: () => Promise<Blob>
importFile: (file: File) => Promise<BatchImportResult>
}>(), {
successUnit: '条',
errorReportFileName: '',
maxFileSizeMb: 5,
maxRows: 1000,
})
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{
success: [result: BatchImportResult]
}>()
const fileInputRef = ref<HTMLInputElement>()
const selectedFile = ref<File | null>(null)
const downloadingTemplate = ref(false)
const importing = ref(false)
const result = ref<BatchImportResult | null>(null)
const importError = ref<ApiErrorDetails | null>(null)
const displayedErrors = computed(() => result.value?.errors.slice(0, 100) || [])
watch(open, (isOpen) => {
if (isOpen) resetState()
})
function resetFileInput() {
if (fileInputRef.value) fileInputRef.value.value = ''
}
function resetState() {
selectedFile.value = null
result.value = null
importError.value = null
resetFileInput()
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadTemplateFile() {
downloadingTemplate.value = true
try {
saveBlob(await props.downloadTemplate(), props.templateFileName)
} catch (error) {
const details = getApiErrorDetails(error, '导入模板下载失败,请稍后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
downloadingTemplate.value = false
}
}
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
result.value = null
importError.value = null
if (!file) {
selectedFile.value = null
return
}
if (!file.name.toLowerCase().endsWith('.xlsx')) {
selectedFile.value = null
resetFileInput()
ElMessage.error('请选择 .xlsx 格式的标准模板文件')
return
}
if (file.size > props.maxFileSizeMb * 1024 * 1024) {
selectedFile.value = null
resetFileInput()
ElMessage.error(`导入文件不能超过 ${props.maxFileSizeMb}MB`)
return
}
selectedFile.value = file
}
async function startImport() {
if (!selectedFile.value) {
ElMessage.warning('请先选择填写完成的 XLSX 文件')
return
}
importing.value = true
importError.value = null
result.value = null
try {
const importResult = await props.importFile(selectedFile.value)
result.value = importResult
if (importResult.success) {
selectedFile.value = null
resetFileInput()
ElMessage.success(`成功导入 ${importResult.importedRows} ${props.successUnit}${props.entityLabel}`)
emit('success', importResult)
}
} catch (error) {
importError.value = getApiErrorDetails(error, `${props.entityLabel}导入失败,请稍后重试`)
} finally {
importing.value = false
}
}
function downloadErrors() {
if (!result.value?.errors.length) return
const csvCell = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const lines = [
['行号', '字段', '错误原因'].map(csvCell).join(','),
...result.value.errors.map((item) => [item.row || '文件', item.field, item.message].map(csvCell).join(',')),
]
const fallbackName = `${result.value.fileName.replace(/\.xlsx$/i, '') || props.entityLabel}-错误报告.csv`
saveBlob(new Blob([`\ufeff${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), props.errorReportFileName || fallbackName)
}
</script>
<template>
<el-dialog
v-model="open"
:title="title"
width="min(720px, calc(100vw - 32px))"
:close-on-click-modal="!importing"
:close-on-press-escape="!importing"
:show-close="!importing"
class="batch-import-dialog"
>
<div class="batch-import-flow">
<section class="batch-import-step">
<div class="batch-import-step__heading">
<span>1</span>
<div><strong>下载标准模板</strong><p>请勿修改模板版本或表头,仅填写模板中的数据行。</p></div>
</div>
<el-button :loading="downloadingTemplate" @click="downloadTemplateFile"><Download :size="16" />下载模板</el-button>
</section>
<section class="batch-import-step">
<div class="batch-import-step__heading">
<span>2</span>
<div><strong>选择填写后的文件</strong><p>仅支持 .xlsx,最大 {{ maxFileSizeMb }}MB、{{ maxRows }} 行;当前仅新增,不更新已有档案。</p></div>
</div>
<label class="batch-import-file" :class="{ disabled: importing }">
<input ref="fileInputRef" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" :disabled="importing" @change="selectFile" />
<FileSpreadsheet :size="20" />
<span>{{ selectedFile?.name || '选择 XLSX 文件' }}</span>
</label>
</section>
<el-alert type="info" :closable="false" show-icon title="整批校验通过后才会写入;任一行有误时整批不写入。" />
<el-alert v-if="importError" type="error" :closable="false" show-icon title="导入请求失败">
<p>{{ importError.message }}</p>
<ApiErrorMeta :details="importError" />
</el-alert>
<el-alert v-if="result?.success" type="success" :closable="false" show-icon :title="`导入完成:成功新增 ${result.importedRows} ${successUnit}${entityLabel}`" />
<section v-if="result && !result.success" class="batch-import-errors" aria-live="polite">
<div class="batch-import-errors__heading">
<div><strong>校验未通过,未写入任何数据</strong><p>发现 {{ result.errors.length }} 项问题,请修正后重新上传。</p></div>
<el-button size="small" @click="downloadErrors"><Download :size="14" />下载错误报告</el-button>
</div>
<div class="batch-import-errors__list">
<div v-for="(item, index) in displayedErrors" :key="`${item.row}-${item.field}-${index}`" class="batch-import-error">
<span>{{ item.row > 0 ? `第 ${item.row} 行` : '文件' }}</span>
<strong>{{ item.field }}</strong>
<p>{{ item.message }}</p>
</div>
<p v-if="result.errors.length > displayedErrors.length" class="batch-import-errors__overflow">
仅展示前 {{ displayedErrors.length }} 项,完整内容请下载错误报告。
</p>
</div>
</section>
</div>
<template #footer>
<div class="batch-import-footer">
<el-button :disabled="importing" @click="open = false">关闭</el-button>
<el-button type="primary" :loading="importing" :disabled="!selectedFile" @click="startImport"><Upload :size="16" />开始导入</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.batch-import-flow { display: grid; gap: 16px; }
.batch-import-step { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fafbfc; }
.batch-import-step__heading { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
.batch-import-step__heading > span { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--cc-primary); color: #fff; font-size: 12px; font-weight: 700; }
.batch-import-step__heading strong, .batch-import-errors__heading strong { color: var(--cc-text); font-size: 14px; }
.batch-import-step__heading p, .batch-import-errors__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.batch-import-file { display: inline-flex; min-width: 190px; max-width: 280px; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-primary); cursor: pointer; }
.batch-import-file:hover { border-color: var(--cc-primary); }
.batch-import-file:focus-within { outline: 2px solid color-mix(in srgb, var(--cc-primary) 28%, transparent); outline-offset: 2px; }
.batch-import-file.disabled { cursor: not-allowed; opacity: .6; }
.batch-import-file input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.batch-import-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.batch-import-flow :deep(.el-alert p) { margin: 5px 0 0; }
.batch-import-errors { overflow: hidden; border: 1px solid #f4c7c7; border-radius: 8px; }
.batch-import-errors__heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #fff5f5; }
.batch-import-errors__list { max-height: 220px; overflow: auto; }
.batch-import-error { display: grid; grid-template-columns: 72px 130px minmax(0, 1fr); gap: 10px; padding: 10px 16px; border-top: 1px solid #f4dddd; font-size: 12px; }
.batch-import-error span { color: #b42318; font-weight: 600; }
.batch-import-error p { margin: 0; overflow-wrap: anywhere; }
.batch-import-errors__overflow { margin: 0; padding: 10px 16px; border-top: 1px solid #f4dddd; color: var(--cc-muted); font-size: 12px; }
.batch-import-footer { display: flex; justify-content: flex-end; gap: 10px; }
.batch-import-footer :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.batch-import-step, .batch-import-errors__heading { align-items: stretch; flex-direction: column; }
.batch-import-file, .batch-import-step > :deep(.el-button) { width: 100%; max-width: none; min-height: 44px; }
.batch-import-error { grid-template-columns: 64px minmax(0, 1fr); }
.batch-import-error p { grid-column: 1 / -1; }
.batch-import-footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.batch-import-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
</style>
+9 -8
View File
@@ -16,6 +16,7 @@ export interface QueryOption {
export interface QueryField {
key: string
type: QueryFieldType
label?: string
width?: string | number
placeholder?: string
options?: QueryOption[]
@@ -86,14 +87,14 @@ defineExpose({ search, reset })
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<template v-for="field in fields" :key="field.key">
<div v-for="field in fields" :key="field.key" class="query-toolbar__field" :style="fieldStyle(field)">
<span v-if="field.label" class="query-toolbar__label">{{ field.label }}</span>
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
:style="fieldStyle(field)"
@update:model-value="updateField(field.key, $event)"
>
<template #prefix><Search :size="16" /></template>
@@ -104,7 +105,6 @@ defineExpose({ search, reset })
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
:style="fieldStyle(field)"
@update:model-value="updateField(field.key, $event)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
@@ -120,10 +120,9 @@ defineExpose({ search, reset })
start-placeholder="开始日期"
end-placeholder="结束日期"
v-bind="field.props"
:style="fieldStyle(field)"
@update:model-value="updateField(field.key, $event)"
/>
</template>
</div>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
@@ -135,9 +134,11 @@ defineExpose({ search, reset })
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 12px 16px; }
.query-toolbar__field { display: grid; width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; gap: 6px; }
.query-toolbar__label { color: var(--cc-text); font-size: 12px; font-weight: 600; line-height: 1.4; }
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>