JessieExcel/src/components/orders/OrderImportDialog.vue
2026-03-25 01:54:12 +08:00

409 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { Factory, Order } from '@/shared/types'
import { api } from '@/utils/api'
import type { ImportPreviewRow } from '@/utils/orderImport'
import { buildImportPreview, readSpreadsheetRows } from '@/utils/orderImport'
const props = defineProps<{ open: boolean; factories: Factory[] }>()
const emit = defineEmits<{ (e: 'update:open', v: boolean): void; (e: 'imported'): void }>()
const importing = ref(false)
const parsing = ref(false)
const importMode = ref<'file' | 'json'>('file')
const fileName = ref('')
const jsonText = ref('')
const previewRows = ref<ImportPreviewRow[]>([])
const selectedKeys = ref<Set<number>>(new Set())
const tableRef = ref<
| {
clearSelection: () => void
toggleRowSelection: (row: ImportPreviewRow, selected?: boolean) => void
}
| null
>(null)
const skipDuplicates = ref(true)
const factoryNameById = computed(() => new Map(props.factories.map((f) => [f.id, f.name])))
const displayFactory = (row: ImportPreviewRow): string => {
return (
row.cleaned.factoryName ??
(row.cleaned.factoryId ? factoryNameById.value.get(row.cleaned.factoryId) : '') ??
row.cleaned.factoryId ??
''
)
}
const hasPreview = computed(() => previewRows.value.length > 0)
const errorCount = computed(
() => previewRows.value.filter((r) => r.issues.some((x) => x.severity === 'error')).length,
)
const warnCount = computed(() => previewRows.value.reduce((acc, r) => acc + r.issues.filter((x) => x.severity === 'warn').length, 0))
const selectedCount = computed(() => selectedKeys.value.size)
const duplicateCount = computed(() => previewRows.value.filter((r) => r.issues.some((x) => x.message.includes('订单号已存在'))).length)
const close = () => emit('update:open', false)
const reset = () => {
parsing.value = false
importing.value = false
importMode.value = 'file'
fileName.value = ''
jsonText.value = ''
previewRows.value = []
selectedKeys.value = new Set()
skipDuplicates.value = true
}
const syncTableSelection = async () => {
await nextTick()
if (!tableRef.value) return
tableRef.value.clearSelection()
for (const r of previewRows.value) {
if (selectedKeys.value.has(r.index)) {
tableRef.value.toggleRowSelection(r, true)
}
}
}
watch(
() => props.open,
async (v) => {
if (!v) {
reset()
return
}
},
)
const onPickFile = async (file: File) => {
parsing.value = true
try {
fileName.value = file.name
const rawRows = await readSpreadsheetRows(file)
const preview0 = buildImportPreview({ rawRows, factories: props.factories })
const orderNos = preview0.rows.map((r) => r.cleaned.orderNo).filter((x): x is string => Boolean(x))
let duplicates: string[] = []
try {
duplicates = orderNos.length > 0 ? await api.checkOrderDuplicates(orderNos) : []
} catch {
duplicates = []
}
const preview = buildImportPreview({
rawRows,
factories: props.factories,
existingOrderNos: new Set(duplicates),
})
previewRows.value = preview.rows
const selected = new Set<number>()
for (const r of preview.rows) {
const hasErrors = r.issues.some((x) => x.severity === 'error')
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
if (!hasErrors && (!skipDuplicates.value || !isDup)) {
selected.add(r.index)
}
}
selectedKeys.value = selected
await syncTableSelection()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '解析失败')
} finally {
parsing.value = false
}
}
const onUploadChange = async (uploadFile: { raw?: File }) => {
const f = uploadFile.raw
if (!f) return
await onPickFile(f)
}
const onSkipDuplicatesChange = (v: boolean) => {
skipDuplicates.value = v
if (!hasPreview.value) return
const selected = new Set(selectedKeys.value)
for (const r of previewRows.value) {
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
const hasErrors = r.issues.some((x) => x.severity === 'error')
if (hasErrors) {
selected.delete(r.index)
continue
}
if (skipDuplicates.value && isDup) selected.delete(r.index)
if (!skipDuplicates.value && !selected.has(r.index)) selected.add(r.index)
}
selectedKeys.value = selected
void syncTableSelection()
}
const rowClassName = (args: { row: ImportPreviewRow }) => {
const hasErrors = args.row.issues.some((x) => x.severity === 'error')
return hasErrors ? 'bg-red-50' : ''
}
const isRowSelectable = (row: ImportPreviewRow) => !row.issues.some((x) => x.severity === 'error')
const onSelectionChange = (rows: ImportPreviewRow[]) => {
selectedKeys.value = new Set(rows.map((r) => r.index))
}
const onDownloadTemplate = () => {
const headers = [
'订单号',
'PO号',
'产品名称',
'国别',
'订单金额',
'下单日期',
'客人货期',
'工厂名称',
'工厂货期',
'合同状态',
'订单进度',
'备注',
]
const csv = `${headers.join(',')}\n`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '订单导入模板.csv'
a.click()
URL.revokeObjectURL(a.href)
}
const onParseJson = async () => {
parsing.value = true
try {
const t = jsonText.value.trim()
if (!t) {
ElMessage.warning('请粘贴JSON数据')
return
}
const parsed = JSON.parse(t)
if (!Array.isArray(parsed)) {
ElMessage.error('JSON必须是数组')
return
}
fileName.value = '粘贴JSON'
const rawRows = parsed as Record<string, unknown>[]
const preview0 = buildImportPreview({ rawRows, factories: props.factories })
const orderNos = preview0.rows.map((r) => r.cleaned.orderNo).filter((x): x is string => Boolean(x))
let duplicates: string[] = []
try {
duplicates = orderNos.length > 0 ? await api.checkOrderDuplicates(orderNos) : []
} catch {
duplicates = []
}
const preview = buildImportPreview({
rawRows,
factories: props.factories,
existingOrderNos: new Set(duplicates),
})
previewRows.value = preview.rows
const selected = new Set<number>()
for (const r of preview.rows) {
const hasErrors = r.issues.some((x) => x.severity === 'error')
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
if (!hasErrors && (!skipDuplicates.value || !isDup)) selected.add(r.index)
}
selectedKeys.value = selected
await syncTableSelection()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : 'JSON解析失败')
} finally {
parsing.value = false
}
}
const toCreateInput = (row: ImportPreviewRow): Partial<Order> & { orderNo: string; productName: string } | null => {
const c = row.cleaned
if (!c.orderNo || !c.productName) return null
const isDup = row.issues.some((x) => x.message.includes('订单号已存在'))
if (skipDuplicates.value && isDup) return null
return {
orderNo: c.orderNo,
poNo: c.poNo,
productName: c.productName,
country: c.country,
orderAmount: c.orderAmount,
orderDate: c.orderDate,
customerDeliveryDate: c.customerDeliveryDate,
factoryId: c.factoryId,
factoryDeliveryDate: c.factoryDeliveryDate,
factoryContract: c.factoryContract,
packagingStatus: c.packagingStatus,
stickerStatus: c.stickerStatus,
shippingStatus: c.shippingStatus,
inspectionStatus: c.inspectionStatus,
purchaseAmount: c.purchaseAmount,
orderProgress: c.orderProgress ?? '下单',
remarks: c.remarks,
ciAmount: c.ciAmount,
etd: c.etd,
eta: c.eta,
paymentDate: c.paymentDate,
paymentAmount: c.paymentAmount,
balance: c.balance,
}
}
const onImport = async () => {
if (!hasPreview.value) {
ElMessage.warning('请先选择文件')
return
}
if (selectedKeys.value.size === 0) {
ElMessage.warning('没有可导入的行')
return
}
importing.value = true
try {
const items = previewRows.value
.filter((r) => selectedKeys.value.has(r.index))
.filter((r) => !r.issues.some((x) => x.severity === 'error'))
.map((r) => toCreateInput(r))
.filter(
(x): x is Partial<Order> & { orderNo: string; productName: string } => x !== null,
)
const res = await api.bulkCreateOrders({ items, skipDuplicates: skipDuplicates.value })
const skipped = res.skippedOrderNos.length
ElMessage.success(`导入完成:成功 ${res.createdCount},跳过重复 ${skipped}`)
emit('imported')
emit('update:open', false)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '导入失败')
} finally {
importing.value = false
}
}
const title = computed(() => (fileName.value ? `导入订单 - ${fileName.value}` : '导入订单'))
</script>
<template>
<el-dialog :model-value="open" :title="title" width="1100px" @close="close">
<div class="space-y-3">
<el-radio-group v-model="importMode" size="small">
<el-radio-button label="file">文件导入</el-radio-button>
<el-radio-button label="json">粘贴JSON</el-radio-button>
</el-radio-group>
<div class="flex items-center gap-2">
<el-upload
v-if="importMode==='file'"
:show-file-list="false"
accept=".xlsx,.xls,.csv"
:auto-upload="false"
:on-change="onUploadChange"
>
<el-button type="primary" :loading="parsing">选择Excel/CSV</el-button>
</el-upload>
<el-button v-else type="primary" :loading="parsing" @click="onParseJson">解析JSON</el-button>
<el-button @click="onDownloadTemplate">下载模板</el-button>
<div class="flex-1" />
<el-switch :model-value="skipDuplicates" @change="onSkipDuplicatesChange" />
<span class="text-xs text-zinc-600">跳过重复订单号</span>
</div>
<el-input
v-if="importMode==='json' && !hasPreview"
v-model="jsonText"
type="textarea"
:rows="10"
placeholder="粘贴 JSON 数组(例如 [{...},{...}]),字段支持 orderNo/poNo/product/country/factory/contractStatus/orderProgress/remarks 等"
/>
<el-alert
v-if="hasPreview"
type="info"
show-icon
:closable="false"
:title="`共 ${previewRows.length} 行;错误 ${errorCount} 行;警告 ${warnCount} 条;重复 ${duplicateCount} 行;已选择 ${selectedCount} 行导入`"
/>
<el-table
v-if="hasPreview"
ref="tableRef"
:data="previewRows"
stripe
class="w-full"
height="520"
:row-key="(r) => r.index"
reserve-selection
:row-class-name="rowClassName"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" :selectable="isRowSelectable" />
<el-table-column prop="index" label="#" width="60" />
<el-table-column label="订单号" min-width="140">
<template #default="scope">
<span>{{ scope.row.cleaned.orderNo }}</span>
</template>
</el-table-column>
<el-table-column label="产品" min-width="160">
<template #default="scope">
<span>{{ scope.row.cleaned.productName }}</span>
</template>
</el-table-column>
<el-table-column label="国别" width="100">
<template #default="scope">
<span>{{ scope.row.cleaned.country }}</span>
</template>
</el-table-column>
<el-table-column label="工厂" width="160">
<template #default="scope">
<span>{{ displayFactory(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="金额" width="120">
<template #default="scope">
<span>{{ scope.row.cleaned.orderAmount ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="下单" width="110">
<template #default="scope">
<span>{{ scope.row.cleaned.orderDate ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="客人货期" width="110">
<template #default="scope">
<span>{{ scope.row.cleaned.customerDeliveryDate ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="进度" width="110">
<template #default="scope">
<span>{{ scope.row.cleaned.orderProgress ?? '下单' }}</span>
</template>
</el-table-column>
<el-table-column label="合同" width="140">
<template #default="scope">
<span>{{ scope.row.cleaned.factoryContract ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="问题" min-width="260">
<template #default="scope">
<div class="space-y-1">
<div v-for="(it, idx) in scope.row.issues" :key="idx" class="text-xs" :class="it.severity==='error' ? 'text-red-600' : 'text-amber-600'">
{{ it.severity === 'error' ? '错误' : '警告' }}{{ it.message }}
</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button v-if="hasPreview" @click="reset" :disabled="parsing || importing">清空</el-button>
<el-button type="primary" :loading="importing" :disabled="parsing" @click="onImport">开始导入</el-button>
</template>
</el-dialog>
</template>