Merge remote-tracking branch 'websoft/master'

This commit is contained in:
2026-07-28 23:14:49 +08:00
12 changed files with 1033 additions and 228 deletions

View File

@@ -0,0 +1,182 @@
const typeLevelDic = [
{ label: '一级货物类型', value: 1 },
{ label: '二级货物类型', value: 2 },
];
const validateCargoName = (rule, value, callback) => {
const v = String(value || '').trim();
if (!v) {
callback(new Error('请输入货物类型名称'));
} else if (v.length > 50) {
callback(new Error('货物类型名称不能超过50字符'));
} else {
callback();
}
};
const validateRemark = (rule, value, callback) => {
if (String(value || '').length > 200) {
callback(new Error('备注不能超过200字'));
} else {
callback();
}
};
export const getCargoTypeOption = ctx => ({
height: 'auto',
calcHeight: 32,
dialogWidth: 760,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: false,
searchIcon: false,
searchMenuSpan: 24,
searchIndex: 8,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
viewBtn: false,
delBtn: false,
editBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 220,
column: [
{
label: '类型',
prop: 'typeLevel',
type: 'radio',
dataType: 'number',
dicData: typeLevelDic,
search: true,
searchSpan: 12,
value: 2,
span: 24,
minWidth: 120,
editDisabled: true,
rules: [{ required: true, message: '请选择货物类型级别', trigger: 'change' }],
change: ({ value }) => ctx.handleTypeLevelChange(value),
},
{
label: '上级货物类型',
prop: 'parentId',
formslot: true,
hide: true,
display: false,
span: 24,
rules: [{ required: true, message: '请选择上级货物类型', trigger: 'change' }],
},
{
label: '上级货物类型',
prop: 'parentCargoName',
slot: true,
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
},
{
label: '上级货物类型编码',
prop: 'parentCargoCode',
slot: true,
readonly: true,
editDisabled: true,
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
rules: [{ required: true, message: '请选择上级货物类型', trigger: 'change' }],
},
{
label: '货物类型',
prop: 'cargoName',
minWidth: 150,
maxlength: 50,
showWordLimit: true,
rules: [{ validator: validateCargoName, trigger: 'blur' }],
},
{
label: '货物类型编码',
prop: 'cargoCode',
minWidth: 140,
search: true,
searchSpan: 12,
maxlength: 4,
editDisabled: true,
rules: [
{
validator: (rule, value, callback) => {
const form = ctx.form || {};
const code = String(value || '').trim();
const pattern = form.typeLevel === 1 ? /^\d{2}$/ : /^\d{4}$/;
if (!code) {
callback(new Error('请输入货物类型编码'));
} else if (!pattern.test(code)) {
callback(new Error('货物类型编码格式不正确'));
} else if (
form.typeLevel === 2 &&
form.parentCargoCode &&
!code.startsWith(form.parentCargoCode)
) {
callback(new Error('二级编码前2位必须与上级货物类型编码一致'));
} else {
callback();
}
},
trigger: 'blur',
},
],
change: ({ value }) => ctx.handleCargoCodeChange(value),
},
{
label: '创建人',
prop: 'createUserName',
slot: true,
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
minRows: 4,
span: 24,
minWidth: 180,
maxlength: 200,
showWordLimit: true,
overHidden: true,
rules: [{ validator: validateRemark, trigger: 'blur' }],
},
{
label: '更新时间',
prop: 'updateTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
},
{
label: '创建时间',
prop: 'createTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
},
],
});

View File

