修改公共组件

This commit is contained in:
wuweihua
2026-09-09 10:33:09 +08:00
parent f2f17f41f6
commit fddd8f0586
11 changed files with 658 additions and 451 deletions
@@ -34,4 +34,6 @@ public class BmsProductCountry implements Serializable
private Date updatedAt;
/** query only: true = expire_date is null */
private Boolean openOnly;
/** query only: ACTIVE已生效 / PENDING待生效 / EXPIRED已失效(按生效/失效日期与当前日期派生) */
private String effectStatus;
}
@@ -20,12 +20,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="expireDate" column="expire_date" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
<result property="effectStatus" column="effect_status" />
</resultMap>
<!-- 派生状态:已失效(过期) > 待生效(未到生效日) > 已生效 -->
<sql id="effectStatusCase">
case
when expire_date is not null and expire_date &lt;= curdate() then 'EXPIRED'
when effective_date is null or effective_date &gt; curdate() then 'PENDING'
else 'ACTIVE'
end
</sql>
<sql id="selectVo">
select detail_id, product_code, country_name, country_code, weight_min, weight_max,
freight_price, registration_fee, size_limit, last_mile, lead_time,
effective_date, expire_date, created_at, updated_at
effective_date, expire_date, created_at, updated_at,
<include refid="effectStatusCase"/> as effect_status
from bms_product_country
</sql>
@@ -53,6 +64,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="openOnly != null and openOnly">
AND expire_date is null
</if>
<if test="effectStatus != null and effectStatus != ''">
AND (<include refid="effectStatusCase"/>) = #{effectStatus}
</if>
</where>
order by effective_date desc, country_name asc, weight_min asc, detail_id asc
</select>
@@ -0,0 +1,121 @@
<template>
<el-table
ref="tableRef"
class="simple-table"
v-loading="loading"
:data="data"
:row-key="rowKey"
:border="border"
:stripe="stripe"
:size="size"
:height="height"
:max-height="maxHeight"
:span-method="spanMethod"
:empty-text="emptyText"
:highlight-current-row="highlightCurrentRow"
@selection-change="(val) => $emit('selection-change', val)"
@row-click="(row, col, e) => $emit('row-click', row, col, e)"
>
<el-table-column v-if="selection" type="selection" width="55" align="center" />
<el-table-column v-if="showIndex" type="index" :label="indexLabel" width="55" align="center" />
<el-table-column
v-for="col in columns"
:key="col.key || col.prop || col.label"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align"
:fixed="col.fixed"
:sortable="col.sortable"
:show-overflow-tooltip="col.showOverflowTooltip"
>
<template #header="scope">
<slot :name="`header-${col.prop}`" v-bind="scope">{{ col.label }}</slot>
</template>
<template #default="scope">
<slot :name="`col-${col.slot || col.prop}`" v-bind="scope" :row="scope.row">
<dict-tag
v-if="col.dict"
:options="getDictOptions(col.dict)"
:value="scope.row[col.prop]"
/>
<span v-else-if="col.formatter" :class="col.className">{{ col.formatter(scope.row, col, scope.row[col.prop], scope.$index) }}</span>
<span v-else :class="col.className">{{ scope.row[col.prop] }}</span>
</slot>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
/**
* SimpleTable ¡ª local-data table (no search / toolbar / pagination).
* Column config aligns with BasicTable: prop, label, width, minWidth, align, fixed,
* sortable, showOverflowTooltip, dict, formatter, slot, className.
*
* <simple-table :data="list" :columns="columns" :loading="loading" border>
* <template #col-action="{ row }">...</template>
* </simple-table>
*/
defineOptions({ name: 'SimpleTable' })
const props = defineProps({
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
rowKey: { type: [String, Function], default: undefined },
border: { type: Boolean, default: false },
stripe: { type: Boolean, default: false },
size: { type: String, default: undefined },
height: { type: [String, Number], default: undefined },
maxHeight: { type: [String, Number], default: undefined },
spanMethod: { type: Function, default: null },
emptyText: { type: String, default: undefined },
selection: { type: Boolean, default: false },
showIndex: { type: Boolean, default: false },
indexLabel: { type: String, default: '#' },
highlightCurrentRow: { type: Boolean, default: false }
})
defineEmits(['selection-change', 'row-click'])
const { proxy } = getCurrentInstance()
const tableRef = ref()
const dictMap = reactive({})
const dictTypes = computed(() => {
const set = new Set()
props.columns.forEach((c) => c.dict && set.add(c.dict))
return [...set]
})
watch(
dictTypes,
(types) => {
if (!types.length) return
const dictRefs = proxy.useDict(...types)
types.forEach((t) => {
const d = dictRefs[t]
dictMap[t] = isRef(d) ? d : ref(d)
})
},
{ immediate: true }
)
function getDictOptions(type) {
const d = dictMap[type]
return isRef(d) ? d.value : d || []
}
defineExpose({
getTableRef: () => tableRef.value
})
</script>
<style scoped>
.simple-table {
width: 100%;
}
</style>
+2
View File
@@ -47,6 +47,7 @@ import DictTag from '@/components/DictTag'
import BasicForm from '@/components/BasicForm'
// 公共表格组件
import BasicTable from '@/components/BasicTable'
import SimpleTable from '@/components/SimpleTable'
const app = createApp(App)
@@ -71,6 +72,7 @@ app.component('RightToolbar', RightToolbar)
app.component('Editor', Editor)
app.component('BasicForm', BasicForm)
app.component('BasicTable', BasicTable)
app.component('SimpleTable', SimpleTable)
app.use(router)
app.use(store)
@@ -0,0 +1,107 @@
<template>
<el-drawer v-model="visible" size="920px" :title="title" append-to-body>
<div class="mb8" style="display: flex; align-items: center; gap: 12px">
<el-input v-model="keyword" placeholder="搜索国家(中文名 / ISO2)" clearable style="width: 260px" />
<!-- <el-select v-model="status" placeholder="状态(全部)" clearable style="width: 160px">
<el-option v-for="d in bms_product_effect_status" :key="d.value" :label="d.label" :value="d.value" />
</el-select> -->
</div>
<simple-table
:data="filteredList"
:columns="columns"
:loading="loading"
border
max-height="calc(100vh - 120px)"
empty-text="暂无国家明细"
:span-method="spanMethod"
>
<template #col-sizeLimit="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
</template>
<template #col-lastMile="{ row }">
<span class="pre-wrap">{{ row.lastMile }}</span>
</template>
</simple-table>
</el-drawer>
</template>
<script setup>
import SimpleTable from '@/components/SimpleTable'
import { listProductCountries } from '@/api/price/product'
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const columns = [
{ prop: 'countryName', label: '国家', width: 90, showOverflowTooltip: true },
{ prop: 'countryCode', label: 'ISO2', width: 60, align: 'center' },
{
prop: 'weightMin',
label: '重量段(KG)',
width: 120,
align: 'center',
formatter: (row) => (row.weightMin != null || row.weightMax != null ? (row.weightMin + ' ~ ' + row.weightMax) : '-')
},
{ prop: 'freightPrice', label: '运费(RMB/KG)', width: 110, align: 'right' },
{ prop: 'registrationFee', label: '挂号费(RMB/票)', width: 120, align: 'right' },
{ prop: 'sizeLimit', label: '尺寸限制', minWidth: 140 },
{ prop: 'lastMile', label: '尾程', minWidth: 110 },
{ prop: 'leadTime', label: '时效', width: 100, showOverflowTooltip: true },
{ prop: 'effectiveDate', label: '生效日期', width: 110, align: 'center' },
{ prop: 'expireDate', label: '失效日期', width: 110, align: 'center', formatter: (row) => row.expireDate || '长期' },
{ prop: 'effectStatus', label: '状态', width: 90, align: 'center', dict: 'bms_product_effect_status' }
]
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const keyword = ref('')
const status = ref('')
const filteredList = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const st = status.value
return list.value.filter(r => {
if (kw && !(String(r.countryName || '').toLowerCase().includes(kw) ||
String(r.countryCode || '').toLowerCase().includes(kw))) return false
if (st && r.effectStatus !== st) return false
return true
})
})
function open(row) {
title.value = row.productCode + ' \u00b7 ' + row.productName + ' \u00b7 \u56fd\u5bb6\u670d\u52a1\u660e\u7ec6'
visible.value = true
loading.value = true
list.value = []
keyword.value = ''
status.value = ''
listProductCountries(row.productCode).then(res => {
list.value = (res.data || []).slice().sort((a, b) => {
return String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
})
}).finally(() => {
loading.value = false
})
}
function spanMethod({ column, rowIndex }) {
if (column.property !== 'countryName' && column.property !== 'countryCode') return
const rows = filteredList.value
if (!rows.length) return
const cur = rows[rowIndex]?.countryCode
if (rowIndex > 0 && rows[rowIndex - 1]?.countryCode === cur) {
return { rowspan: 0, colspan: 0 }
}
let count = 1
while (rowIndex + count < rows.length && rows[rowIndex + count]?.countryCode === cur) count++
return { rowspan: count, colspan: 1 }
}
defineExpose({ open })
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
.pre-wrap { white-space: pre-wrap; font-size: 12px; line-height: 1.4; }
</style>
@@ -0,0 +1,157 @@
<template>
<el-dialog :title="title" v-model="visible" width="640px" append-to-body>
<basic-form ref="formRef" v-model="form" :schemas="schemas" label-width="100px" />
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
</el-dialog>
</template>
<script setup>
import BasicForm from '@/components/BasicForm'
import { getProduct, addProduct, updateProduct } from '@/api/price/product'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('')
const form = ref(emptyForm())
const schemas = computed(() => [
{
label: '产品代码',
prop: 'productCode',
component: 'input',
placeholder: '如 WSP037',
span: 12,
width: '100%',
disabled: !!form.value.productId,
props: { maxlength: 20 },
rules: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
},
{
label: '产品名称',
prop: 'productName',
component: 'input',
placeholder: '产品名称',
span: 12,
width: '100%',
props: { maxlength: 100 },
rules: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }]
},
{
label: '时效等级',
prop: 'serviceLevel',
component: 'input',
placeholder: '如 标准',
span: 12,
width: '100%',
props: { maxlength: 20 }
},
{
label: '走货属性',
prop: 'cargoAttr',
component: 'input',
placeholder: '普货/带电等',
span: 12,
width: '100%',
props: { maxlength: 100 }
},
{
label: '目的区域',
prop: 'destRegion',
component: 'input',
placeholder: '如 欧盟',
span: 12,
width: '100%',
props: { maxlength: 50 }
},
{
label: '税模式',
prop: 'taxMode',
component: 'select',
placeholder: '请选择',
dict: 'bms_tax_mode',
span: 12,
width: '100%',
rules: [{ required: true, message: '请选择税模式', trigger: 'change' }]
},
{
label: '生效日期',
prop: 'effectiveDate',
component: 'date',
placeholder: '选择日期',
span: 12,
width: '100%'
},
{
label: '更新内容',
prop: 'updateNote',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
},
{
label: '备注',
prop: 'remark',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
}
])
function emptyForm() {
return {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
status: '0',
remark: undefined
}
}
function reset() {
form.value = emptyForm()
nextTick(() => formRef.value?.clearValidate())
}
function open(row) {
reset()
if (!row) {
title.value = '新建产品'
visible.value = true
return
}
getProduct(row.productId).then(res => {
form.value = { ...emptyForm(), ...(res.data || {}) }
title.value = '修改产品'
visible.value = true
nextTick(() => formRef.value?.clearValidate())
})
}
function submitForm() {
formRef.value.validate(valid => {
if (!valid) return
const req = form.value.productId ? updateProduct(form.value) : addProduct(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
@@ -0,0 +1,136 @@
<template>
<el-drawer v-model="visible" size="920px" :title="title" append-to-body>
<div class="mb8" style="display: flex; justify-content: space-between">
<el-button type="primary" plain icon="Plus" @click="handleRelAdd" v-hasPermi="['price:surcharge:add']">新增关联</el-button>
</div>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" empty-text="该产品暂无关联附加费">
<el-table-column label="附加费名称" prop="scName" min-width="140" show-overflow-tooltip />
<el-table-column label="计费方式" width="110" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="140" align="right">
<template #default="{ row }">
<span class="charge-value">{{ formatChargeValue(row) }}</span>
</template>
</el-table-column>
<el-table-column label="取值方式" width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<el-table-column label="适用国家" width="110" align="center">
<template #default="{ row }">{{ countryLabel(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="作用范围" width="170" align="center">
<template #default="{ row }">
<el-tag v-if="row.detailId == null" type="primary" size="small" effect="plain">产品级 · 全部明细</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ row.countryName || '-' }} · {{ row.weightMin }}-{{ row.weightMax }}kg</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="String(row.ssCode).toLowerCase() === 'on' ? 'success' : 'info'" size="small">
{{ String(row.ssCode).toLowerCase() === 'on' ? '有效' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="scNote" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" icon="Delete" @click="handleRelDelete(row.relId)" v-hasPermi="['price:surcharge:remove']">解绑</el-button>
</template>
</el-table-column>
</el-table>
<surcharge-rel-dialog ref="relRef" @success="handleRelSuccess" />
</el-drawer>
</template>
<script setup>
import { onMounted } from 'vue'
import { listSurchargeByProduct, delSurchargeRel } from '@/api/price/surcharge'
import { listRegionOptions } from '@/api/data/region'
import SurchargeRelDialog from './SurchargeRelDialog.vue'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const relRef = ref()
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const product = ref(null)
const regionOptions = ref([])
function open(row) {
product.value = row
title.value = row.productCode + ' · ' + row.productName + ' · 附加费关联'
visible.value = true
loadList()
}
function loadList() {
if (!product.value) return
loading.value = true
listSurchargeByProduct(product.value.productCode).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function handleRelAdd() {
relRef.value.open(product.value)
}
function handleRelSuccess() {
loadList()
emit('success')
}
function handleRelDelete(relId) {
proxy.$modal.confirm('是否确认解绑该附加费?').then(() => {
return delSurchargeRel(relId)
}).then(() => {
proxy.$modal.msgSuccess('解绑成功')
loadList()
emit('success')
}).catch(() => {})
}
function chargeTypeLabel(val) {
const found = bms_charge_type.value.find(c => c.value === val)
return found ? found.label : (val || '-')
}
function roundModeLabel(val) {
const found = bms_round_mode.value.find(d => d.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function countryLabel(code) {
if (!code) return '全部'
const found = regionOptions.value.find(r => r.countryCode2 === code)
return found ? (found.countryNameCn || found.countryNameEn || code) : code
}
onMounted(() => {
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
})
defineExpose({ open })
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
.charge-value { font-weight: 600; color: #d63031; }
</style>
@@ -0,0 +1,98 @@
<template>
<el-dialog :title="title" v-model="visible" width="560px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="附加费" prop="scId">
<el-select v-model="form.scId" placeholder="请选择附加费" filterable style="width: 100%">
<el-option v-for="s in surchargePool" :key="s.scId" :label="s.scName" :value="s.scId" />
</el-select>
</el-form-item>
<el-form-item label="作用范围" prop="scope">
<el-radio-group v-model="form.scope">
<el-radio value="PRODUCT">产品级(全部明细)</el-radio>
<el-radio value="DETAIL">明细级(指定国家×重量段)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.scope === 'DETAIL'" label="明细" prop="detailId">
<el-select v-model="form.detailId" placeholder="请选择明细" filterable style="width: 100%">
<el-option v-for="d in countryList" :key="d.detailId" :label="detailLabel(d)" :value="d.detailId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submit">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { listProductCountries } from '@/api/price/product'
import { listSurcharge, addSurchargeRel } from '@/api/price/surcharge'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('新增附加费关联')
const product = ref(null)
const surchargePool = ref([])
const countryList = ref([])
const form = ref({})
const rules = {
scId: [{ required: true, message: '请选择附加费', trigger: 'change' }]
}
function detailLabel(d) {
const w = d.weightMin != null && d.weightMax != null ? (d.weightMin + '-' + d.weightMax + 'kg') : '-'
return (d.countryName || d.countryCode || '-') + ' · ' + w
}
function reset() {
form.value = {
scId: undefined,
scope: 'PRODUCT',
detailId: undefined
}
formRef.value?.resetFields()
}
function open(row) {
product.value = row
reset()
visible.value = true
listSurcharge({ ssCode: 'on', pageNum: 1, pageSize: 1000 }).then(res => {
surchargePool.value = res.rows || []
})
if (row && row.productCode) {
listProductCountries(row.productCode).then(res => {
countryList.value = res.data || []
})
}
}
function submit() {
formRef.value.validate(valid => {
if (!valid) return
if (form.value.scope === 'DETAIL' && !form.value.detailId) {
proxy.$modal.msgError('请选择明细')
return
}
const payload = {
scId: form.value.scId,
productId: product.value ? product.value.productId : undefined,
productCode: product.value ? product.value.productCode : undefined,
detailId: form.value.scope === 'DETAIL' ? form.value.detailId : null
}
addSurchargeRel(payload).then(() => {
proxy.$modal.msgSuccess('关联成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
+16 -404
View File
@@ -39,186 +39,16 @@
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">产品明细</el-button>
<el-button link type="primary" icon="Money" @click="openSurcharges(row)" v-hasPermi="['price:surcharge:query']">附加费</el-button>
<el-button link type="primary" icon="View" @click="countryRef.open(row)" v-hasPermi="['price:product:query']">产品明细</el-button>
<el-button link type="primary" icon="Money" @click="surchargeRef.open(row)" v-hasPermi="['price:surcharge:query']">附加费</el-button>
<el-button :disabled="isProductActive(row)" :title="isProductActive(row) ? '已生效不可编辑' : ''" link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button :disabled="isProductActive(row)" :title="isProductActive(row) ? '已生效不可删除' : ''" link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
</basic-table>
<el-dialog :title="title" v-model="open" width="640px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row>
<el-col :span="12">
<el-form-item label="产品代码" prop="productCode">
<el-input v-model="form.productCode" placeholder="如 WSP037" maxlength="20" :disabled="!!form.productId" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="form.productName" placeholder="产品名称" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="时效等级" prop="serviceLevel">
<el-input v-model="form.serviceLevel" placeholder="如 标准" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="走货属性" prop="cargoAttr">
<el-input v-model="form.cargoAttr" placeholder="普货/带电等" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="目的区域" prop="destRegion">
<el-input v-model="form.destRegion" placeholder="如 欧盟" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="税模式" prop="taxMode">
<el-select v-model="form.taxMode" placeholder="请选择" style="width: 100%">
<el-option
v-for="dict in bms_tax_mode"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker v-model="form.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio value="0">启用(按生效日期自动:待生效/已生效)</el-radio>
<el-radio value="1">已失效</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新内容" prop="updateNote">
<el-input v-model="form.updateNote" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="open = false">取 消</el-button>
</template>
</el-dialog>
<el-drawer v-model="countryOpen" size="920px" :title="countryTitle" append-to-body>
<el-input v-model="countryKeyword" placeholder="搜索国家(中文名 / ISO2)" clearable class="mb8" style="width: 260px" />
<el-table v-loading="countryLoading" :data="filteredCountryList" border :span-method="countrySpanMethod" max-height="calc(100vh - 120px)" empty-text="暂无国家明细">
<el-table-column label="国家" prop="countryName" width="90" show-overflow-tooltip />
<el-table-column label="ISO2" prop="countryCode" width="60" align="center" />
<el-table-column label="重量段(KG)" width="120" align="center">
<template #default="{ row }">
<span v-if="row.weightMin != null || row.weightMax != null">{{ row.weightMin }} ~ {{ row.weightMax }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="运费(RMB/KG)" prop="freightPrice" width="110" align="right" />
<el-table-column label="挂号费(RMB/票)" prop="registrationFee" width="120" align="right" />
<el-table-column label="尺寸限制" prop="sizeLimit" min-width="140">
<template #default="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
</template>
</el-table-column>
<el-table-column label="尾程" prop="lastMile" min-width="110">
<template #default="{ row }">
<span class="pre-wrap">{{ row.lastMile }}</span>
</template>
</el-table-column>
<el-table-column label="时效" prop="leadTime" width="100" show-overflow-tooltip />
<el-table-column label="生效日期" prop="effectiveDate" width="110" align="center" />
<el-table-column label="失效日期" width="110" align="center">
<template #default="{ row }">{{ row.expireDate || '长期' }}</template>
</el-table-column>
</el-table>
</el-drawer>
<el-drawer v-model="surchargeOpen" size="920px" :title="surchargeTitle" append-to-body>
<div class="mb8" style="display: flex; justify-content: space-between">
<el-button type="primary" plain icon="Plus" @click="handleRelAdd" v-hasPermi="['price:surcharge:add']">新增关联</el-button>
</div>
<el-table v-loading="surchargeLoading" :data="surchargeList" border max-height="calc(100vh - 160px)" empty-text="该产品暂无关联附加费">
<el-table-column label="附加费名称" prop="scName" min-width="140" show-overflow-tooltip />
<el-table-column label="计费方式" width="110" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="140" align="right">
<template #default="{ row }">
<span class="charge-value">{{ formatChargeValue(row) }}</span>
</template>
</el-table-column>
<el-table-column label="取值方式" width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<el-table-column label="适用国家" width="110" align="center">
<template #default="{ row }">{{ countryLabel(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="作用范围" width="170" align="center">
<template #default="{ row }">
<el-tag v-if="row.detailId == null" type="primary" size="small" effect="plain">产品级 · 全部明细</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ row.countryName || '-' }} · {{ row.weightMin }}-{{ row.weightMax }}kg</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="String(row.ssCode).toLowerCase() === 'on' ? 'success' : 'info'" size="small">
{{ String(row.ssCode).toLowerCase() === 'on' ? '有效' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="scNote" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" icon="Delete" @click="handleRelDelete(row.relId)" v-hasPermi="['price:surcharge:remove']">解绑</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog :title="relTitle" v-model="relOpen" width="560px" append-to-body>
<el-form ref="relFormRef" :model="relForm" :rules="relRules" label-width="100px">
<el-form-item label="附加费" prop="scId">
<el-select v-model="relForm.scId" placeholder="请选择附加费" filterable style="width: 100%">
<el-option v-for="s in surchargePool" :key="s.scId" :label="s.scName" :value="s.scId" />
</el-select>
</el-form-item>
<el-form-item label="作用范围" prop="scope">
<el-radio-group v-model="relForm.scope">
<el-radio value="PRODUCT">产品级(全部明细)</el-radio>
<el-radio value="DETAIL">明细级(指定国家×重量段)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="relForm.scope === 'DETAIL'" label="明细" prop="detailId">
<el-select v-model="relForm.detailId" placeholder="请选择明细" filterable style="width: 100%">
<el-option v-for="d in countryList" :key="d.detailId" :label="detailLabel(d)" :value="d.detailId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitRel">确 定</el-button>
<el-button @click="relOpen = false">取 消</el-button>
</div>
</template>
</el-dialog>
<product-form-dialog ref="formRef" @success="() => tableRef.refresh()" />
<product-country-drawer ref="countryRef" />
<product-surcharge-drawer ref="surchargeRef" @success="() => tableRef.refresh()" />
<excel-import-dialog
ref="importRef"
title="产品 Excel 导入"
@@ -234,43 +64,19 @@
<script setup name="PriceProduct">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import { listProduct, getProduct, addProduct, updateProduct, delProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct, listSurcharge, addSurchargeRel, delSurchargeRel } from '@/api/price/surcharge'
import { listRegionOptions } from '@/api/data/region'
import { onMounted } from 'vue'
import ProductFormDialog from './components/ProductFormDialog.vue'
import ProductCountryDrawer from './components/ProductCountryDrawer.vue'
import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue'
import { listProduct, delProduct } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_product_effect_status, bms_tax_mode, bms_round_mode, bms_charge_type } = useDict('bms_product_effect_status', 'bms_tax_mode', 'bms_round_mode', 'bms_charge_type')
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const tableRef = ref()
const importRef = ref()
const formRef = ref()
const open = ref(false)
const title = ref('')
const countryOpen = ref(false)
const countryTitle = ref('')
const countryLoading = ref(false)
const countryList = ref([])
const countryKeyword = ref('')
const surchargeOpen = ref(false)
const surchargeTitle = ref('')
const surchargeLoading = ref(false)
const surchargeList = ref([])
const currentProduct = ref(null)
const regionOptions = ref([])
const surchargePool = ref([])
const relOpen = ref(false)
const relTitle = ref('')
const relFormRef = ref()
const filteredCountryList = computed(() => {
const kw = countryKeyword.value.trim().toLowerCase()
if (!kw) return countryList.value
return countryList.value.filter(r =>
String(r.countryName || '').toLowerCase().includes(kw) ||
String(r.countryCode || '').toLowerCase().includes(kw)
)
})
const countryRef = ref()
const surchargeRef = ref()
const importRef = ref()
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
@@ -308,16 +114,6 @@ const searchColumns = [
}
]
const data = reactive({
form: {},
rules: {
productCode: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }],
productName: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
taxMode: [{ required: true, message: '请选择税模式', trigger: 'change' }]
}
})
const { form, rules } = toRefs(data)
function queryList(params) {
return listProduct(params)
}
@@ -334,32 +130,12 @@ function resolveEffectStatus(row) {
return String(row.effectiveDate).slice(0, 10) <= todayStr() ? 'ACTIVE' : 'PENDING'
}
// 已生效不可编辑/删除
function isProductActive(row) {
return resolveEffectStatus(row) === 'ACTIVE'
}
function reset() {
form.value = {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
status: '0',
remark: undefined
}
proxy.resetForm('formRef')
}
function handleAdd() {
reset()
open.value = true
title.value = '新建产品'
formRef.value.open()
}
function handleUpdate(row) {
@@ -367,24 +143,7 @@ function handleUpdate(row) {
proxy.$modal.msgError('已生效的产品不可编辑')
return
}
reset()
getProduct(row.productId).then(res => {
form.value = res.data
open.value = true
title.value = '修改产品'
})
}
function submitForm() {
proxy.$refs['formRef'].validate(valid => {
if (!valid) return
const req = form.value.productId ? updateProduct(form.value) : addProduct(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
open.value = false
tableRef.value.refresh()
})
})
formRef.value.open(row)
}
function handleDelete(ids) {
@@ -400,155 +159,8 @@ function handleDelete(ids) {
function handleImport() {
importRef.value.open()
}
function openCountries(row) {
countryTitle.value = `${row.productCode} · ${row.productName} · 国家服务明细`
countryOpen.value = true
countryLoading.value = true
countryList.value = []
countryKeyword.value = ''
listProductCountries(row.productCode).then(res => {
// 按 ISO2 排序,保证相同国家相邻,便于合并单元格
const list = (res.data || []).slice().sort((a, b) => {
return String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
})
countryList.value = list
}).finally(() => {
countryLoading.value = false
})
}
// 弹窗表格合并:相同 countryCode 的连续行合并「国家」「ISO2」列
function countrySpanMethod({ column, rowIndex }) {
if (column.property !== 'countryName' && column.property !== 'countryCode') return
const list = filteredCountryList.value
if (!list.length) return
const cur = list[rowIndex]?.countryCode
if (rowIndex > 0 && list[rowIndex - 1]?.countryCode === cur) {
return { rowspan: 0, colspan: 0 }
}
let count = 1
while (rowIndex + count < list.length && list[rowIndex + count]?.countryCode === cur) count++
return { rowspan: count, colspan: 1 }
}
// ===== 产品附加费关联 =====
const relData = reactive({
relForm: {},
relRules: {
scId: [{ required: true, message: '请选择附加费', trigger: 'change' }]
}
})
const { relForm, relRules } = toRefs(relData)
function openSurcharges(row) {
currentProduct.value = row
surchargeTitle.value = `${row.productCode} · ${row.productName} · 附加费关联`
surchargeOpen.value = true
loadSurcharges()
}
function loadSurcharges() {
if (!currentProduct.value) return
surchargeLoading.value = true
listSurchargeByProduct(currentProduct.value.productCode).then(res => {
surchargeList.value = res.data || []
}).finally(() => {
surchargeLoading.value = false
})
}
function loadSurchargePool() {
return listSurcharge({ ssCode: 'on', pageNum: 1, pageSize: 1000 }).then(res => {
surchargePool.value = res.rows || []
})
}
function resetRelForm() {
relForm.value = {
scId: undefined,
scope: 'PRODUCT',
detailId: undefined
}
relFormRef.value && relFormRef.value.resetFields()
}
function handleRelAdd() {
resetRelForm()
relTitle.value = '新增附加费关联'
relOpen.value = true
loadSurchargePool()
}
function submitRel() {
relFormRef.value.validate(valid => {
if (!valid) return
if (relForm.value.scope === 'DETAIL' && !relForm.value.detailId) {
proxy.$modal.msgError('请选择明细')
return
}
const payload = {
scId: relForm.value.scId,
productId: currentProduct.value ? currentProduct.value.productId : undefined,
productCode: currentProduct.value ? currentProduct.value.productCode : undefined,
detailId: relForm.value.scope === 'DETAIL' ? relForm.value.detailId : null
}
addSurchargeRel(payload).then(() => {
proxy.$modal.msgSuccess('关联成功')
relOpen.value = false
loadSurcharges()
tableRef.value.refresh()
})
})
}
function handleRelDelete(relId) {
proxy.$modal.confirm('是否确认解绑该附加费?').then(() => {
return delSurchargeRel(relId)
}).then(() => {
proxy.$modal.msgSuccess('解绑成功')
loadSurcharges()
tableRef.value.refresh()
}).catch(() => {})
}
function detailLabel(d) {
const w = d.weightMin != null && d.weightMax != null ? `${d.weightMin}-${d.weightMax}kg` : '-'
return `${d.countryName || d.countryCode || '-'} · ${w}`
}
function chargeTypeLabel(val) {
const found = bms_charge_type.value.find(c => c.value === val)
return found ? found.label : (val || '-')
}
function roundModeLabel(val) {
const found = bms_round_mode.value.find(d => d.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function countryLabel(code) {
if (!code) return '全部'
const found = regionOptions.value.find(r => r.countryCode2 === code)
return found ? (found.countryNameCn || found.countryNameEn || code) : code
}
onMounted(() => {
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
})
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.pre-wrap { white-space: pre-wrap; font-size: 12px; line-height: 1.4; }
.charge-value { font-weight: 600; color: #d63031; }
</style>
@@ -176,7 +176,6 @@
<el-empty v-if="!viewResult" description="暂无详情" />
<div v-else class="quote-result">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="试算ID">{{ viewResult.quoteId }}</el-descriptions-item>
<el-descriptions-item label="客户ID">{{ viewResult.customerId }}</el-descriptions-item>
<el-descriptions-item label="产品">{{ viewResult.productName || '-' }} ({{ viewResult.productCode }})</el-descriptions-item>
<el-descriptions-item label="国家">{{ viewResult.countryName || '-' }} ({{ viewResult.countryCode }})</el-descriptions-item>
@@ -240,7 +239,6 @@ const historyLoading = ref(false)
const viewResult = ref(null)
const historyColumns = [
{ label: '试算ID', prop: 'quoteId', align: 'center', width: 90 },
{ label: '客户ID', prop: 'customerId', align: 'center', width: 90 },
{ label: '产品', prop: 'product', minWidth: 160, slot: 'product' },
{ label: '国家', prop: 'country', minWidth: 130, slot: 'country' },
+4 -44
View File
@@ -4,7 +4,6 @@
<el-step title="基本信息" />
<el-step title="套餐明细" />
<el-step title="绑定客户" />
<el-step title="校验确认" />
</el-steps>
<!-- 第 1 步 -->
@@ -256,29 +255,10 @@
</el-table>
</el-card>
<!-- 第 4 步 -->
<el-card v-show="active === 3" shadow="never" class="step-card">
<template #header>
<div class="card-head">
<span>第 4 步 · 校验确认</span>
<el-button type="primary" size="small" @click="runValidate">重新校验</el-button>
</div>
</template>
<el-table :data="checkItems" border empty-text="点击「下一步」或「重新校验」执行校验">
<el-table-column label="校验项" prop="item" min-width="200" />
<el-table-column label="结果" width="120">
<template #default="{ row }">
<el-tag :type="checkTag(row.result)" size="small">{{ checkLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" prop="message" min-width="280" />
</el-table>
</el-card>
<div class="wizard-actions">
<el-button @click="goBack">取消</el-button>
<el-button v-if="active > 0" @click="active--">上一步</el-button>
<el-button v-if="active < 3" type="primary" @click="nextStep">下一步</el-button>
<el-button v-if="active < 2" type="primary" @click="nextStep">下一步</el-button>
<el-button type="info" :loading="saving" @click="saveDraft">保存草稿</el-button>
<el-button type="primary" :loading="saving" @click="saveAndSubmit">保存并提交审核</el-button>
</div>
@@ -286,7 +266,7 @@
</template>
<script setup name="PriceSetCreate">
import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set'
import { getPriceSet, savePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
@@ -296,7 +276,6 @@ const route = useRoute()
const active = ref(0)
const saving = ref(false)
const formRef = ref()
const checkItems = ref([])
const productOptions = ref([])
/** productCode -> bms_product_country rows */
const countryCache = reactive({})
@@ -571,6 +550,8 @@ function buildPayload(submitFlag) {
...c,
customerId: Number(c.customerId)
}))
console.log('details', details)
return {
priceSetId: form.priceSetId,
priceSetName: form.priceSetName,
@@ -592,20 +573,9 @@ function nextStep() {
})
return
}
if (active.value === 2) {
active.value++
runValidate()
return
}
active.value++
}
function runValidate() {
validatePriceSet(buildPayload(false)).then(res => {
checkItems.value = res.data || []
})
}
function saveDraft() {
formRef.value.validate(valid => {
if (!valid) {
@@ -646,16 +616,6 @@ function goBack() {
}
}
function checkTag(result) {
const map = { PASS: 'success', WARN: 'warning', FAIL: 'danger', INFO: 'primary' }
return map[result] || 'info'
}
function checkLabel(result) {
const map = { PASS: '通过', WARN: '待确认', FAIL: '失败', INFO: '提示' }
return map[result] || result
}
function loadExisting(id) {
getPriceSet(id).then(res => {
const data = res.data || {}