增加一个打单功能套餐

This commit is contained in:
wuweihua
2026-11-09 23:00:48 +08:00
parent 2632e5aa78
commit 16afa0d590
30 changed files with 3418 additions and 132 deletions
@@ -0,0 +1,91 @@
<template>
<el-dialog
v-model="visible"
title="导入打单价卡"
:width="autoFillOnParse ? '560px' : '1080px'"
append-to-body
destroy-on-close
@closed="onClosed"
>
<label-import-panel
ref="panelRef"
:auto-fill-on-parse="autoFillOnParse"
:price-set-id="priceSetId"
@parsed="onParsed"
/>
<template #footer>
<el-button @click="visible = false">{{ autoFillOnParse ? '关闭' : '取消' }}</el-button>
<el-button v-if="!autoFillOnParse" type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ElMessageBox } from 'element-plus'
import LabelImportPanel from './LabelImportPanel.vue'
const props = defineProps({
/** 已有套餐时传入,弹窗内直接落库(详情页) */
priceSetId: { type: [Number, String], default: null },
/**
* 仅创建页传 true:解析成功立刻回填页面,不点确定/保存
* 详情页默认 false:预览勾选后点保存落库
*/
autoFillOnParse: { type: Boolean, default: false }
})
const emit = defineEmits(['saved', 'confirm'])
const { proxy } = getCurrentInstance()
const visible = ref(false)
const saving = ref(false)
const panelRef = ref()
const autoFilled = ref(false)
function open() {
autoFilled.value = false
visible.value = true
nextTick(() => panelRef.value?.reset())
}
function onClosed() {
panelRef.value?.reset()
saving.value = false
autoFilled.value = false
}
/** 仅创建页(autoFillOnParse)生效 */
function onParsed(payload) {
if (!props.autoFillOnParse || autoFilled.value) return
if (!payload?.products?.length) return
autoFilled.value = true
emit('confirm', payload)
visible.value = false
proxy.$modal.msgSuccess(`已导入 ${payload.products.length} 个产品`)
}
async function save() {
if (!props.priceSetId) {
proxy.$modal.msgError('缺少套餐ID')
return
}
if (!panelRef.value?.validateReady()) return
const count = panelRef.value.selectedCount || 0
try {
await proxy.$modal.confirm(`将保存 ${count} 个应收产品到本套餐。`)
} catch {
return
}
saving.value = true
try {
const result = await panelRef.value.submit({ priceSetId: props.priceSetId })
visible.value = false
ElMessageBox.close()
proxy.$modal.msgSuccess(result.message || '保存成功')
emit('saved', { priceSetId: result.priceSetId || props.priceSetId })
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
@@ -0,0 +1,345 @@
<template>
<div class="label-import-panel">
<el-form label-width="96px" class="import-form">
<el-form-item label="支付类型">
<el-tag type="success">应收 AR</el-tag>
<span class="form-tip">打单套餐固定应收,不显示建价选项,直接 Excel 导入</span>
</el-form-item>
<el-form-item label="产品">
<el-select v-model="dictValue" clearable filterable placeholder="不选则按工作表名称匹配全部字典产品" style="width: 420px">
<el-option v-for="item in payment_product_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<el-upload
:key="uploadKey"
drag
accept=".xlsx"
:auto-upload="false"
:show-file-list="false"
:disabled="recognizing"
@change="onFile"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="el-upload__text">将 xlsx 价卡拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="upload-tip">
{{ autoFillOnParse
? '解析成功后自动回填到创建页产品价卡列表;打单保存与应付独立,弹窗内不展示表格。'
: '模板以威狮 B2C 末端派送价卡为准。选一个产品就只读对应工作表。' }}
</div>
</template>
</el-upload>
<div v-if="recognizing" class="parse-progress">
<el-progress :percentage="parsePercent" :stroke-width="16" :status="parseStatus" striped striped-flow :duration="12" />
<div class="parse-progress-msg">{{ parseMessage }}</div>
</div>
<!-- 创建页 autoFillOnParse:不展示弹窗预览表,结果回填页面;详情等入口保留预览勾选 -->
<div v-if="preview && !autoFillOnParse" v-loading="recognizing" class="preview-box">
<el-alert
:title="`${preview.sourceFile || ''} 归属 ${preview.supplierName || '-'} 生效 ${preview.effectiveDate || '-'} 共 ${products.length} 个产品`"
type="info"
:closable="false"
show-icon
/>
<div class="preview-bar">
<el-switch v-model="overwrite" active-text="本套餐已存在时另存为新版本" />
<span class="preview-hint">仅本打单套餐维度;与应付公共价卡互不影响。开启后同产品编号不变,旧价留在历史 term。</span>
</div>
<el-table
ref="tableRef"
:data="products"
border
size="small"
max-height="420"
row-key="rowKey"
@selection-change="onSelect"
>
<el-table-column type="selection" width="48" :selectable="canSelect" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
<el-table-column prop="relatedSheet" label="关联分区表" min-width="120" show-overflow-tooltip />
<el-table-column prop="weightCount" label="重量段" width="80" align="right" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="right" />
<el-table-column label="识别" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.parseStatus)" size="small">{{ statusText(row.parseStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="库中" width="90">
<template #default="{ row }">
<el-tag v-if="row.duplicated" type="warning" size="small">已存在</el-tag>
<span v-else>新记录</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="View" @click="openDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="skippedText" class="upload-tip">未匹配字典、已跳过:{{ skippedText }}</div>
</div>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail :product="detailProduct" :editable="false" />
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import { previewLabel, labelParseProgress, saveLabelImport } from '@/api/price/label'
import { useDict } from '@/utils/dict'
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
const props = defineProps({
/** 仅创建页开启:解析成功后立刻抛出勾选结果,其它入口保持预览后手动保存 */
autoFillOnParse: { type: Boolean, default: false },
/** 详情再导入时传入,用于「本套餐已存在」判断;新建不传 */
priceSetId: { type: [Number, String], default: null }
})
const emit = defineEmits(['parsed'])
const { proxy } = getCurrentInstance()
const { payment_product_type } = useDict('payment_product_type')
const recognizing = ref(false)
const overwrite = ref(false)
const dictValue = ref('')
const preview = ref(null)
const selected = ref([])
const tableRef = ref()
const uploadKey = ref(0)
const parsePercent = ref(0)
const parseMessage = ref('')
const parseStatus = ref(undefined)
const active = ref(true)
const detailVisible = ref(false)
const detailProduct = ref(null)
const products = computed(() => preview.value?.products || [])
const skippedText = computed(() => (preview.value?.skippedSheets || []).filter(Boolean).join('、'))
const selectedCount = computed(() => selected.value.length)
const detailTitle = computed(() => {
if (!detailProduct.value) return '产品明细'
return detailProduct.value.productName || detailProduct.value.sheetName || '产品明细'
})
let parseSeq = 0
let progressTimer = null
function reset() {
parseSeq += 1
preview.value = null
selected.value = []
overwrite.value = false
dictValue.value = ''
recognizing.value = false
parsePercent.value = 0
parseMessage.value = ''
parseStatus.value = undefined
detailVisible.value = false
detailProduct.value = null
stopProgress()
uploadKey.value += 1
}
function openDetail(row) {
detailProduct.value = row || null
detailVisible.value = true
}
async function onFile(uploadFile) {
const raw = uploadFile?.raw
if (!raw) return
if (!raw.name.toLowerCase().endsWith('.xlsx')) {
proxy.$modal.msgError('请上传xlsx价卡')
return
}
const seq = ++parseSeq
const progressId = createProgressId()
recognizing.value = true
parsePercent.value = 0
parseMessage.value = '正在上传价卡…'
parseStatus.value = undefined
startProgress(seq, progressId)
try {
const res = await previewLabel(raw, dictValue.value, progressId, event => {
if (seq !== parseSeq || !event?.total) return
const uploaded = Math.min(8, Math.round(event.loaded * 8 / event.total))
if (uploaded > parsePercent.value) {
parsePercent.value = uploaded
parseMessage.value = '正在上传价卡…'
}
}, props.priceSetId)
if (seq !== parseSeq) return
parsePercent.value = 100
parseMessage.value = '解析完成'
parseStatus.value = 'success'
const data = res.data || {}
data.products = (data.products || []).map((item, index) => ({
...item,
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}`
}))
preview.value = data
const picked = data.products.filter(row => canSelect(row))
selected.value = picked
// 详情等入口:预览表勾选;创建页不渲染预览表,直接回填页面
if (!props.autoFillOnParse) {
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
} else if (picked.length) {
emit('parsed', getSelectionPayload())
} else {
proxy.$modal.msgWarning(
props.priceSetId
? '没有可导入的产品,请调整产品筛选或开启「本套餐已存在时另存为新版本」后重试'
: '没有可导入的产品,请调整产品筛选或检查价卡工作表是否匹配字典'
)
}
} catch (error) {
if (seq !== parseSeq) return
parseStatus.value = 'exception'
parseMessage.value = error?.message || '价卡解析失败'
} finally {
if (seq === parseSeq) {
stopProgress()
recognizing.value = false
}
}
}
function createProgressId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID()
return `lbl${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`
}
function startProgress(seq, progressId) {
stopProgress()
progressTimer = window.setInterval(async () => {
if (seq !== parseSeq) return
try {
const res = await labelParseProgress(progressId)
if (seq !== parseSeq) return
const data = res.data || {}
if (!data.status || data.status === 'PENDING') return
const server = Math.min(99, Number(data.percent) || 0)
if (server >= parsePercent.value) {
parsePercent.value = server
if (data.message) parseMessage.value = data.message
}
if (data.status === 'FAIL') parseStatus.value = 'exception'
} catch {
// ignore
}
}, 400)
}
function stopProgress() {
if (progressTimer) {
window.clearInterval(progressTimer)
progressTimer = null
}
}
function canSelect(row) {
if (row.parseStatus === 'MISSING') return false
return overwrite.value || !row.duplicated
}
watch(overwrite, async () => {
if (!active.value || !preview.value) return
const picked = products.value.filter(row => canSelect(row))
selected.value = picked
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
})
function onSelect(rows) {
if (!active.value) return
selected.value = rows || []
}
function statusType(status) {
if (status === 'OK') return 'success'
if (status === 'EMPTY') return 'warning'
return 'danger'
}
function statusText(status) {
if (status === 'OK') return '已识别'
if (status === 'EMPTY') return '未识别到配置'
if (status === 'MISSING') return '缺少工作表'
return status || '-'
}
function validateReady() {
if (!selected.value.length) {
proxy.$modal.msgError('请先上传价卡并勾选要保存的产品')
return false
}
return true
}
/** 弹窗确认时回传勾选结果(创建页暂存,点完成再落库) */
function getSelectionPayload() {
return {
overwrite: overwrite.value,
sourceFile: preview.value?.sourceFile || '',
supplierName: preview.value?.supplierName || '',
effectiveDate: preview.value?.effectiveDate || '',
products: selected.value.map(row => ({ ...row, productType: 'AR' }))
}
}
/** 按现有导入逻辑保存;basic 由创建页传入名称/备注 */
async function submit(payload = {}) {
if (!validateReady()) {
throw new Error('NO_SELECTION')
}
const productsPayload = (payload.products && payload.products.length)
? payload.products
: selected.value.map(row => ({ ...row, productType: 'AR' }))
const res = await saveLabelImport({
priceSetId: payload.priceSetId || null,
priceSetName: String(payload.priceSetName || '').trim(),
priceSetNote: payload.priceSetNote || '',
overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value,
products: productsPayload
})
const data = res.data || {}
return {
message: data.message || res.msg || '保存成功',
priceSetId: data.priceSetId || payload.priceSetId
}
}
onBeforeUnmount(() => {
active.value = false
stopProgress()
})
defineExpose({ reset, validateReady, submit, getSelectionPayload, selectedCount })
</script>
<style scoped>
.import-form { margin-bottom: 8px; }
.form-tip { margin-left: 12px; color: #909399; font-size: 12px; }
.upload-icon { font-size: 48px; color: #c0c4cc; }
.upload-tip { color: #909399; font-size: 12px; margin-top: 6px; }
.parse-progress { margin: 12px 0; }
.parse-progress-msg { margin-top: 6px; color: #606266; font-size: 13px; }
.preview-box { margin-top: 12px; }
.preview-bar { display: flex; align-items: center; gap: 12px; margin: 10px 0; }
.preview-hint { color: #909399; font-size: 12px; }
</style>
@@ -0,0 +1,169 @@
<template>
<el-drawer
:model-value="visible"
:title="title"
size="78%"
destroy-on-close
append-to-body
@close="emit('update:visible', false)"
>
<div class="toolbar">
<el-input v-model="keyword" clearable placeholder="产品名称 / 工作表 / 国家" style="width: 260px" />
<el-button type="danger" plain :disabled="!selectedIds.length" @click="handleRemove" v-hasPermi="['price:label:remove']">移除</el-button>
</div>
<el-table
v-loading="loading"
:data="filteredRows"
border
height="calc(100vh - 220px)"
row-key="termId"
@selection-change="onSelect"
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column prop="productId" label="产品编号" width="90" align="center" />
<el-table-column prop="productName" label="产品名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="supplierName" label="价卡归属" width="100" align="center" />
<el-table-column prop="countryScope" label="国家/口岸" min-width="120" show-overflow-tooltip />
<el-table-column prop="chargeType" label="费用类型" width="110" align="center" />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="versionNo" label="版本" width="70" align="center" />
<el-table-column label="有效期" min-width="180" align="center">
<template #default="{ row }">{{ termText(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="termStatusTag(row.termStatus)" size="small">{{ termStatusText(row.termStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="weightCount" label="重量段" width="80" align="center" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="center" />
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openProduct(row)" v-hasPermi="['price:label:query']">查看</el-button>
<el-button link type="danger" @click="handleRemove([row.productId])" v-hasPermi="['price:label:remove']">移除</el-button>
</template>
</el-table-column>
</el-table>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail v-loading="detailLoading" :product="detail" :editable="false" />
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-drawer>
</el-drawer>
</template>
<script setup>
import { listLabelProducts, getLabelProduct, removeLabelProducts } from '@/api/price/label'
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
priceSetId: { type: [Number, String], default: null },
version: { type: Object, default: null }
})
const emit = defineEmits(['update:visible', 'changed'])
const { proxy } = getCurrentInstance()
const loading = ref(false)
const rows = ref([])
const keyword = ref('')
const selectedIds = ref([])
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const title = computed(() => {
const v = props.version || {}
const label = v.versionLabel || (v.versionNo != null ? `V${v.versionNo}` : '')
return label ? `版本明细 · ${label}` : '版本明细'
})
const detailTitle = computed(() => {
if (!detail.value) return '产品明细'
const no = detail.value.versionNo ? `第 ${detail.value.versionNo} 版` : ''
return `${detail.value.productName || '产品'} ${no}`.trim()
})
const filteredRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return rows.value
return rows.value.filter(row => {
const text = [row.productName, row.sheetName, row.countryScope, row.supplierName].join(' ').toLowerCase()
return text.includes(kw)
})
})
function termText(row) {
const start = row.termStart || row.effectiveDate || ''
const end = row.termEnd || '不限'
if (!start) return '未设置'
return `${String(start).slice(0, 10)} ~ ${String(end).slice(0, 10)}`
}
function termStatusText(status) {
const map = { '0': '待生效', '1': '生效', '2': '已失效' }
return map[String(status)] || '-'
}
function termStatusTag(status) {
const map = { '0': 'warning', '1': 'success', '2': 'danger' }
return map[String(status)] || 'info'
}
function onSelect(selection) {
selectedIds.value = (selection || []).map(item => item.productId).filter(Boolean)
}
async function load() {
if (!props.priceSetId || props.version?.versionNo == null) {
rows.value = []
return
}
loading.value = true
try {
const res = await listLabelProducts(props.priceSetId, props.version.versionNo)
rows.value = res.data || []
} finally {
loading.value = false
}
}
async function openProduct(row) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
try {
const res = await getLabelProduct(row.productId, row.termId)
detail.value = res.data || null
} finally {
detailLoading.value = false
}
}
function handleRemove(ids) {
const list = (Array.isArray(ids) ? ids : selectedIds.value).filter(Boolean)
if (!list.length || !props.priceSetId) return
proxy.$modal.confirm(`确认从本套餐移除选中的 ${list.length} 个产品?`).then(() => {
return removeLabelProducts(props.priceSetId, list.join(','))
}).then(() => {
proxy.$modal.msgSuccess('已移除')
load()
emit('changed')
}).catch(() => {})
}
watch(() => [props.visible, props.priceSetId, props.version?.versionNo], ([vis]) => {
if (vis) load()
}, { immediate: true })
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: center;
}
</style>
@@ -0,0 +1,145 @@
<template>
<el-card shadow="never" class="ver-card">
<template #header>
<div class="ver-head">
<div class="ver-head-left">
<span class="ver-title">套餐版本</span>
<el-tag size="small" type="info" effect="plain" round>{{ versions.length }}</el-tag>
</div>
<el-button
type="primary"
plain
size="small"
:disabled="hasPending"
v-hasPermi="['price:label:import']"
@click="$emit('create-version')"
>新建版本</el-button>
</div>
</template>
<el-tabs v-model="statusTab" class="ver-tabs">
<el-tab-pane :label="tabLabel('all')" name="all" />
<el-tab-pane :label="tabLabel('0')" name="0" />
<el-tab-pane :label="tabLabel('1')" name="1" />
<el-tab-pane :label="tabLabel('2')" name="2" />
</el-tabs>
<el-table :data="filteredVersions" border empty-text="暂无版本,完成导入后在此设置生效时间">
<el-table-column label="版本号" width="100" align="center">
<template #default="{ row }">{{ row.versionLabel || ('V' + (row.versionNo || 1)) }}</template>
</el-table-column>
<el-table-column label="生效时间" min-width="160" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="$emit('open-details', row)">
{{ formatDay(row.effectiveTime) || '未设置' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="失效时间" min-width="160" align="center">
<template #default="{ row }">{{ formatDay(row.expireTime) || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="产品数" width="90" align="center">
<template #default="{ row }">{{ row.productCount || 0 }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="$emit('open-details', row)">查看详情</el-button>
<el-button
v-if="canEdit(row)"
link
type="primary"
@click="$emit('edit', row)"
v-hasPermi="['price:label:edit']"
>编辑</el-button>
<el-button
v-if="canReschedule(row)"
link
type="primary"
@click="$emit('reschedule', row)"
v-hasPermi="['price:label:edit']"
>修改生效时间</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup>
const props = defineProps({
versions: { type: Array, default: () => [] }
})
defineEmits(['edit', 'reschedule', 'open-details', 'create-version'])
const statusTab = ref('all')
const counts = computed(() => {
const map = { all: 0, '0': 0, '1': 0, '2': 0 }
;(props.versions || []).forEach(v => {
map.all++
const s = String(v?.status ?? '')
if (map[s] != null) map[s]++
})
return map
})
function tabLabel(key) {
const labels = { all: '全部', '0': '待生效', '1': '已生效', '2': '已失效' }
return `${labels[key]} (${counts.value[key] || 0})`
}
const hasPending = computed(() => (props.versions || []).some(v => String(v.status) === '0'))
const filteredVersions = computed(() => {
const list = props.versions || []
if (statusTab.value === 'all') return list
return list.filter(v => String(v.status) === statusTab.value)
})
function formatDay(val) {
if (!val) return ''
return String(val).slice(0, 10)
}
function statusText(status) {
const map = { '0': '待生效', '1': '生效', '2': '已失效' }
return map[String(status)] || '-'
}
function statusTag(status) {
const map = { '0': 'warning', '1': 'success', '2': 'danger' }
return map[String(status)] || 'info'
}
/** 待生效:可编辑价卡 */
function canEdit(row) {
return row && String(row.status) === '0'
}
/** 仅待生效可改生效时间;已生效不可改 */
function canReschedule(row) {
return row && String(row.status) === '0'
}
</script>
<style scoped>
.ver-card { margin-bottom: 16px; }
.ver-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.ver-head-left { display: flex; align-items: center; gap: 8px; }
.ver-title { font-weight: 600; }
.ver-tabs { margin-bottom: 8px; }
.ver-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
</style>