@@ -15,14 +15,45 @@ const parseBlobJson = async blob => {
return text ? JSON.parse(text) : {};
};
export const openImportDialog = (vm, businessName, onSuccess) => {
/**
* 加工后端返回的导入失败明细 Excel。
* 若传入 failDetailDecorator函数则动态加载 exceljs 读取 blob 流,
* 交由 decorator 完成改写原因列、调整列宽等加工,再导出新的 blob
* 否则原样返回 blob保持旧行为不影响未使用 decorator 的模块)。
* @param {Blob} blob 后端返回的失败明细 Excel 流
* @param {String} businessName 业务名称(如「货物类型」)
* @param {Function} [failDetailDecorator] async (workbook, { businessName }) => void
* @returns {Promise<Blob>}
*/
const decorateFailExcel = async (blob, businessName, failDetailDecorator) => {
if (typeof failDetailDecorator !== 'function') return blob;
const ExcelJS = await import('exceljs');
const buf = await blob.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buf);
await failDetailDecorator(workbook, { businessName });
const out = await workbook.xlsx.writeBuffer();
return new Blob([out], { type: 'application/vnd.ms-excel' });
};
/**
* 打开导入弹窗
* @param {Object} vm 组件实例
* @param {String} businessName 业务名称,用于失败明细文件命名
* @param {Function} [onSuccess] 导入成功回调
* @param {Object} [options] 扩展项
* @param {Function} [options.failDetailDecorator] 失败明细加工函数,用于对后端返回的失败 Excel 标红/改写原因
*/
export const openImportDialog = (vm, businessName, onSuccess, options = {}) => {
const { failDetailDecorator } = options;
const column = vm.findColumn(vm.excelOption.column, 'excelFile');
column.httpRequest = (option, uploadColumn) =>
handleImportExcel(vm, option, uploadColumn, businessName, onSuccess);
handleImportExcel(vm, option, uploadColumn, businessName, onSuccess, { failDetailDecorator });
vm.excelBox = true;
};
export const handleImportExcel = async (vm, option, column, businessName, onSuccess) => {
export const handleImportExcel = async (vm, option, column, businessName, onSuccess, options = {}) => {
const { failDetailDecorator } = options;
const file = option.file;
const fileName = file && file.name ? file.name.toLowerCase() : '';
if (!excelPattern.test(fileName)) {
@@ -37,8 +68,9 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
try {
const res = await importBlob(column.action, file);
if (isExcelResponse(res)) {
const decorated = await decorateFailExcel(res.data, businessName, failDetailDecorator);
downloadXls(
res.data,
decorated,
`${businessName}导入失败明细${vm.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
vm.$message.warning('部分数据导入失败,已下载失败明细');

View File

@@ -1,5 +1,52 @@
<template>
<basic-container class="cargo-type-page">
<!-- 紧凑搜索栏 -->
<div class="compact-search">
<div class="compact-search__form">
<el-select
v-model="query.typeLevel"
placeholder="选择类型"
clearable
class="compact-search__select"
@change="handleCompactSearch"
>
<el-option label="一级货物类型" :value="1" />
<el-option label="二级货物类型" :value="2" />
</el-select>
<el-input
v-model="query.keyword"
placeholder="搜索分类名称"
clearable
class="compact-search__input"
@keyup.enter="handleCompactSearch"
@clear="handleCompactSearch"
>
<template #append>
<el-button @click="handleCompactSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="compact-search__actions">
<el-button type="primary" v-if="permission.cargo_type_add" @click="$refs.crud.rowAdd()">
<el-icon class="el-icon--left"><Plus /></el-icon>添加分类
</el-button>
<el-button
type="primary"
plain
v-if="permission.cargo_type_import"
@click="handleImport"
>批量导入</el-button>
<el-button
type="danger"
plain
v-if="permission.cargo_type_delete"
@click="handleDelete"
>批量删除</el-button>
</div>
</div>
<avue-crud
:option="option"
:table-loading="loading"
@@ -20,40 +67,6 @@
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
icon="el-icon-upload"
plain
v-if="permission.cargo_type_import"
@click="handleImport"
>批量导入
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="permission.cargo_type_template"
@click="handleTemplate"
>下载模板
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="permission.cargo_type_export"
@click="handleExport"
>批量导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="permission.cargo_type_delete"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #parentCargoName="{ row }">
{{ row.typeLevel === 1 ? '/' : row.parentCargoName || '/' }}
@@ -73,7 +86,7 @@
clearable
:remote-method="loadParentOptions"
:loading="parentLoading"
:disabled="form.typeLevel !== 2"
:disabled="dialogType !== 'add' || form.typeLevel !== 2"
placeholder="请输入名称或编码搜索"
style="width: 100%"
@change="handleParentChange"
@@ -91,7 +104,6 @@
<el-button
type="primary"
text
icon="el-icon-view"
v-if="permission.cargo_type_view"
@click="$refs.crud.rowView(row, index)"
>
@@ -100,7 +112,6 @@
<el-button
type="primary"
text
icon="el-icon-edit"
v-if="permission.cargo_type_edit"
@click="$refs.crud.rowEdit(row, index)"
>
@@ -109,7 +120,6 @@
<el-button
type="primary"
text
icon="el-icon-delete"
v-if="permission.cargo_type_delete"
@click="rowDel(row)"
>
@@ -146,6 +156,7 @@ import {
remove,
submit,
} from '@/api/base/cargo-type';
import { getCargoTypeOption } from '@/option/base/cargo-type';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
@@ -153,46 +164,105 @@ import { openImportDialog } from '@/utils/import-excel';
import { getToken } from '@/utils/auth';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { Search, Plus } from '@element-plus/icons-vue';
const typeLevelDic = [
{ label: '一级货物类型', value: 1 },
{ label: '二级货物类型', value: 2 },
// 导入失败明细中,每个错误文本对应的「字段列」与「规则说明」
const CARGO_TYPE_FAIL_RULES = [
{ match: '请选择货物类型级别', col: '类型', rule: '仅允许:一级货物类型/二级货物类型' },
{ match: '货物类型编码格式不正确', col: '货物类型编码', rule: '一级2位数字二级4位数字' },
{ match: '该货物类型编码已存在', col: '货物类型编码', rule: '请更换唯一编码' },
{ match: '请选择上级货物类型', col: '上级货物类型', rule: '二级货物类型必须选择上级' },
{
match: '二级编码前2位必须与上级货物类型编码一致',
col: '货物类型编码',
rule: '二级编码前2位必须与上级货物类型编码一致',
},
{ match: '货物类型名称不能超过50字符', col: '货物类型', rule: '货物类型名称不能超过50字符' },
{ match: '备注不能超过200字', col: '备注', rule: '备注不能超过200字' },
];
// 失败明细各列目标宽度Excel 字符数key 与后端 CargoTypeImportFailureExcel 表头完全一致
const CARGO_TYPE_FAIL_COL_WIDTH = {
'*类型': 16,
'上级货物类型(如为一级则不需填写)': 22,
'上级货物类型编码(如为一级则不需填写)': 26,
'*货物类型': 18,
'*货物类型编码': 18,
'备注': 24,
'导入失败原因': 70,
};
/**
* 加工后端返回的导入失败明细 Excel
* - 「导入失败原因」列按 A. B. C. 编号列出,多条以分号隔开
* - 每个错误后追加对应规则说明,仅失败原因列标红
* - 适当加宽各列、原因列自动换行,避免内容拥挤
*/
export const decorateCargoTypeFailDetail = async workbook => {
const ws = workbook.worksheets[0];
if (!ws) return;
const headerRow = ws.getRow(1);
const colMap = {};
headerRow.eachCell((cell, colNumber) => {
const name = String(cell.value == null ? '' : cell.value).trim();
if (name) colMap[name] = colNumber;
});
const reasonCol = colMap['导入失败原因'];
if (!reasonCol) return;
ws.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // 跳过表头
// 仅「导入失败原因」列允许红色,其他列红色字体统一清除
row.eachCell((cell, colNumber) => {
if (colNumber === reasonCol) return;
if (cell.font && cell.font.color) {
const font = { ...cell.font };
delete font.color;
cell.font = font;
}
});
const reasonCell = row.getCell(reasonCol);
let raw = String(reasonCell.value == null ? '' : reasonCell.value).trim();
if (!raw) return;
// 去掉后端可能已经存在的「第N行」前缀避免重复
raw = raw.replace(/^第\s*\d+\s*行[:]\s*/, '');
const parts = raw
.split(/[;\n\r]+/)
.map(s => s.trim().replace(/^第\s*\d+\s*行[:]\s*/, ''))
.filter(Boolean);
if (parts.length === 0) return;
const decorated = parts.map((part, index) => {
const label = `${String.fromCharCode(65 + index)}.`; // A. B. C.
const rule = CARGO_TYPE_FAIL_RULES.find(r => part.includes(r.match));
if (rule) {
// 规则说明若已包含在错误文本中则不再重复拼接
return part.includes(rule.rule) ? `${label}${part}` : `${label}${part}${rule.rule}`;
}
return `${label}${part}`;
});
// 兜底:失败行的备注列超长(>200字补充备注超长提示
const remarkCol = colMap['备注'];
if (remarkCol) {
const remarkVal = String(row.getCell(remarkCol).value == null ? '' : row.getCell(remarkCol).value).trim();
if (remarkVal.length > 200 && !decorated.some(d => d.includes('备注不能超过200字'))) {
decorated.push(`${String.fromCharCode(65 + decorated.length)}.备注不能超过200字`);
}
}
reasonCell.value = `${rowNumber}行:${decorated.join('')}`;
// 仅操作 G 列:黑色字体、无背景填充,其余列保持后端原样
reasonCell.fill = { type: 'pattern', pattern: 'none' };
reasonCell.font = { color: { argb: 'FF000000' } };
reasonCell.alignment = { wrapText: true, vertical: 'top' };
});
// 加宽各列,避免内容拥挤(仅改列宽,不改动单元格样式)
Object.keys(colMap).forEach(name => {
const col = ws.getColumn(colMap[name]);
col.width = CARGO_TYPE_FAIL_COL_WIDTH[name] || 20;
});
};
export default {
components: { Search, Plus },
data() {
const validateCargoName = (rule, value, callback) => {
const cargoName = String(value || '').trim();
if (!cargoName) {
callback(new Error('请输入货物类型名称'));
} else if (cargoName.length > 50) {
callback(new Error('货物类型名称不能超过50字符'));
} else {
callback();
}
};
const validateCargoCode = (rule, value, callback) => {
const cargoCode = String(value || '').trim();
const pattern = this.form.typeLevel === 1 ? /^\d{2}$/ : /^\d{4}$/;
if (!pattern.test(cargoCode)) {
callback(new Error('货物类型编码格式不正确'));
} else if (
this.form.typeLevel === 2 &&
this.form.parentCargoCode &&
!cargoCode.startsWith(this.form.parentCargoCode)
) {
callback(new Error('二级编码前2位必须与上级货物类型编码一致'));
} else {
callback();
}
};
const validateRemark = (rule, value, callback) => {
if (String(value || '').length > 200) {
callback(new Error('备注不能超过200字'));
} else {
callback();
}
};
return {
form: {},
query: {},
@@ -202,6 +272,7 @@ export default {
excelForm: {},
parentOptions: [],
parentLoading: false,
dialogType: 'add',
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
@@ -209,137 +280,7 @@ export default {
total: 0,
},
selectionList: [],
option: {
height: 'auto',
calcHeight: 32,
dialogWidth: 760,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 8,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
viewBtn: false,
delBtn: false,
editBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 220,
column: [
{
label: '类型',
prop: 'typeLevel',
type: 'radio',
dataType: 'number',
dicData: typeLevelDic,
search: true,
value: 1,
span: 24,
minWidth: 120,
rules: [{ required: true, message: '请选择货物类型级别', trigger: 'change' }],
change: ({ value }) => this.handleTypeLevelChange(value),
},
{
label: '上级货物类型',
prop: 'parentId',
formslot: true,
hide: true,
display: false,
span: 24,
rules: [{ required: true, message: '请选择上级货物类型', trigger: 'change' }],
},
{
label: '上级货物类型',
prop: 'parentCargoName',
slot: true,
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
},
{
label: '上级货物类型编码',
prop: 'parentCargoCode',
slot: true,
readonly: true,
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
rules: [{ required: true, message: '请选择上级货物类型', trigger: 'change' }],
},
{
label: '货物类型',
prop: 'cargoName',
minWidth: 150,
maxlength: 50,
showWordLimit: true,
rules: [{ validator: validateCargoName, trigger: 'blur' }],
},
{
label: '货物类型编码',
prop: 'cargoCode',
minWidth: 140,
search: true,
maxlength: 4,
rules: [{ validator: validateCargoCode, trigger: 'blur' }],
change: ({ value }) => this.handleCargoCodeChange(value),
},
{
label: '创建人',
prop: 'createUserName',
slot: true,
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
minRows: 4,
span: 24,
minWidth: 180,
maxlength: 200,
showWordLimit: true,
overHidden: true,
rules: [{ validator: validateRemark, trigger: 'blur' }],
},
{
label: '更新时间',
prop: 'updateTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
},
{
label: '创建时间',
prop: 'createTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 160,
},
],
},
option: getCargoTypeOption(this),
excelOption: {
submitBtn: false,
emptyBtn: false,
@@ -506,7 +447,6 @@ export default {
done();
},
error => {
window.console.log(error);
loading();
}
);
@@ -524,7 +464,6 @@ export default {
done();
},
error => {
window.console.log(error);
loading();
}
);
@@ -565,6 +504,7 @@ export default {
this.onLoad(this.page, this.query);
},
beforeOpen(done, type) {
this.dialogType = type || 'add';
if (['edit', 'view'].includes(type)) {
getDetail(this.form.id).then(res => {
this.form = res.data.data || {};
@@ -575,7 +515,7 @@ export default {
return;
}
this.form = {
typeLevel: 1,
typeLevel: 2,
cargoName: '',
cargoCode: '',
parentId: undefined,
@@ -586,6 +526,17 @@ export default {
this.syncColumnDisplay();
done();
},
handleCompactSearch() {
this.page.currentPage = 1;
const params = {};
if (this.query.typeLevel != null) {
params.typeLevel = this.query.typeLevel;
}
if (this.query.keyword) {
params.cargoName = this.query.keyword;
}
this.onLoad(this.page, params);
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
@@ -627,7 +578,9 @@ export default {
});
},
handleImport() {
openImportDialog(this, '货物类型');
openImportDialog(this, '货物类型', undefined, {
failDetailDecorator: decorateCargoTypeFailDetail,
});
},
buildExportParams() {
const params = { ...this.query };
@@ -654,9 +607,19 @@ export default {
},
handleTemplate() {
NProgress.start();
exportBlob('/blade-system/cargo-type/export-template', {})
// 直接下载工程内置的标准导入模板public 目录静态资源),表头与可正常导入的模板一致,
// 含一级19 类)及全部二级示例,便于用户参照填写层级关系与编码规则
const templateUrl = `${import.meta.env.BASE_URL}cargo-type-import-template.xlsx`;
fetch(templateUrl)
.then(res => {
downloadXls(res.data, '货物类型导入模板.xlsx');
if (!res.ok) throw new Error('模板文件不存在');
return res.blob();
})
.then(blob => {
downloadXls(blob, '货物类型导入模板.xlsx');
})
.catch(() => {
this.$message.error('模板下载失败,请稍后重试');
})
.finally(() => {
NProgress.done();
@@ -677,15 +640,47 @@ export default {
box-shadow: none;
}
:deep(.avue-crud__search) {
padding: 12px 12px 4px;
margin-bottom: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
// 隐藏表格工具栏中的搜索图标按钮
:deep(.avue-crud__search-btn) {
display: none !important;
}
:deep(.avue-crud__search .el-form-item__label) {
min-width: 160px;
white-space: nowrap;
// 紧凑搜索栏
.compact-search {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin-bottom: 8px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
gap: 16px;
&__form {
display: flex;
align-items: center;
flex: 1;
gap: 12px;
min-width: 0;
}
&__select {
width: 160px;
flex-shrink: 0;
}
&__input {
width: 280px;
flex-shrink: 0;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
:deep(.el-table) {