-
- {{ updateSupportLabel }}
-
-
仅允许导入xls、xlsx格式文件。
-
下载模板
+
+
+
+
+
+
+
+
+
+
点击或拖拽上传文件
+
支持 {{ accept }} 格式,单次最多导入 1 个文件,文件大小不超过 {{ fileSizeMb }} MB
-
-
+
+
+
+
+
+
+ {{ selectedFile.name }}
+
+
+
+
+
+
+
+
+ {{ validateError }}
+
+
+
+
+
{{ updateSupportLabel }}
+
+
+
+ 仅允许上传 {{ accept }} 格式文件,文件大小不超过 {{ fileSizeMb }} MB。
+ 下载模板
+
+
+
+
@@ -36,7 +83,7 @@ const props = defineProps({
// 对话框宽度
width: {
type: String,
- default: '400px'
+ default: '420px'
},
// 上传接口地址(必传)
action: {
@@ -62,6 +109,31 @@ const props = defineProps({
defaultUpdateSupport: {
type: Boolean,
default: false
+ },
+ // 是否显示「覆盖更新」勾选(设为 false 可完全隐藏该选项)
+ showUpdateSupport: {
+ type: Boolean,
+ default: true
+ },
+ // 接收的文件类型(扩展名或 MIME),默认 xlsx/xls
+ accept: {
+ type: String,
+ default: '.xlsx, .xls'
+ },
+ // 单文件大小上限(MB),默认 50
+ fileSizeMb: {
+ type: Number,
+ default: 50
+ },
+ // 业务说明(字符串数组),显示在弹窗顶部 importDesc 段
+ importDesc: {
+ type: Array,
+ default: () => []
+ },
+ // 确定按钮文字
+ submitText: {
+ type: String,
+ default: '确 定'
}
})
@@ -72,6 +144,8 @@ const visible = ref(false)
const selectedFile = ref(null)
const isUploading = ref(false)
const updateSupport = ref(false)
+const progressPercent = ref(0)
+const validateError = ref('')
const headers = { Authorization: 'Bearer ' + getToken() }
const uploadUrl = computed(() => {
@@ -84,6 +158,8 @@ const templateUrl = computed(() => !!props.templateAction)
function open() {
updateSupport.value = props.defaultUpdateSupport
isUploading.value = false
+ progressPercent.value = 0
+ validateError.value = ''
visible.value = true
nextTick(() => {
selectedFile.value = null
@@ -94,6 +170,8 @@ function open() {
// 关闭时清理
function handleClose() {
isUploading.value = false
+ progressPercent.value = 0
+ validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
}
@@ -104,24 +182,64 @@ function handleDownloadTemplate() {
}
// 上传进度
-function handleProgress() {
+function handleProgress(event, file) {
isUploading.value = true
+ if (file && typeof file.percent === 'number') {
+ progressPercent.value = Math.round(file.percent)
+ }
+}
+
+// 文件类型/大小校验
+function checkFile(file) {
+ if (!file) return '请先选择文件'
+ const name = (file.name || '').toLowerCase()
+ const types = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
+ if (types.length) {
+ const ok = types.some(t => {
+ const ext = t.startsWith('.') ? t : '.' + t
+ return name.endsWith(ext)
+ })
+ if (!ok) return `仅允许上传 ${props.accept} 格式文件`
+ }
+ const sizeMb = file.size / 1024 / 1024
+ if (sizeMb > props.fileSizeMb) return `文件大小不能超过 ${props.fileSizeMb} MB`
+ return ''
}
/** 文件选择处理 */
const handleFileChange = (file, fileList) => {
+ progressPercent.value = 0
+ const err = checkFile(file)
+ if (err) {
+ validateError.value = err
+ selectedFile.value = null
+ nextTick(() => uploadRef.value?.clearFiles())
+ return
+ }
+ validateError.value = ''
selectedFile.value = file
}
/** 文件删除处理 */
const handleFileRemove = (file, fileList) => {
selectedFile.value = null
+ progressPercent.value = 0
+ validateError.value = ''
+}
+
+/** 手动清除已选文件 */
+function handleClearFile() {
+ selectedFile.value = null
+ validateError.value = ''
+ uploadRef.value?.clearFiles()
}
// 上传成功
function handleSuccess(response) {
visible.value = false
isUploading.value = false
+ progressPercent.value = 0
+ validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
proxy.$alert("
" + response.msg + '
', '导入结果', { dangerouslyUseHTMLString: true })
@@ -131,8 +249,9 @@ function handleSuccess(response) {
// 提交上传
function handleSubmit() {
const file = selectedFile.value
- if (!file || file.length === 0 || !file.name.toLowerCase().endsWith('.xls') && !file.name.toLowerCase().endsWith('.xlsx')) {
- proxy.$modal.msgError("请选择后缀为 “xls”或“xlsx”的文件。")
+ const err = checkFile(file)
+ if (err) {
+ proxy.$modal.msgError(err)
return
}
uploadRef.value.submit()
@@ -140,3 +259,167 @@ function handleSubmit() {
defineExpose({ open })
+
+
diff --git a/bms-front/src/views/data/region/index.vue b/bms-front/src/views/data/region/index.vue
index ebeb774d..9db2300d 100644
--- a/bms-front/src/views/data/region/index.vue
+++ b/bms-front/src/views/data/region/index.vue
@@ -5,6 +5,7 @@
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
+ :span-method="objectSpanMethod"
row-key="regionId"
>
@@ -285,6 +286,53 @@ function queryList(params) {
return listRegion(params)
}
+// 单元格合并:按「洲/大区」continent 合并相同大区的行(仅作用于 continent 列)
+// 关键:el-table 在 :data 引用变化时会重新调用 span-method,
+// 但这里 BasicTable 内部的 tableData 是异步请求回来的,父组件拿不到其引用,
+// 因此用 watch + nextTick + el-table.doLayout() 强制刷新。
+const spanMap = ref([])
+function rebuildSpanMap() {
+ const rows = tableRef.value?.getData?.() || []
+ const map = new Array(rows.length)
+ for (let i = 0; i < rows.length; i++) {
+ const cur = rows[i]?.continent
+ if (i > 0 && rows[i - 1]?.continent === cur) {
+ map[i] = { rowspan: 0, colspan: 0 }
+ } else {
+ let count = 1
+ while (i + count < rows.length && rows[i + count]?.continent === cur) count++
+ map[i] = { rowspan: count, colspan: 1 }
+ }
+ }
+ spanMap.value = map
+ // 强制 el-table 重新调用 span-method
+ tableRef.value?.getTableRef?.()?.doLayout?.()
+}
+function objectSpanMethod({ column, rowIndex }) {
+ if (column.property !== 'continent') return { rowspan: 1, colspan: 1 }
+ return spanMap.value[rowIndex] || { rowspan: 1, colspan: 1 }
+}
+// 监听 BasicTable 数据变化
+// 关键:expose.data 是 ref 对象;getData() 是 ()=>tableData.value 函数
+// watch 源用 getData() 函数(Vue 会调用 source getter 并比较返回值),
+// 每次 tableData.value 被重新赋值(分页/搜索/刷新),返回值(新数组)变化,watch 触发
+onMounted(() => {
+ let lastLen = -1
+ watch(
+ () => {
+ const ref = tableRef.value?.data
+ return ref?.value || []
+ },
+ (rows) => {
+ if (rows.length !== lastLen) {
+ lastLen = rows.length
+ nextTick(rebuildSpanMap)
+ }
+ },
+ { immediate: true }
+ )
+})
+
const data = reactive({
form: {},
rules: {
diff --git a/bms-front/src/views/price/basic/product/index.vue b/bms-front/src/views/price/basic/product/index.vue
index 6f94c04e..f3386acd 100644
--- a/bms-front/src/views/price/basic/product/index.vue
+++ b/bms-front/src/views/price/basic/product/index.vue
@@ -20,10 +20,10 @@
新建产品
- 修改
+ 修改
- 删除
+ 删除
导入 Excel
@@ -48,8 +48,8 @@
产品明细
- 编辑
- 删除
+ 编辑
+ 删除
@@ -123,7 +123,8 @@
-
+
+
@@ -173,6 +174,15 @@ const countryOpen = ref(false)
const countryTitle = ref('')
const countryLoading = ref(false)
const countryList = ref([])
+const countryKeyword = 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 columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
@@ -225,6 +235,15 @@ function queryList(params) {
return listProduct(params)
}
+// 判断产品是否"生效中":status='0'(未停用)且 effectiveDate <= 今天
+function isProductActive(row) {
+ if (!row) return false
+ if (row.status !== '0') return false
+ if (!row.effectiveDate) return false
+ const today = new Date().toISOString().slice(0, 10)
+ return String(row.effectiveDate).slice(0, 10) <= today
+}
+
function reset() {
form.value = {
productId: undefined,
@@ -249,6 +268,10 @@ function handleAdd() {
}
function handleUpdate(row) {
+ if (isProductActive(row)) {
+ proxy.$modal.msgError('生效中的产品不可编辑')
+ return
+ }
reset()
getProduct(row.productId).then(res => {
form.value = res.data
@@ -288,12 +311,31 @@ function openCountries(row) {
countryOpen.value = true
countryLoading.value = true
countryList.value = []
+ countryKeyword.value = ''
listProductCountries(row.productCode).then(res => {
- countryList.value = res.data || []
+ // 按 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 }
+}