优化
This commit is contained in:
@@ -52,6 +52,16 @@
|
||||
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
|
||||
- 新页面至少适配现有 320px 最小宽度策略,并检查侧栏、表格、抽屉和表单在窄屏下的退化方式。
|
||||
|
||||
## 公共前端组件复用
|
||||
|
||||
- 开发或重构页面前,先检查 `src/shared/components/`,不得在业务页面重复实现已有的查询栏、数据表格、批量导入、弹窗、抽屉、加载错误、状态和复制交互。公共组件无法满足时,先判断能力是否具有跨模块复用价值:通用能力扩展公共组件并补测试;纯业务特例留在所属模块,不用页面级 CSS 覆盖破坏公共组件契约。
|
||||
- 查询与筛选统一使用 `src/shared/components/QueryToolbar.vue`。通过 `fields` 配置 `input`、`select`、`date`、`daterange`,使用 `v-model` 管理查询模型,并监听 `search`、`reset`;额外操作放入 `actions` 插槽。不要在每个列表页重新编写筛选容器、查询/重置按钮和移动端换行样式。
|
||||
- 业务数据列表统一使用 `src/shared/components/DataTable.vue`。通过 `columns` 和命名插槽定义列,使用 `v-model:page`、`v-model:page-size` 及 `total` 接入分页;无需分页时可省略 `total`。不得重复拼装 `el-table` 与分页器;“操作”列仍须遵守仓库根规则,放在第一列,固定时固定在左侧。
|
||||
- 标准 XLSX 批量导入/上传统一使用 `src/shared/components/BatchImportDialog.vue`。页面只维护弹窗开关,传入标题、实体名称、模板文件名、`downloadTemplate` 和 `importFile`,并在 `success` 后刷新列表;文件格式和大小校验、模板下载、提交状态、接口错误、整批校验结果、CSV 错误报告及响应式样式由组件负责。不要在页面内重复创建文件输入框、上传状态、错误报告逻辑或导入弹窗 CSS。
|
||||
- 普通模态框和侧滑编辑/详情优先使用 `src/shared/components/AppDialog.vue`、`AppDrawer.vue`,复用提交锁定、加载态、底部按钮和窄屏约束;仅当业务交互与其契约明显不同时直接使用 Element Plus 原始容器。
|
||||
- 列表或详情加载失败优先组合 `src/shared/components/LoadErrorState.vue` 与 `ApiErrorMeta.vue`,保留重试入口及 `requestId`、`traceId`;状态展示和复制操作分别优先使用 `StatusBadge.vue`、`CopyButton.vue`。
|
||||
- 公共组件的使用示例优先参考已接入页面:查询和表格见 `modules/master-data/express-companies/pages/ExpressCompaniesView.vue`,批量导入见该页面及 `modules/master-data/counterparties/pages/PartnersView.vue`,抽屉见 `modules/downloads/pages/DownloadCenterView.vue`。扩展公共组件时同步检查所有现有消费者,并至少运行组件测试和直接受影响页面测试。
|
||||
|
||||
## 本地命令与质量门禁
|
||||
|
||||
在 `apps/admin-web` 目录执行:
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user