Files
tms-erp-web/src/views/business/common-cargo.vue
T
b2894lxlx a528788409 🐛 修复总单地址回填、结算费用英文项与项目部门提示
常用地址级联回填不再误清空;预结算/正式结算过滤 freightJson 杂项;项目查看态业务部门/平台公司支持悬停完整信息。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 14:04:55 +08:00

815 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<basic-container class="common-cargo-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
ref="crud"
:permission="permissionList"
:before-open="beforeOpen"
@row-save="rowSave"
@row-update="rowUpdate"
@row-del="rowDel"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
icon="el-icon-plus"
v-if="hasPermission('common_cargo_add')"
@click="$refs.crud.rowAdd()"
>新建常用货物
</el-button>
<el-button
type="primary"
icon="el-icon-upload"
plain
v-if="config.importUrl && hasPermission('common_cargo_import')"
@click="handleImport"
>批量导入
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="config.templateUrl && hasPermission('common_cargo_template')"
@click="handleTemplate"
>下载模板
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="config.exportUrl && hasPermission('common_cargo_export')"
@click="handleExport"
>批量导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="hasPermission('common_cargo_delete')"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #firstCargoTypeName-form>
<el-select
v-model="form.firstCargoTypeCode"
clearable
filterable
remote
:remote-method="loadFirstCargoTypeOptions"
:loading="firstCargoTypeLoading"
:disabled="dialogReadonly || dialogType === 'edit'"
placeholder="请选择一级货物类型"
style="width: 100%"
@change="handleFirstCargoTypeChange"
>
<el-option
v-for="item in firstCargoTypeOptions"
:key="item.cargoCode"
:label="item.cargoName"
:value="item.cargoCode"
/>
</el-select>
</template>
<template #secondCargoTypeName-form>
<el-select
v-model="form.secondCargoTypeCode"
clearable
filterable
remote
:remote-method="loadSecondCargoTypeOptions"
:loading="secondCargoTypeLoading"
:disabled="dialogReadonly || dialogType === 'edit' || !form.firstCargoTypeCode"
:placeholder="form.firstCargoTypeCode ? '请选择二级货物类型' : '请先选择一级货物类型'"
style="width: 100%"
@change="handleSecondCargoTypeChange"
>
<el-option
v-for="item in secondCargoTypeOptions"
:key="item.cargoCode"
:label="item.cargoName"
:value="item.cargoCode"
/>
</el-select>
</template>
<template #cargoCode-form>
<el-input
:model-value="getCargoCodeSuffix()"
clearable
:maxlength="getCargoCodeMaxLength()"
show-word-limit
placeholder="请输入"
:disabled="dialogReadonly"
@input="handleCargoCodeInput"
>
<template v-if="getCargoCodePrefix()" #prepend>
{{ getCargoCodePrefix() }}
</template>
</el-input>
</template>
<template #cargoValue-form>
<div class="common-cargo-page__value-unit">
<el-input
v-model="form.cargoValue"
clearable
placeholder="请输入"
:disabled="dialogReadonly"
@input="handleCargoValueInput"
@blur="handleCargoValueBlur"
/>
<el-select
v-model="form.priceUnit"
clearable
:disabled="dialogReadonly"
:loading="priceUnitLoading"
placeholder="请选择"
>
<el-option
v-for="item in priceUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</template>
<template #cargoValue="{ row }">
{{ formatCargoValue(row.cargoValue) }}
</template>
<template #menu="{ row, index }">
<el-link
type="primary"
v-if="hasPermission('common_cargo_view')"
@click="$refs.crud.rowView(row, index)"
>
查看
</el-link>
<el-link
type="primary"
v-if="hasPermission('common_cargo_edit') && !row.readonly"
@click="$refs.crud.rowEdit(row, index)"
>
编辑
</el-link>
<el-link
type="danger"
v-if="hasPermission('common_cargo_delete') && !row.readonly"
@click="rowDel(row)"
>
删除
</el-link>
</template>
</avue-crud>
<empty-pagination
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
<el-dialog :title="`${config.title}导入`" append-to-body v-model="excelBox" width="555px">
<avue-form :option="excelOptionConfig" v-model="excelForm">
<template #excelTemplate>
<el-button type="primary" @click="handleTemplate">
点击下载<i class="el-icon-download el-icon--right"></i>
</el-button>
</template>
</avue-form>
</el-dialog>
</basic-container>
</template>
<script>
import * as api from '@/api/business/common-cargo';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { exportBlob } from '@/api/common';
import { getDeptTree } from '@/api/system/dept';
import { config, excelOption, formatCargoValue, option } from '@/option/business/common-cargo';
import { getDictionary } from '@/api/system/dictbiz';
import { getToken, isAdministrator } from '@/utils/auth';
import { openImportDialog } from '@/utils/import-excel';
import { downloadXls } from '@/utils/util';
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
export default {
data() {
return {
api,
config,
option,
priceUnitOptions: [],
priceUnitLoading: false,
form: {},
query: {},
loading: true,
data: [],
dialogReadonly: false,
dialogType: 'add',
excelBox: false,
excelForm: {},
excelOptionConfig: excelOption,
firstCargoTypeLoading: false,
secondCargoTypeLoading: false,
firstCargoTypeOptions: [],
secondCargoTypeOptions: [],
deptOptions: [],
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
total: 0,
},
selectionList: [],
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return {
addBtn: this.hasPermission('common_cargo_add'),
};
},
isAdmin() {
return isAdministrator(this.userInfo);
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
},
created() {
this.applyDefaultDeptSearch();
this.initDeptTree();
this.loadPriceUnitOptions();
},
methods: {
formatCargoValue,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission && this.permission[code], false);
},
getRecords(res) {
const data = res && res.data && res.data.data;
if (Array.isArray(data)) return data;
return (data && data.records) || [];
},
defaultDeptId() {
// 用户可能绑定多个组织(逗号分隔),搜索下拉是单选,只取当前会话第一个组织 ID。
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || '';
},
applyDefaultDeptSearch() {
// 超管默认不限组织,避免预填当前组织后看不到其他组织/无组织数据。
if (this.isAdmin) {
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
if (deptColumn) {
deptColumn.searchValue = undefined;
}
const nextQuery = { ...this.query };
delete nextQuery.deptId;
delete nextQuery.searchDeptId;
this.query = nextQuery;
this.$nextTick(() => {
if (this.$refs.crud?.searchForm) {
this.$refs.crud.searchForm.searchDeptId = undefined;
}
});
return;
}
const deptId = this.defaultDeptId();
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
if (!deptId || !deptColumn) return;
deptColumn.searchValue = deptId;
this.query = { ...this.query, deptId };
this.$nextTick(() => {
if (this.$refs.crud?.searchForm) {
this.$refs.crud.searchForm.searchDeptId = deptId;
}
});
},
initDeptTree() {
getDeptTree(this.userInfo.tenantId).then(res => {
this.deptOptions = this.flattenDept(res.data.data || []);
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
if (deptColumn) {
deptColumn.dicData = this.deptOptions;
}
// 超管不回填默认组织;普通用户在 dicData 到位后再回填,避免下拉清空已选组织。
if (this.isAdmin) return;
const deptId = String(this.query.deptId || this.defaultDeptId() || '');
if (!deptId) return;
if (deptColumn) {
deptColumn.searchValue = deptId;
}
this.query = { ...this.query, deptId };
this.$nextTick(() => {
if (this.$refs.crud?.searchForm) {
this.$refs.crud.searchForm.searchDeptId = deptId;
}
});
});
},
loadPriceUnitOptions() {
this.priceUnitLoading = true;
getDictionary({ code: 'unit_fee' })
.then(res => {
const payload = res?.data?.data || res?.data || [];
const list = Array.isArray(payload) ? payload : payload.records || payload.data || [];
this.priceUnitOptions = list
.map(item => ({
label: item.dictValue || item.label || item.name || item.dictKey || item.value,
value: item.dictKey || item.value || item.dictValue || item.name,
}))
.filter(item => item.label && item.value);
if (this.dialogType === 'add' && !this.form.priceUnit && this.priceUnitOptions.length) {
this.form.priceUnit = this.priceUnitOptions[0].value;
}
const priceUnitColumn = this.findColumn(this.option.column, 'priceUnit');
if (priceUnitColumn) priceUnitColumn.dicData = this.priceUnitOptions;
})
.finally(() => {
this.priceUnitLoading = false;
});
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return;
result.push({
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: id,
});
if (item.children && item.children.length) {
result.push(...this.flattenDept(item.children, level + 1));
}
});
return result;
},
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
normalizeCargoTypeOptions(list = []) {
return list
.map(item => ({
...item,
cargoCode: String(item.cargoCode || ''),
cargoName: item.cargoName || '',
}))
.filter(item => item.cargoCode && item.cargoName);
},
ensureCargoTypeOption(listName, optionItem) {
if (!optionItem || !optionItem.cargoCode) return;
const exists = this[listName].some(item => item.cargoCode === optionItem.cargoCode);
if (!exists) {
this[listName] = [optionItem, ...this[listName]];
}
},
loadFirstCargoTypeOptions(keyword = '') {
this.firstCargoTypeLoading = true;
getCargoTypeList(1, 30, {
typeLevel: 1,
cargoName: String(keyword || '').trim(),
})
.then(res => {
this.firstCargoTypeOptions = this.normalizeCargoTypeOptions(this.getRecords(res));
})
.finally(() => {
this.firstCargoTypeLoading = false;
});
},
loadSecondCargoTypeOptions(keyword = '') {
if (!this.form.firstCargoTypeCode) {
this.secondCargoTypeOptions = [];
return;
}
this.secondCargoTypeLoading = true;
getCargoTypeList(1, 30, {
typeLevel: 2,
parentCargoCode: this.form.firstCargoTypeCode,
cargoName: String(keyword || '').trim(),
})
.then(res => {
this.secondCargoTypeOptions = this.normalizeCargoTypeOptions(this.getRecords(res));
})
.finally(() => {
this.secondCargoTypeLoading = false;
});
},
handleFirstCargoTypeChange(value) {
const first = this.firstCargoTypeOptions.find(item => item.cargoCode === value);
this.form.firstCargoTypeName = first ? first.cargoName : '';
this.form.firstCargoTypeCode = first ? first.cargoCode : '';
this.form.secondCargoTypeName = '';
this.form.secondCargoTypeCode = '';
this.secondCargoTypeOptions = [];
this.syncCargoCodePrefix();
if (first) {
this.loadSecondCargoTypeOptions();
}
},
handleSecondCargoTypeChange(value) {
const second = this.secondCargoTypeOptions.find(item => item.cargoCode === value);
this.form.secondCargoTypeName = second ? second.cargoName : '';
this.form.secondCargoTypeCode = second ? second.cargoCode : '';
this.form.cargoCode = second ? String(second.cargoCode).slice(0, 4) : '';
this.syncCargoCodePrefix();
},
getCargoCodePrefix() {
return this.form.secondCargoTypeCode
? String(this.form.secondCargoTypeCode).slice(0, 4)
: '';
},
getCargoCodeSuffix() {
const prefix = this.getCargoCodePrefix();
const cargoCode = String(this.form.cargoCode || '');
return prefix && cargoCode.startsWith(prefix) ? cargoCode.slice(prefix.length) : cargoCode;
},
getCargoCodeMaxLength() {
return 20 - this.getCargoCodePrefix().length;
},
syncCargoCodePrefix() {
const prefix = this.getCargoCodePrefix();
const current = String(this.form.cargoCode || '').replace(/\D/g, '');
if (!prefix) {
this.form.cargoCode = current;
return;
}
const suffix = current.startsWith(prefix) ? current.slice(prefix.length) : current;
this.form.cargoCode = `${prefix}${suffix}`.slice(0, 20);
},
handleCargoCodeInput(value) {
const prefix = this.getCargoCodePrefix();
const suffix = String(value || '')
.replace(/\D/g, '')
.slice(0, this.getCargoCodeMaxLength());
this.form.cargoCode = `${prefix}${suffix}`;
},
handleCargoValueInput(value) {
const raw = String(value || '').replace(/[^\d.]/g, '');
const parts = raw.split('.');
const integer = parts[0] || '';
const decimal = parts.slice(1).join('').slice(0, 2);
this.form.cargoValue = parts.length > 1 ? `${integer}.${decimal}` : integer;
},
handleCargoValueBlur() {
this.form.cargoValue = formatCargoValue(this.form.cargoValue);
},
normalizeRow(row) {
const submitRow = { ...row };
Object.keys(submitRow).forEach(key => {
if (typeof submitRow[key] === 'string') {
submitRow[key] = submitRow[key].trim();
}
});
if (submitRow.cargoValue !== undefined && submitRow.cargoValue !== null) {
submitRow.cargoValue = String(submitRow.cargoValue).trim();
}
const cargoValue = submitRow.cargoValue;
if (
cargoValue === undefined ||
cargoValue === null ||
String(cargoValue).trim() === '' ||
String(cargoValue) === '-1'
) {
submitRow.cargoValue = null;
} else {
submitRow.cargoValue = formatCargoValue(submitRow.cargoValue);
}
return submitRow;
},
normalizeCargoValue(row) {
if (row && Object.prototype.hasOwnProperty.call(row, 'cargoValue')) {
const formatted = formatCargoValue(row.cargoValue);
row.cargoValue = formatted || null;
}
return row;
},
validateRow(row) {
if (!row.firstCargoTypeName || !row.firstCargoTypeCode) {
this.$message.warning('请选择一级货物类型');
return false;
}
if (!row.secondCargoTypeName || !row.secondCargoTypeCode) {
this.$message.warning('请选择二级货物类型');
return false;
}
if (!row.cargoName) {
this.$message.warning('请输入货物名称');
return false;
}
if (row.cargoName.length > 100) {
this.$message.warning('货物名称不能超过100个字符');
return false;
}
const cargoCode = String(row.cargoCode || '');
const prefix = String(row.secondCargoTypeCode || '').slice(0, 4);
if (!cargoCode || cargoCode === prefix) {
this.$message.warning('请输入货物编号');
return false;
}
if (!cargoCode.startsWith(prefix)) {
this.$message.warning('货物编号需拼接二级货物类型编号在前面');
return false;
}
if (cargoCode.length > 20) {
this.$message.warning('货物编号不能超过20个字符');
return false;
}
if (row.cargoValue && !/^\d+(\.\d{1,2})?$/.test(String(row.cargoValue))) {
this.$message.warning('单价只能输入数字,最多保留2位小数');
return false;
}
if (!row.model) {
this.$message.warning('请输入型号');
return false;
}
if (row.model.length > 50) {
this.$message.warning('型号不能超过50个字符');
return false;
}
if (row.remark && row.remark.length > 200) {
this.$message.warning('备注不能超过200个字符');
return false;
}
return true;
},
stopSubmitLoading(loading) {
if (typeof loading === 'function') {
loading();
}
},
rowSave(row, done, loading) {
const submitRow = this.normalizeRow(row);
if (!this.validateRow(submitRow)) {
this.stopSubmitLoading(loading);
return;
}
api.submit(submitRow).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
error => {
window.console.log(error);
this.stopSubmitLoading(loading);
}
);
},
rowUpdate(row, index, done, loading) {
const submitRow = this.normalizeRow(row);
if (!this.validateRow(submitRow)) {
this.stopSubmitLoading(loading);
return;
}
api.submit(submitRow).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
error => {
window.console.log(error);
this.stopSubmitLoading(loading);
}
);
},
rowDel(row) {
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => api.remove(row.id))
.then(res => {
this.afterRemove(res.data.data);
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
const selection = this.selectionList.filter(item => !item.readonly);
if (!selection.length) {
this.$message.warning('所选数据均为只读数据');
return;
}
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => api.remove(selection.map(item => item.id).join(',')))
.then(res => {
this.afterRemove(res.data.data);
});
},
afterRemove(result = {}) {
this.onLoad(this.page);
const successCount = result.successCount || 0;
const skippedCount = result.skippedCount || 0;
if (skippedCount) {
this.$message.warning(`成功删除${successCount}条,因业务引用跳过${skippedCount}条`);
} else {
this.$message({ type: 'success', message: '删除成功!' });
}
this.selectionClear();
},
beforeOpen(done, type) {
this.dialogReadonly = type === 'view';
this.dialogType = type || 'add';
if (type === 'add') {
this.form = {
...this.form,
priceUnit: this.priceUnitOptions[0]?.value || this.form.priceUnit || '',
};
this.loadFirstCargoTypeOptions();
done();
return;
}
if (['edit', 'view'].includes(type)) {
api.getDetail(this.form.id).then(res => {
this.form = this.normalizeCargoValue(res.data.data || {});
this.ensureCargoTypeOption('firstCargoTypeOptions', {
cargoName: this.form.firstCargoTypeName,
cargoCode: this.form.firstCargoTypeCode,
});
this.ensureCargoTypeOption('secondCargoTypeOptions', {
cargoName: this.form.secondCargoTypeName,
cargoCode: this.form.secondCargoTypeCode,
});
if (this.form.firstCargoTypeCode) {
this.loadSecondCargoTypeOptions();
}
done();
});
return;
}
done();
},
searchReset() {
this.query = {};
this.applyDefaultDeptSearch();
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
this.selectionList = list;
},
selectionClear() {
this.selectionList = [];
if (this.$refs.crud) {
this.$refs.crud.toggleSelection();
}
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data;
this.page.total = result.total;
this.data = (result.records || []).map(row => this.normalizeCargoValue(row));
this.selectionClear();
})
.finally(() => {
this.loading = false;
});
},
buildExportParams() {
return {
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
},
handleExport() {
this.$confirm(`是否导出${config.exportName}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
NProgress.start();
exportBlob(config.exportUrl, this.buildExportParams(), { feedback: true })
.then(res => {
downloadXls(
res.data,
`${config.exportName}${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
})
.finally(() => {
NProgress.done();
});
});
},
handleTemplate() {
NProgress.start();
exportBlob(
config.templateUrl,
{
[this.website.tokenHeader]: getToken(),
},
{ feedback: true }
)
.then(res => {
downloadXls(res.data, `${config.title}模板.xlsx`);
})
.finally(() => {
NProgress.done();
});
},
handleImport() {
const column = this.excelOptionConfig.column.find(item => item.prop === 'excelFile');
column.action = config.importUrl;
openImportDialog(this, config.title, () => this.onLoad(this.page, this.query));
},
},
};
</script>
<style lang="scss" scoped>
.common-cargo-page {
// 仅缩短本页面筛选栏中的输入框和下拉框,避免影响表单及其他页面。
:deep(.avue-crud__search .el-form-item__content > .el-input),
:deep(.avue-crud__search .el-form-item__content > .el-select) {
width: 70%;
}
&__value-unit {
display: flex;
align-items: center;
width: 100%;
.el-input {
flex: 1;
min-width: 0;
}
.el-select {
flex: 0 0 112px;
margin-left: -1px;
}
:deep(.el-input__wrapper) {
border-radius: 4px 0 0 4px;
}
.el-select :deep(.el-input__wrapper) {
border-radius: 0 4px 4px 0;
}
}
}
</style>