Merge branch 'refs/heads/feature/bfi-api-rate' into feature/hcs-api-p0-web

# Conflicts:
#	src/option/business/contract-manage.js
This commit is contained in:
刘泉佳
2026-10-15 04:13:05 +08:00
120 changed files with 10791 additions and 2542 deletions
+2 -1
View File
@@ -352,7 +352,8 @@ export default {
return result;
},
normalizeListQuery(params = {}) {
const query = { ...params, ...this.query };
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
+41 -17
View File
@@ -415,19 +415,27 @@ export default {
});
},
buildDeptTree(tree) {
return tree.map(item => ({
label: item.title || item.deptName || item.name,
value: item.id,
children:
item.children && item.children.length ? this.buildDeptTree(item.children) : undefined,
}));
return tree.map(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
return {
label: item.title || item.deptName || item.name,
value: id,
children:
item.children && item.children.length ? this.buildDeptTree(item.children) : undefined,
};
}).filter(item => item.value);
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
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: item.id,
value: id,
});
if (item.children && item.children.length) {
result.push(...this.flattenDept(item.children, level + 1));
@@ -436,7 +444,26 @@ export default {
return result;
},
defaultDeptId() {
return (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return (
String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || ''
);
},
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;
},
applyDefaultDeptSearch() {
const deptId = this.defaultDeptId();
@@ -999,9 +1026,9 @@ export default {
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -1024,12 +1051,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, {
...params,
...this.query,
allDept: this.isAdmin ? 1 : 0,
})
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data;
this.page.total = result.total;
@@ -1042,8 +1067,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: this.isAdmin ? 1 : 0,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
@@ -171,7 +171,7 @@
:label="item.label"
:value="item.value" /></el-select></template
></el-table-column>
<el-table-column label="单价(元)" width="180"
<el-table-column :label="unitPriceColumnLabel" width="180"
><template #default="{ row }"
><div v-if="readonly && usesRangeUnitPrice(row)" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
@@ -379,6 +379,7 @@ import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { InfoFilled } from '@element-plus/icons-vue';
import { getDictionary } from '@/api/system/dictbiz';
import SectionCard from '@/components/section-card/main.vue';
import { currencyUnit } from '@/utils/currency';
const clone = value => JSON.parse(JSON.stringify(value));
/** 计费要素与计量单位维度的对应关系(这些要素可选计费单位) */
@@ -386,12 +387,15 @@ const BILLING_ELEMENT_DIMENSION_MAP = {
按重量: '重量',
按体积: '体积',
按数量: '数量',
按里程: '里程',
'按吨·公里': '吨·公里',
'固定金额(整单一口价)': '整车',
};
const BILLING_UNIT_SELECTABLE_ELEMENTS = Object.keys(BILLING_ELEMENT_DIMENSION_MAP);
/** 固定计费单位(不可编辑) */
const FIXED_BILLING_UNIT_MAP = {
/** 对应维度下无计量单位时的默认计费单位 */
const DEFAULT_BILLING_UNIT_MAP = {
按里程: '公里',
'按吨·公里': '吨公里',
'按吨·公里': '吨·公里',
'固定金额(整单一口价)': '单',
};
const defaultRule = () => ({
@@ -428,6 +432,7 @@ export default {
value: { type: Object, default: () => ({}) },
index: { type: Number, default: -1 },
readonly: Boolean,
settlementCurrency: { type: String, default: 'RMB' },
},
emits: ['update:modelValue', 'save'],
data() {
@@ -497,6 +502,9 @@ export default {
emitPath: true,
};
},
unitPriceColumnLabel() {
return `单价(${currencyUnit(this.settlementCurrency)})`;
},
},
mounted() {
this.loadDictionaries();
@@ -652,58 +660,61 @@ export default {
}),
]).finally(() => {
this.unitLoading = false;
(this.draft.rules || []).forEach(row => this.syncBillingUnit(row));
});
},
measurementDimension(billingElement) {
return BILLING_ELEMENT_DIMENSION_MAP[billingElement] || '';
},
fixedBillingUnit(billingElement) {
return FIXED_BILLING_UNIT_MAP[String(billingElement || '').trim()] || '';
defaultBillingUnit(billingElement) {
return DEFAULT_BILLING_UNIT_MAP[String(billingElement || '').trim()] || '';
},
canSelectBillingUnit(row) {
return BILLING_UNIT_SELECTABLE_ELEMENTS.includes(String(row?.billingElement || '').trim());
},
billingUnitPlaceholder(row) {
if (!row?.billingElement) return '请先选择计费要素';
if (this.fixedBillingUnit(row.billingElement)) return this.fixedBillingUnit(row.billingElement);
if (!this.canSelectBillingUnit(row)) return '当前计费要素无需选择';
return '请选择';
},
unitOptionsFor(row) {
const fixedUnit = this.fixedBillingUnit(row?.billingElement);
if (fixedUnit) {
return [{ id: fixedUnit, label: fixedUnit, value: fixedUnit }];
}
if (!this.canSelectBillingUnit(row)) {
return [];
}
const dimension = this.measurementDimension(row?.billingElement);
if (dimension) {
return this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value);
const defaultUnit = this.defaultBillingUnit(row?.billingElement);
const options = dimension
? this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value)
: [];
// 对应维度未配置计量单位时,下拉仅展示默认值
if (!options.length && defaultUnit) {
return [{ id: `default-${defaultUnit}`, label: defaultUnit, value: defaultUnit }];
}
return [];
return options;
},
syncBillingUnit(row) {
const fixedUnit = this.fixedBillingUnit(row?.billingElement);
if (fixedUnit) {
row.billingUnit = fixedUnit;
return;
}
if (!this.canSelectBillingUnit(row)) {
row.billingUnit = '';
return;
}
const options = this.unitOptionsFor(row);
if (!options.some(item => String(item.value) === String(row.billingUnit || ''))) {
row.billingUnit = '';
const defaultUnit = this.defaultBillingUnit(row?.billingElement);
const current = String(row.billingUnit || '').trim();
if (options.some(item => String(item.value) === current)) {
return;
}
if (defaultUnit && options.some(item => String(item.value) === defaultUnit)) {
row.billingUnit = defaultUnit;
return;
}
row.billingUnit = options[0]?.value || defaultUnit || '';
},
feeTypeKey(row) {
const option = this.feeCategories.find(
@@ -215,7 +215,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -2065,11 +2065,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -2338,14 +2349,16 @@
<div class="business-crud-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="business-crud-page__transport-plan-detail-attachments">
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -2531,14 +2544,16 @@
<div class="business-crud-page__waybill-attachments">
<span class="business-crud-page__waybill-label">附件</span>
<div>
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ file.originalName || file.name || '-' }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.originalName || file.name || '-' }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -2820,11 +2835,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<el-table-column
label="文件名"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -2966,10 +2991,50 @@
<el-table :data="waybillRouteChangeRecords" border>
<el-table-column prop="changeTime" label="变更时间" min-width="170" />
<el-table-column prop="changeUser" label="变更人" min-width="120" />
<el-table-column prop="content" label="变更内容" min-width="360">
<el-table-column
prop="content"
label="变更内容"
min-width="360"
:show-overflow-tooltip="false"
>
<template #default="{ row }">
<div class="business-crud-page__route-change-record-content">
{{ row.content || '-' }}
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="business-crud-page__route-change-group"
>
<div v-if="group.title" class="business-crud-page__route-change-group-title">
{{ group.title }}
</div>
<div v-if="group.text" class="business-crud-page__route-change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="business-crud-page__route-change-field"
>
<span class="business-crud-page__route-change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="business-crud-page__route-change-before">
{{ field.before }}
</span>
<span class="business-crud-page__route-change-arrow">→</span>
<span class="business-crud-page__route-change-after">
{{ field.after }}
</span>
</template>
<span v-else class="business-crud-page__route-change-single">
{{ field.single }}
<span v-if="field.unchanged" class="business-crud-page__route-change-flag">
未变更
</span>
</span>
</div>
</div>
</div>
</template>
</el-table-column>
@@ -3056,7 +3121,7 @@
<el-option
v-for="item in transportPlanImportContractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -4225,11 +4290,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.enter="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.space.prevent="previewAttachment(row, dispatchItemAttachmentRows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -5242,8 +5318,11 @@ import { addressTypeOptions } from '@/option/base/common-address';
import { packageOptions } from '@/option/business/common';
import { formatUpdateUserName } from '@/utils/audit';
import { getToken, isAdministrator } from '@/utils/auth';
import { formatContractOptionLabel } from '@/utils/contract';
import { openImportDialog } from '@/utils/import-excel';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { parseRouteChangeContent } from '@/utils/route-change';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
@@ -5822,7 +5901,7 @@ export default {
billCycleTypeOptions: ['固定截单日', '自然月', '自定义多周期'],
reconciliationModeOptions: ['明细逐行核对', '按账单汇总核对', '跳过自动核对'],
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
quantityUnitOptions: [],
attachmentFileTypes: [
'pdf',
'bmp',
@@ -5865,7 +5944,6 @@ export default {
this.ensureBillingUnitOptions();
}
if (this.config.enableTaskInfoForm) {
this.loadTaskQuantityUnitOptions();
this.loadTaskFeeUnitOptions();
this.ensureBillingCargoTypeOptions();
}
@@ -5886,6 +5964,7 @@ export default {
} else {
this.consumeTemplateCreatePayload();
}
this.loadTaskQuantityUnitOptions();
},
computed: {
...mapGetters(['permission', 'userInfo']),
@@ -6589,6 +6668,8 @@ export default {
},
},
methods: {
formatContractOptionLabel,
parseRouteChangeContent,
initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
@@ -7789,8 +7870,6 @@ export default {
['contractName', '客户合同'],
['planName', '计划名称'],
['transportType', '运输方式'],
['planStartDate', '计划开始日期'],
['planEndDate', '计划结束日期'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
];
@@ -9270,9 +9349,10 @@ export default {
return Promise.resolve(this.taskQuantityUnitOptions);
}
this.taskQuantityUnitLoading = true;
return getDictionary({ code: 'quantity_unit' })
.then(res => {
this.taskQuantityUnitOptions = this.normalizeDictOptions(res.data.data || []);
return loadMeasurementUnitOptions()
.then(options => {
this.taskQuantityUnitOptions = options;
this.quantityUnitOptions = options.map(item => item.value);
return this.taskQuantityUnitOptions;
})
.finally(() => {
@@ -14111,7 +14191,7 @@ export default {
&__dispatch-shipping-form {
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
}
@@ -14634,7 +14714,7 @@ export default {
width: 100%;
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.el-select),
@@ -15859,7 +15939,65 @@ export default {
}
&__route-change-record-content {
white-space: pre-wrap;
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
&__route-change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
&__route-change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
&__route-change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
&__route-change-field-label {
color: #909399;
white-space: nowrap;
}
&__route-change-before,
&__route-change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-arrow {
color: #c0c4cc;
text-align: right;
}
&__route-change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
@media (max-width: 1200px) {
@@ -16251,7 +16389,7 @@ export default {
}
:global(.business-crud-page__mileage-dialog .business-crud-page__mileage-form .el-form-item) {
margin-bottom: 16px;
margin-bottom: 24px;
}
:global(.business-crud-page__mileage-dialog .business-crud-page__mileage-form .el-input),
@@ -16,7 +16,7 @@
class="contract-manage-form__attachment-table"
@selection-change="selected = $event"
>
<el-table-column v-if="!readonly" type="selection" width="55" align="center" />
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column v-if="attachmentType" label="附件类型" min-width="160" align="center">
<template #default="{ row, $index }">
@@ -45,11 +45,17 @@
align="left"
header-align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="preview?.(row, rows)">{{
attachmentName(row)
}}</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="preview?.(row, rows)"
@keydown.enter="preview?.(row, rows)"
@keydown.space.prevent="preview?.(row, rows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column v-if="description" label="附件描述" min-width="220">
@@ -173,15 +179,43 @@ import { downloadFileByUrl } from '@/utils/util';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
const CONTRACT_ATTACHMENT_TYPE_OTHER = '其他文件';
/** 用印材料历史取值兼容别名 */
export const CONTRACT_SEAL_MATERIAL_LOCATION = '用印材料';
export const CONTRACT_SEAL_MATERIAL_TYPE_ALIASES = {
合同文件: CONTRACT_SEAL_MATERIAL_LOCATION,
};
/** 佐证材料附件位置(历史:其它附件) */
export const CONTRACT_SUPPORTING_ATTACHMENT_LOCATION = '附件(佐证材料等)';
export const CONTRACT_ATTACHMENT_TYPE_DICT = 'contract_attachment_type';
export const CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT = 'contract_supporting_attachment_type';
export const isContractSupportingAttachmentLocation = location => {
const text = String(location || '');
return (
text === CONTRACT_SUPPORTING_ATTACHMENT_LOCATION ||
text === '其它附件' ||
text === '其他附件'
);
};
export const resolveAttachmentTypeDictCode = (location, fallback = CONTRACT_ATTACHMENT_TYPE_DICT) =>
isContractSupportingAttachmentLocation(location)
? CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
: fallback || CONTRACT_ATTACHMENT_TYPE_DICT;
export const defaultContractAttachmentTypeOptions = [
{ label: '合同文件', value: '合同文件' },
{ label: '用印材料', value: '用印材料' },
{ label: '双章归档文件', value: '双章归档文件' },
// 高风险/黑名单客商合同必传,后端按 fileType=特批说明 校验与转传特批(与后端 HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION 对齐)
{ label: '特批说明', value: '特批说明' },
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典 contract_attachment_type 动态加载 */
export const defaultContractSupportingAttachmentTypeOptions = [
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典动态加载 */
export const contractAttachmentTypeOptions = defaultContractAttachmentTypeOptions;
export const isContractSealMaterialLocation = location => {
const text = String(location || '');
return text === CONTRACT_SEAL_MATERIAL_LOCATION || text === '合同文件';
};
export const attachmentFileTypes = [
'pdf',
'bmp',
@@ -236,8 +270,11 @@ const isOtherAttachmentTypeOption = item => {
const attachmentTypeKeywords = fileType => {
const text = String(fileType || '');
if (text.includes('双章')) return ['双章归档文件', '双章归档', '双章'];
if (text.includes('合同') && !text.includes('其他') && !text.includes('其它')) {
return ['合同文件', '合同'];
if (
text.includes('用印') ||
(text.includes('合同') && !text.includes('其他') && !text.includes('其它'))
) {
return ['用印材料', '合同文件', '用印', '合同'];
}
if (isOtherAttachmentTypeOption({ label: text, value: text })) return [];
return text ? [text] : [];
@@ -256,8 +293,13 @@ export const resolveContractAttachmentType = (
const list = options.length ? options : defaultContractAttachmentTypeOptions;
const otherType = resolveOtherAttachmentType(list);
const values = list.map(item => item.value);
if (values.includes(row.fileType)) return row.fileType;
const matchedByLabel = list.find(item => String(item.label) === String(row.fileType));
const rawFileType = String(row.fileType || '');
const aliasedFileType = CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawFileType] || rawFileType;
if (values.includes(aliasedFileType)) return aliasedFileType;
if (values.includes(rawFileType)) return rawFileType;
const matchedByLabel = list.find(
item => String(item.label) === aliasedFileType || String(item.label) === rawFileType
);
if (matchedByLabel) return matchedByLabel.value;
const fileName = normalizeAttachmentTypeText(attachmentName(row));
if (!fileName) return otherType;
@@ -285,17 +327,32 @@ export const normalizeContractFileRows = (rows, options = defaultContractAttachm
description: row.description || '',
}));
const mapDictOptions = response => {
const mapDictOptions = (response, dictCode = CONTRACT_ATTACHMENT_TYPE_DICT) => {
const data = response?.data?.data || response?.data || [];
const records = Array.isArray(data) ? data : data.records || [];
const applySealAlias = dictCode === CONTRACT_ATTACHMENT_TYPE_DICT;
return records
.map(item => ({
label: item.dictValue,
value: item.dictKey,
}))
.map(item => {
const rawLabel = String(item.dictValue || '');
const rawValue = String(item.dictKey || '');
const label = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawLabel] ||
(rawLabel === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawLabel)
: rawLabel;
const value = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawValue] ||
(rawValue === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawValue)
: rawValue;
return { label, value };
})
.filter(item => item.label && item.value);
};
const defaultOptionsByDictCode = dictCode =>
dictCode === CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
? [...defaultContractSupportingAttachmentTypeOptions]
: [...defaultContractAttachmentTypeOptions];
export default {
name: 'ContractAttachmentSection',
components: { UploadFilled },
@@ -309,8 +366,13 @@ export default {
markApprovedOnUpload: Boolean,
useUploadDialog: Boolean,
dialogOnly: Boolean,
attachmentLocation: { type: String, default: '合同文件' },
attachmentLocation: { type: String, default: CONTRACT_SEAL_MATERIAL_LOCATION },
attachmentLocationOptions: { type: Array, default: null },
/** 附件类型业务字典 code,默认用印材料字典;佐证材料传 contract_supporting_attachment_type */
attachmentTypeDictCode: {
type: String,
default: CONTRACT_ATTACHMENT_TYPE_DICT,
},
preview: { type: Function, default: null },
},
emits: ['update:rows', 'upload-to-location', 'upload-confirmed'],
@@ -319,8 +381,9 @@ export default {
selected: [],
attachmentFileTypes,
contractAttachmentTypeOptions: [...defaultContractAttachmentTypeOptions],
activeAttachmentTypeDictCode: CONTRACT_ATTACHMENT_TYPE_DICT,
uploadDialogVisible: false,
uploadDialogLocation: '合同文件',
uploadDialogLocation: CONTRACT_SEAL_MATERIAL_LOCATION,
uploadDialogType: resolveOtherAttachmentType(defaultContractAttachmentTypeOptions),
uploadDialogFiles: [],
uploadDialogFileList: [],
@@ -339,48 +402,91 @@ export default {
defaultAttachmentType() {
return resolveOtherAttachmentType(this.contractAttachmentTypeOptions);
},
resolvedAttachmentTypeDictCode() {
return this.attachmentTypeDictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
},
resolvedLocationOptions() {
const options = (this.attachmentLocationOptions || []).filter(Boolean);
if (options.length) return options;
return ['合同文件', '其它附件'];
if (options.length) {
return options.map(item => {
if (item === '合同文件') return CONTRACT_SEAL_MATERIAL_LOCATION;
if (item === '其它附件' || item === '其他附件') {
return CONTRACT_SUPPORTING_ATTACHMENT_LOCATION;
}
return item;
});
}
return [CONTRACT_SEAL_MATERIAL_LOCATION, CONTRACT_SUPPORTING_ATTACHMENT_LOCATION];
},
},
watch: {
attachmentTypeDictCode: {
immediate: false,
handler() {
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
},
},
created() {
this.loadAttachmentTypeOptions();
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
methods: {
attachmentName,
loadAttachmentTypeOptions() {
return getBizDictionary({ code: 'contract_attachment_type' })
currentUploadUserName() {
const raw = this.$store.getters.userInfo;
const userInfo = raw && !Array.isArray(raw) ? raw : {};
// 与顶部栏一致优先 real_name;用印材料 / 佐证材料共用
return (
userInfo.real_name ||
userInfo.realName ||
userInfo.nick_name ||
userInfo.nickName ||
userInfo.user_name ||
userInfo.userName ||
userInfo.account ||
''
);
},
loadAttachmentTypeOptions(dictCode = this.resolvedAttachmentTypeDictCode, alignRows = false) {
const code = dictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
this.activeAttachmentTypeDictCode = code;
const fallbackOptions = defaultOptionsByDictCode(code);
return getBizDictionary({ code })
.then(res => {
const options = mapDictOptions(res);
// 切换字典期间若已发起更新请求,忽略过期响应
if (this.activeAttachmentTypeDictCode !== code) return;
const options = mapDictOptions(res, code);
if (options.length) {
// 字典缺少「其他文件」时补上,保证未匹配文件名可默认选中
this.contractAttachmentTypeOptions = options.some(isOtherAttachmentTypeOption)
? options
: [...options, { label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER }];
if (
!this.contractAttachmentTypeOptions.some(
item => String(item.value) === String(this.uploadDialogType)
)
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典加载后,把已有行的中文类型名对齐到 dictKey,保证下拉可选中
if (this.attachmentType && (this.rows || []).length) {
const aligned = (this.rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, this.contractAttachmentTypeOptions),
}));
const changed = aligned.some(
(row, index) => row.fileType !== this.rows[index]?.fileType
);
if (changed) this.update(aligned);
}
} else {
this.contractAttachmentTypeOptions = fallbackOptions;
}
if (
!this.contractAttachmentTypeOptions.some(
item => String(item.value) === String(this.uploadDialogType)
)
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典加载后,把已有行的中文类型名对齐到 dictKey,保证下拉可选中
if (alignRows && this.attachmentType && (this.rows || []).length) {
const aligned = (this.rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, this.contractAttachmentTypeOptions),
}));
const changed = aligned.some(
(row, index) => row.fileType !== this.rows[index]?.fileType
);
if (changed) this.update(aligned);
}
})
.catch(() => {
// 字典加载失败时保留默认选项
if (this.activeAttachmentTypeDictCode !== code) return;
this.contractAttachmentTypeOptions = fallbackOptions;
this.uploadDialogType = this.defaultAttachmentType;
});
},
isRowLocked(row) {
@@ -390,13 +496,14 @@ export default {
this.$emit('update:rows', Array.isArray(rows) ? rows.map(item => ({ ...item })) : []);
},
enrichUploadedRow(row, existing) {
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const next = {
...existing,
...row,
description: row.description || existing?.description || '',
uploadUserName: existing?.uploadUserName || row.uploadUserName || uploadUserName,
// 新上传行始终写入当前用户;已有行保留原上传人
uploadUserName: existing?.uploadUserName || uploadUserName || row.uploadUserName || '',
uploadTime: existing?.uploadTime || row.uploadTime || uploadTime,
approved: existing?.approved || row.approved || false,
};
@@ -436,20 +543,38 @@ export default {
resolveTypeByLocation(location) {
const options = this.contractAttachmentTypeOptions || [];
const locationText = String(location || '');
if (locationText.includes('其它') || locationText.includes('其他')) {
if (
isContractSupportingAttachmentLocation(locationText) ||
locationText.includes('佐证') ||
locationText.includes('其它') ||
locationText.includes('其他')
) {
return this.defaultAttachmentType;
}
const matched = options.find(
item =>
String(item.value) === locationText ||
String(item.label) === locationText ||
String(item.value).includes('用印') ||
String(item.label).includes('用印') ||
String(item.value).includes('合同') ||
String(item.label).includes('合同')
);
return matched?.value || options[0]?.value || this.defaultAttachmentType;
},
syncUploadDialogTypeByLocation() {
this.uploadDialogType = this.resolveTypeByLocation(this.uploadDialogLocation);
const dictCode = resolveAttachmentTypeDictCode(
this.uploadDialogLocation,
this.resolvedAttachmentTypeDictCode
);
const applyType = () => {
this.uploadDialogType = this.resolveTypeByLocation(this.uploadDialogLocation);
};
if (dictCode !== this.activeAttachmentTypeDictCode) {
this.loadAttachmentTypeOptions(dictCode, false).finally(applyType);
return;
}
applyType();
},
openUploadDialog() {
const options = this.resolvedLocationOptions;
@@ -546,13 +671,13 @@ export default {
this.$message.warning('请选择附件类型');
return;
}
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const appended = this.uploadDialogFiles.map(row => ({
...row,
fileType: this.uploadDialogType,
description: row.description || '',
uploadUserName: row.uploadUserName || uploadUserName,
uploadUserName: uploadUserName || row.uploadUserName || '',
uploadTime: row.uploadTime || uploadTime,
...(this.markApprovedOnUpload ? { approved: true } : {}),
}));
@@ -609,7 +734,15 @@ export default {
downloadFileByUrl(url, attachmentName(row));
},
batchDownload() {
(this.selected.length ? this.selected : this.rows || []).forEach(this.download);
const sourceRows = this.selected.length ? this.selected : this.rows || [];
const downloadableRows = sourceRows.filter(row => attachmentUrl(row));
if (!downloadableRows.length) {
this.$message.warning(this.selected.length ? '所选附件暂无可下载地址' : '暂无可下载附件');
return;
}
downloadableRows.forEach((row, index) => {
window.setTimeout(() => this.download(row), index * 400);
});
},
formatSize(size) {
const value = Number(size);
@@ -44,19 +44,22 @@
<dt>附件</dt>
<dd>
<template v-if="attachments.length">
<el-link
<span
v-for="file in attachments"
:key="file.url || file.link || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ attachmentName(file) }}</el-link
>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
</template>
<span v-else>-</span>
</dd>
</div>
</dl>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
</section>
<el-dialog v-model="documentPreviewVisible" :title="previewFile.name || '附件预览'" append-to-body destroy-on-close width="90%" top="4vh">
@@ -107,6 +110,7 @@ import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archiv
import { CaretBottom, CaretTop } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { formatRegionAddress } from '@/utils/region-address';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
@@ -161,6 +165,7 @@ export default {
} finally { this.loading = false; }
},
methods: {
formatRegionAddress,
quantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
segmentNumber(route, index) { return String(route.segmentNo || '').replace(/^段/, '') || index + 1; },
isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[segmentNo]); },
@@ -330,7 +335,7 @@ export default {
.transport-flow-tag {
margin-left: 8px;
}
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } :deep(.el-link) { font-size: 14px; } }
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } .attachment-file-name { font-size: 14px; max-width: 100%; } }
.route-map { display: flex; align-items: flex-start; padding: 16px 24px 20px; border-top: 1px solid #eff1f7; overflow-x: auto; }
.route-map__node { display: grid; flex: 0 0 150px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } }
.route-map__progress { color: #303133 !important; }
@@ -21,10 +21,10 @@
<div class="route-overview__node">
<span :class="['route-badge', index === 0 ? 'start' : 'middle']">{{ index === 0 ? '起' : '经' }}</span>
<el-tooltip :content="route.departureName || '-'" placement="top" :show-after="120">
<strong>{{ route.departureName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'departure') }}</strong>
</el-tooltip>
<el-tooltip :content="route.departureAddress || '-'" placement="top" :show-after="120">
<small>{{ route.departureAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.departureContact, route.departurePhone)" placement="top" :show-after="120">
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
@@ -34,10 +34,10 @@
<div v-if="index === routes.length - 1" class="route-overview__node">
<span class="route-badge end">终</span>
<el-tooltip :content="route.arrivalName || '-'" placement="top" :show-after="120">
<strong>{{ route.arrivalName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'arrival') }}</strong>
</el-tooltip>
<el-tooltip :content="route.arrivalAddress || '-'" placement="top" :show-after="120">
<small>{{ route.arrivalAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.arrivalContact, route.arrivalPhone)" placement="top" :show-after="120">
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
@@ -266,7 +266,7 @@
<el-col :span="6">
<el-form-item label="单价">
<el-input :model-value="freightGroupUnitPrice(group)" inputmode="decimal" placeholder="请输入" @input="value => handleFreightGroupUnitPriceInput(group, value)" @clear="() => handleFreightGroupUnitPriceInput(group, '')">
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option label="元/吨" value="元/吨" /><el-option label="元/件" value="元/件" /><el-option label="元/方" value="元/方" /></el-select></template>
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option v-for="item in priceUnitOptions(route)" :key="item.value" :label="item.label" :value="item.value" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
@@ -317,6 +317,14 @@
<script>
import * as api from '@/api/business/master-order';
import { fetchDriversByCarrierOrganizations } from '@/utils/carrier-org-resource';
import { formatContractOptionLabel } from '@/utils/contract';
import {
currencyUnit,
remapPriceUnitCurrency,
resolvePriceUnitByQuantityUnit,
} from '@/utils/currency';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { formatRegionAddress } from '@/utils/region-address';
import { isMobile } from '@/utils/validate';
const unwrapRecords = res => {
@@ -347,7 +355,8 @@ export default {
carrierLoading: false,
driverLoading: false,
escortLoading: false,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
quantityUnitOptions: [],
quantityUnitLoading: false,
};
},
computed: {
@@ -389,10 +398,24 @@ export default {
},
},
async mounted() {
await this.loadQuantityUnitOptions();
await Promise.all([this.load(), this.loadCarrierOptions()]);
this.applySelfOperatedCarrierDefaults();
},
methods: {
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
async load() {
if (!this.id) return;
this.loading = true;
@@ -412,8 +435,10 @@ export default {
},
async loadContractCurrency() {
const directCurrency = this.master?.settlementCurrency || this.master?.settlementCurrencyCode || this.master?.currency;
const previousCurrency = this.master?.settlementCurrency;
if (!this.master?.projectId || !this.master?.contractId) {
this.master.settlementCurrency = this.normalizeCurrencyValue(directCurrency || 'RMB');
this.syncRouteCurrency(previousCurrency, this.master.settlementCurrency);
return;
}
try {
@@ -427,6 +452,20 @@ export default {
this.master.customerContractPartyB = '';
this.master.settlementCurrency = 'RMB';
}
this.syncRouteCurrency(previousCurrency, this.master.settlementCurrency);
},
syncRouteCurrency(_previousCurrency, nextCurrency) {
const currency = this.normalizeCurrencyValue(nextCurrency || 'RMB');
(this.routes || []).forEach(route => {
route.currency = currency;
(route.freightItems || []).forEach(item => {
if (item.priceUnit) {
item.priceUnit = remapPriceUnitCurrency(item.priceUnit, currency);
} else if (item.quantityUnit) {
item.priceUnit = resolvePriceUnitByQuantityUnit(item.quantityUnit, currency);
}
});
});
},
dispatchRouteNodes() {
const routes = Array.isArray(this.master?.routes) ? this.master.routes : [];
@@ -601,8 +640,23 @@ export default {
const prices = (group.rows || []).map(item => String(item.unitPrice ?? '').trim()).filter(Boolean);
return !prices.length || prices.some(price => price !== prices[0]) ? '' : prices[0];
},
freightGroupPriceUnit(group = {}) { return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : ''); },
freightGroupPriceUnit(group = {}) {
if (group.rows?.[0]?.priceUnit) return group.rows[0].priceUnit;
if (!group.quantityUnit) return '';
return (
resolvePriceUnitByQuantityUnit(group.quantityUnit, this.master?.settlementCurrency || 'RMB') ||
`${currencyUnit(this.master?.settlementCurrency || 'RMB')}/${group.quantityUnit}`
);
},
freightGroupAmount(group = {}) { return (group.rows || []).reduce((sum, item) => sum + this.freightAmount(item), 0); },
priceUnitOptions(route = {}) {
const unit = currencyUnit(route.currency || this.master?.settlementCurrency || 'RMB');
return [
{ label: `${unit}/吨`, value: `${unit}/吨` },
{ label: `${unit}/件`, value: `${unit}/件` },
{ label: `${unit}/方`, value: `${unit}/方` },
];
},
handleFreightGroupUnitPriceInput(group, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
const normalized = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
@@ -612,6 +666,10 @@ export default {
handleFreightGroupQuantityUnitChange(route, group, value) {
(group.rows || []).forEach(item => {
item.quantityUnit = value || '';
item.priceUnit = resolvePriceUnitByQuantityUnit(
value,
route.currency || this.master?.settlementCurrency || 'RMB'
) || item.priceUnit;
const goods = (route.goods || []).find(row => String(row.sourceIndex) === String(item.sourceIndex));
if (goods) goods.quantityUnit = value || '';
});
@@ -634,7 +692,7 @@ export default {
})),
});
},
currencyLabel(value) { return ({ CNY: '元', RMB: '元', USD: '美元', EUR: '欧元' })[value] || value || '元'; },
currencyLabel(value) { return currencyUnit(value); },
quantityNumber(value) { return Number(value || 0); },
goodsKey(goods = {}, includeUnit = true) {
return [
@@ -794,7 +852,12 @@ export default {
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
},
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
formatRegionAddress,
addressText(name, address) {
// 名称与详细地址都要按「市 区县」展示;两者格式化后相同时只展示一次
const values = [this.formatRegionAddress(name), this.formatRegionAddress(address)].filter(Boolean);
return [...new Set(values)].join(' / ') || '-';
},
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
dateStartLabel(route) { return route.documentType === '计划单' ? '计划开始日期' : '预计发货日期'; },
dateEndLabel(route) { return route.documentType === '计划单' ? '计划结束日期' : '预计完成日期'; },
@@ -824,6 +887,7 @@ export default {
removeGoods(route, index) { route.goods.splice(index, 1); this.syncFreightItems(route); },
syncFreightItems(route) {
const oldItems = route.freightItems || [];
const currency = route.currency || this.master?.settlementCurrency || 'RMB';
route.freightItems = (route.goods || [])
.filter(item => item.sourceIndex !== undefined && item.sourceIndex !== '')
.map(item => {
@@ -835,7 +899,10 @@ export default {
quantity: item.dispatchQuantity || '',
quantityUnit: item.quantityUnit || '',
unitPrice: old.unitPrice || '',
priceUnit: old.priceUnit || `元/${item.quantityUnit || '吨'}`,
priceUnit:
old.priceUnit ||
resolvePriceUnitByQuantityUnit(item.quantityUnit || '吨', currency) ||
`${currencyUnit(currency)}/${item.quantityUnit || '吨'}`,
};
});
},
@@ -888,9 +955,11 @@ export default {
},
carrierOptionLabel(item) {
const duplicateCount = this.carrierOptions.filter(option => option.carrierName === item.carrierName).length;
return duplicateCount > 1
? `${item.carrierName}(${item.contractName || item.contractId})`
: item.carrierName;
if (duplicateCount <= 1) {
return item.carrierName;
}
const contractLabel = formatContractOptionLabel(item);
return contractLabel ? `${item.carrierName}(${contractLabel})` : item.carrierName;
},
driverOptionLabel(item) { return item.driverName || item.name || ''; },
driverOptionKey(item) { return item.id || item.driverId || this.driverOptionLabel(item) || item.mobile; },
@@ -1197,7 +1266,7 @@ export default {
.route-overview__line { flex: 0 1 56px; min-width: 24px; height: 32px; margin-top: 0; border-bottom: 6px solid #e4e7ed; }
.segment-header { min-height: 46px; padding: 0 20px; border-bottom: 1px solid #eff1f7; font-weight: 600; em { color: #409eff; font-style: normal; } }
.segment-content { padding: 16px 24px; }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 14px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 24px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
/* 短标签按自身宽度,避免被「预计发货日期」等长标签把左侧撑开 */
.dispatch-form__compact-label {
:deep(.el-form-item__label) {
@@ -27,7 +27,7 @@
><el-option
v-for="contract in contracts"
:key="contract.id"
:label="contract.contractName"
:label="formatContractOptionLabel(contract)"
:value="contract.id" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="总单号" required
@@ -261,11 +261,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="240" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -589,6 +600,7 @@ import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import { exportBlob, importBlob } from '@/api/common';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { OfficeBuilding, Search } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
@@ -597,10 +609,10 @@ import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { mapGetters } from 'vuex';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
const quantityUnitOptions = ['吨', '件', '方'];
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
@@ -703,10 +715,11 @@ export default {
'zip',
],
transports: ['公路运输', '铁路运输', '水路运输', '航空运输'],
quantityUnitOptions,
quantityUnitOptions: [],
quantityUnitLoading: false,
form: {
routes: [{ segmentNo: '段1', transportType: '' }],
goods: [{ quantityUnit: quantityUnitOptions[0] }],
goods: [{ quantityUnit: '吨' }],
attachmentsJson: '[]',
transportOrganizationType: '多式联运',
},
@@ -773,6 +786,7 @@ export default {
async mounted() {
this.loading = true;
try {
await this.loadQuantityUnitOptions();
await Promise.all([this.loadProjects(), this.loadCargoTypeOptions(), this.loadRegionOptions()]);
if (this.id) {
await this.loadDetail(this.id);
@@ -782,6 +796,23 @@ export default {
}
},
methods: {
formatContractOptionLabel,
defaultQuantityUnit() {
return this.quantityUnitOptions[0] || '吨';
},
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
responseData(response) {
return response?.data?.data || response?.data || response || {};
},
@@ -958,7 +989,7 @@ export default {
finalTransportType: hasFinalSegment
? finalSegment.transportType
: detail.finalTransportType || '',
goods: detail.goods || [{ quantityUnit: quantityUnitOptions[0] }],
goods: detail.goods || [{ quantityUnit: this.defaultQuantityUnit() }],
attachmentsJson: detail.attachmentsJson || '[]',
transportOrganizationType: '多式联运',
};
@@ -1659,7 +1690,7 @@ export default {
'',
cargoTypePath,
quantity: row.quantity || '',
quantityUnit: row.quantityUnit || quantityUnitOptions[0],
quantityUnit: row.quantityUnit || this.defaultQuantityUnit(),
packageType: row.packageType || row.package || '',
brand: row.brand || '',
specification: row.specification || row.spec || '',
@@ -1785,7 +1816,7 @@ export default {
},
addGoods(index) {
this.form.goods.splice(index + 1, 0, {
quantityUnit: quantityUnitOptions[0],
quantityUnit: this.defaultQuantityUnit(),
materialCode: '',
deviceCode: '',
remark: '',
@@ -2047,7 +2078,7 @@ export default {
font-size: 16px;
}
.el-form-item {
margin-bottom: 14px;
margin-bottom: 24px;
}
}
.master-editor__quantity-field {
@@ -0,0 +1,305 @@
<template>
<div v-if="groups.length" class="punch-record-photos">
<section v-for="group in groups" :key="group.type" class="punch-record-photos__group">
<div class="punch-record-photos__head">
<span class="punch-record-photos__title">{{ group.type }}</span>
<span v-if="editable" class="punch-record-photos__count">{{ countText(group) }}</span>
</div>
<div
class="punch-record-photos__grid"
:class="{ 'punch-record-photos__grid--edit': editable }"
>
<div
v-for="(item, groupIndex) in group.items"
:key="`${item.photo.url}-${item.index}`"
class="punch-record-photos__item"
@click="handlePreview(item.index)"
>
<el-image
:src="item.photo.url"
:preview-src-list="nativePreview ? previewUrls : []"
:initial-index="nativePreview ? item.index : 0"
:preview-teleported="nativePreview"
:lazy="lazy"
fit="cover"
/>
<!-- 主图 = 该类型的第一张,按上传顺序取;没有独立字段,也不可切换 -->
<div v-if="groupIndex === 0" class="punch-record-photos__badge">主图</div>
<el-link
v-if="editable"
class="punch-record-photos__remove"
type="danger"
:underline="false"
@click.stop="$emit('remove', item.index)"
>删除</el-link
>
</div>
<el-upload
v-if="editable && !group.full"
class="punch-record-photos__upload"
:action="uploadAction"
name="file"
:headers="uploadHeaders"
:show-file-list="false"
:before-upload="file => handleBeforeUpload(file, group.type)"
:on-success="res => $emit('upload-success', res, group.type)"
:on-error="error => $emit('upload-error', error, group.type)"
>
<div class="punch-record-photos__add">+ 添加</div>
</el-upload>
<div v-else-if="editable" class="punch-record-photos__add punch-record-photos__add--full">
已满 {{ maxPerType }} 张
</div>
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 打卡照片是每个打卡节点必有的一个照片类型,不由过程配置的凭证类型决定;
// 它与各凭证类型各自最多 9 张,同一类型下第一张即界面上的「主图」。
const PUNCH_PHOTO_TYPE = '打卡照片';
// 照片没带类型时的兜底名称,与后端落库时的兜底口径一致
const PHOTO_TYPE_FALLBACK = '凭证';
const MAX_PHOTOS_PER_TYPE = 9;
const props = defineProps({
// 扁平照片数组:管理端回显用 voucherType,司机端回显与旧数据用 type
photos: {
type: Array,
default: () => [],
},
// 恒定展示的照片类型:「打卡照片」+ 该节点过程配置勾选的凭证类型。
// 留空则只按照片自身带的类型分组(只读回显用)。
groupTypes: {
type: Array,
default: () => [],
},
// 编辑态:每个类型自带「添加」格与删除链接,并显示已选/上限
editable: {
type: Boolean,
default: false,
},
// 每个类型的张数上限;0 表示不限制(在途打卡只有一个照片字段,张数由「只保留第一张」约束)
maxPerType: {
type: Number,
default: MAX_PHOTOS_PER_TYPE,
},
// 编辑态用 el-image 自带预览;只读态不发预览事件,把点击交给调用方开自己的看图器
nativePreview: {
type: Boolean,
default: false,
},
lazy: {
type: Boolean,
default: false,
},
uploadAction: {
type: String,
default: '',
},
uploadHeaders: {
type: Object,
default: () => ({}),
},
// 返回 false 即中止本次上传;类型、体积、张数校验都在调用方
beforeUpload: {
type: Function,
default: null,
},
});
const emit = defineEmits(['preview', 'remove', 'upload-success', 'upload-error']);
const photoType = photo =>
String(photo?.voucherType || photo?.type || '').trim() || PHOTO_TYPE_FALLBACK;
const groups = computed(() => {
const photos = Array.isArray(props.photos) ? props.photos : [];
const names = [];
const buckets = new Map();
const ensure = name => {
if (!buckets.has(name)) {
buckets.set(name, []);
names.push(name);
}
return buckets.get(name);
};
// 恒定展示的类型先占位:一张照片都没有的类型也要出现,否则编辑态下没有添加入口
props.groupTypes.forEach(type => {
const name = String(type || '').trim();
if (name) ensure(name);
});
// 按上传顺序入组,组间顺序即类型首次出现的顺序,不重排照片
photos.forEach((photo, index) => {
if (!photo?.url) return;
ensure(photoType(photo)).push({ photo, index });
});
const list = names.map(type => {
const items = buckets.get(type);
return {
type,
items,
full: props.editable && props.maxPerType > 0 && items.length >= props.maxPerType,
};
});
// 编辑态至少要有一个分组,否则记录里还没有照片时就没有添加入口
if (props.editable && !list.length) {
return [{ type: props.groupTypes[0] || PUNCH_PHOTO_TYPE, items: [], full: false }];
}
return list;
});
// 预览列表按原始下标对齐,删除/分组都不改变点击某张时打开的是哪一张
const previewUrls = computed(() =>
(Array.isArray(props.photos) ? props.photos : []).map(photo => photo?.url || '')
);
const handlePreview = index => {
if (props.nativePreview) return;
emit('preview', index);
};
const handleBeforeUpload = (file, type) => {
if (typeof props.beforeUpload !== 'function') return true;
return props.beforeUpload(file, type);
};
const countText = group =>
props.maxPerType > 0 ? `${group.items.length}/${props.maxPerType}` : String(group.items.length);
</script>
<style lang="scss" scoped>
.punch-record-photos {
// 弹窗里本组件挂在 el-form-item__content 下,那是个 flex 容器:
// 不给宽度就会收缩成内容宽,里面的网格跟着塌成一小块
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
&__group {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
&__head {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 20px;
}
&__title {
font-weight: 600;
color: #303133;
word-break: break-all;
}
&__count {
font-size: 12px;
color: #909399;
}
// 只读回显:撑满容器宽度的五列网格
&__grid {
width: 100%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
// 编辑态沿用弹窗原本的固定 96px 方图 + 自动换行:尺寸不随容器宽窄变化
&__grid--edit {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 10px;
}
&__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
.el-image {
display: block;
width: 100%;
aspect-ratio: 1;
border: 1px solid #eff1f7;
border-radius: 4px;
overflow: hidden;
background: #f5f7fa;
}
}
&__grid--edit &__item {
width: 96px;
.el-image {
width: 96px;
height: 96px;
aspect-ratio: auto;
}
}
&__badge {
position: absolute;
top: 0;
left: 0;
padding: 0 6px;
border-radius: 4px 0 4px 0;
background: rgba(64, 158, 255, 0.9);
font-size: 12px;
line-height: 18px;
color: #fff;
}
&__remove {
align-self: center;
font-size: 12px;
}
&__upload {
width: 100%;
}
&__grid--edit &__upload {
width: 96px;
}
&__add {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
aspect-ratio: 1;
border: 1px dashed #dcdfe6;
border-radius: 4px;
font-size: 13px;
color: #909399;
cursor: pointer;
}
&__grid--edit &__add {
width: 96px;
height: 96px;
aspect-ratio: auto;
}
&__add--full {
color: #c0c4cc;
cursor: not-allowed;
}
}
</style>
@@ -113,7 +113,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -153,11 +153,24 @@
@click="handleTransportSecondaryAddressInputClick('departure')"
>
<template #suffix>
<el-icon @click.stop="openTransportMapDialog('departure')">
<el-icon
class="shipping-template-page__map-suffix"
@click.stop="openTransportMapDialog('departure')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="shipping-template-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
/>
</el-tooltip>
<span>联系人</span>
<el-input
v-model="form.departureContact"
@@ -166,16 +179,6 @@
/>
<span>联系方式</span>
<el-input v-model="form.departurePhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
@@ -197,25 +200,28 @@
@click="handleTransportSecondaryAddressInputClick('arrival')"
>
<template #suffix>
<el-icon @click.stop="openTransportMapDialog('arrival')">
<el-icon
class="shipping-template-page__map-suffix"
@click.stop="openTransportMapDialog('arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="shipping-template-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
/>
</el-tooltip>
<span>联系人</span>
<el-input v-model="form.arrivalContact" placeholder="请输入" :disabled="dialogReadonly" />
<span>联系方式</span>
<el-input v-model="form.arrivalPhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
@@ -454,11 +460,20 @@
>
<el-table-column type="selection" width="55" />
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220"
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
><template #default="{ row }"
><el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link></template
><span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span></template
></el-table-column
>
<el-table-column label="附件描述" min-width="220"
@@ -721,11 +736,21 @@
</div>
<el-table :data="detailAttachmentRows" border empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220">
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -1098,10 +1123,12 @@ import {
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { getDictionary } from '@/api/system/dictbiz';
import { packageOptions } from '@/option/business/common';
import { formatContractOptionLabel } from '@/utils/contract';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { Location } from '@element-plus/icons-vue';
import { Location, OfficeBuilding } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import {
@@ -1252,6 +1279,7 @@ export default {
data() {
return {
Location,
OfficeBuilding,
form: {},
query: {},
loading: true,
@@ -1278,7 +1306,8 @@ export default {
cargoTypeRequest: null,
transportCargoRows: [],
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '立方', '公里', '单', '件', '车', '箱', '托盘'],
quantityUnitOptions: [],
quantityUnitLoading: false,
feeUnitOptions: [],
currencyOptions: [],
currencyLoading: false,
@@ -1525,11 +1554,26 @@ export default {
created() {
this.loadProjectOptions();
this.ensureCargoTypeOptions();
this.loadQuantityUnitOptions();
this.loadFeeUnitOptions();
this.loadCurrencyOptions();
if (this.isStandaloneFormPage) this.$nextTick(() => this.initStandaloneFormPage());
},
methods: {
formatContractOptionLabel,
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
buildOption(option) {
const next = { ...option, column: (option.column || []).map(column => ({ ...column })) };
const tableOrder = this.config.tableColumnOrder || [];
@@ -3384,10 +3428,25 @@ export default {
.shipping-template-page__route-row {
display: grid;
grid-template-columns:
minmax(160px, 1fr) minmax(220px, 2fr) auto minmax(140px, 1fr) auto minmax(160px, 1fr)
auto;
minmax(160px, 1fr) minmax(220px, 2fr) 40px auto minmax(140px, 1fr) auto
minmax(160px, 1fr);
gap: 8px;
align-items: center;
.shipping-template-page__address-pick-btn {
:deep(.el-icon) {
font-size: 22px;
}
}
}
.shipping-template-page__map-suffix {
cursor: pointer;
color: #909399;
font-size: 18px;
&:hover {
color: #409eff;
}
}
.shipping-template-page__address-map-input {
cursor: pointer;
@@ -3506,7 +3565,7 @@ export default {
}
.shipping-template-page__detail-form :deep(.el-form-item) {
min-width: 0;
margin-bottom: 16px;
margin-bottom: 24px;
}
.shipping-template-page__detail-form :deep(.el-form-item__label) {
color: #909399;
@@ -3695,7 +3754,7 @@ export default {
}
:global(.shipping-template-form-page-dialog .avue-group .el-form-item) {
margin-bottom: 16px;
margin-bottom: 24px;
}
:global(.shipping-template-form-page-dialog .avue-form__menu) {
@@ -149,7 +149,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -492,11 +492,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -673,14 +684,16 @@
<div class="transport-plan-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="transport-plan-page__transport-plan-detail-attachments">
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -855,11 +868,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<el-table-column
label="文件名"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -980,7 +1003,7 @@
<el-option
v-for="item in transportPlanImportContractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -1744,11 +1767,11 @@
><el-select
:model-value="dispatchFreightGroupPriceUnit(group)"
class="transport-plan-page__dispatch-unit-select"
placeholder="元/吨"
:placeholder="defaultPriceUnit"
@change="value => handleDispatchFreightGroupPriceUnitChange(group, value)"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
><el-option
v-for="item in taskFeeUnitOptions"
v-for="item in taskFeeUnitSelectOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select
@@ -1969,17 +1992,17 @@
<el-select
v-model="dispatchItemForm.priceUnit"
class="transport-plan-page__dispatch-unit-select"
placeholder="元/吨"
:placeholder="defaultPriceUnit"
:loading="taskFeeUnitLoading"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
>
<el-option
v-if="!taskFeeUnitOptions.some(item => item.value === '元/吨')"
label="元/吨"
value="元/吨"
v-if="!taskFeeUnitSelectOptions.some(item => item.value === defaultPriceUnit)"
:label="defaultPriceUnit"
:value="defaultPriceUnit"
/>
<el-option
v-for="item in taskFeeUnitOptions"
v-for="item in taskFeeUnitSelectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
@@ -2201,11 +2224,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.enter="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.space.prevent="previewAttachment(row, dispatchItemAttachmentRows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -2741,7 +2775,15 @@ import { addressTypeOptions } from '@/option/base/common-address';
import { packageOptions } from '@/option/business/common';
import { formatUpdateUserName } from '@/utils/audit';
import { getToken, isAdministrator } from '@/utils/auth';
import {
currencyUnit,
mapFeeUnitOptions,
remapPriceUnitCurrency,
resolvePriceUnitByQuantityUnit,
} from '@/utils/currency';
import { formatContractOptionLabel } from '@/utils/contract';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
@@ -3206,7 +3248,7 @@ export default {
billingCargoTypeLoading: false,
billingCargoTypeRequest: null,
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
quantityUnitOptions: [],
attachmentFileTypes: [
'pdf',
'bmp',
@@ -3245,6 +3287,7 @@ export default {
} else {
this.consumeTemplateCreatePayload();
}
this.loadTaskQuantityUnitOptions();
},
computed: {
...mapGetters(['permission', 'userInfo']),
@@ -3609,6 +3652,22 @@ export default {
const value = this.dispatchItemForm.freightCurrency || 'RMB';
return this.currencyRemark(value);
},
taskFeeUnitSelectOptions() {
const currency =
this.dispatchItemForm.freightCurrency ||
this.dispatchContractCurrency ||
this.form.settlementCurrency ||
'RMB';
return mapFeeUnitOptions(this.taskFeeUnitOptions, currency);
},
defaultPriceUnit() {
return `${currencyUnit(
this.dispatchItemForm.freightCurrency ||
this.dispatchContractCurrency ||
this.form.settlementCurrency ||
'RMB'
)}/吨`;
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -3719,6 +3778,7 @@ export default {
},
},
methods: {
formatContractOptionLabel,
initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
@@ -4307,8 +4367,6 @@ export default {
['contractName', '客户合同'],
['planName', '计划名称'],
['transportType', '运输方式'],
['planStartDate', '计划开始日期'],
['planEndDate', '计划结束日期'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
];
@@ -4804,19 +4862,49 @@ export default {
contract.customerNames ||
this.form.customerName ||
'';
const nextCurrency = this.getContractCurrency(contract);
this.remapTransportPlanPriceUnits(nextCurrency);
this.$refs.crud?.validateField?.('contractName');
},
remapTransportPlanPriceUnits(currency) {
if (this.form.priceUnit) {
this.form.priceUnit = remapPriceUnitCurrency(this.form.priceUnit, currency);
}
if (this.dispatchItemForm.priceUnit) {
this.dispatchItemForm.priceUnit = remapPriceUnitCurrency(
this.dispatchItemForm.priceUnit,
currency
);
}
this.dispatchItemForm.freightCurrency = currency;
(this.dispatchItemCargoRows || []).forEach(cargo => {
if (cargo.priceUnit) {
cargo.priceUnit = remapPriceUnitCurrency(cargo.priceUnit, currency);
}
});
(this.transportCargoRows || []).forEach(cargo => {
if (cargo.priceUnit) {
cargo.priceUnit = remapPriceUnitCurrency(cargo.priceUnit, currency);
}
});
},
loadDispatchContractCurrency(plan = {}) {
const findContract = () =>
this.contractOptions.find(item => String(item.id) === String(plan.contractId)) ||
this.contractOptions.find(item => item.contractName === plan.contractName);
const applyCurrency = contract => {
const previousCurrency = this.dispatchContractCurrency;
this.dispatchContractCurrency = this.getContractCurrency(
contract &&
(contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency)
? contract
: { settlementCurrency: plan.settlementCurrency }
);
if (previousCurrency && previousCurrency !== this.dispatchContractCurrency) {
this.remapTransportPlanPriceUnits(this.dispatchContractCurrency);
} else {
this.dispatchItemForm.freightCurrency = this.dispatchContractCurrency;
}
return this.dispatchContractCurrency;
};
const current = findContract();
@@ -5076,15 +5164,40 @@ export default {
return Promise.resolve(this.taskQuantityUnitOptions);
}
this.taskQuantityUnitLoading = true;
return getDictionary({ code: 'quantity_unit' })
.then(res => {
this.taskQuantityUnitOptions = this.normalizeDictOptions(res.data.data || []);
return loadMeasurementUnitOptions()
.then(options => {
this.taskQuantityUnitOptions = options;
this.quantityUnitOptions = options.map(item => item.value);
this.ensureQuantityUnitOptions([
this.dispatchItemForm?.quantityUnit,
...(this.dispatchItemCargoRows || []).map(row => row.quantityUnit),
...(this.transportCargoRows || []).map(row => row.quantityUnit),
]);
return this.taskQuantityUnitOptions;
})
.finally(() => {
this.taskQuantityUnitLoading = false;
});
},
ensureQuantityUnitOptions(units = []) {
const pending = [...new Set(
(Array.isArray(units) ? units : [units])
.map(item => String(item || '').trim())
.filter(Boolean)
)];
if (!pending.length) return;
pending.forEach(unit => {
if (!this.quantityUnitOptions.includes(unit)) {
this.quantityUnitOptions = [...this.quantityUnitOptions, unit];
}
if (!this.taskQuantityUnitOptions.some(item => item.value === unit)) {
this.taskQuantityUnitOptions = [
...this.taskQuantityUnitOptions,
{ label: unit, value: unit },
];
}
});
},
loadTaskFeeUnitOptions() {
if (this.taskFeeUnitOptions.length || this.taskFeeUnitLoading) {
return Promise.resolve(this.taskFeeUnitOptions);
@@ -5146,6 +5259,9 @@ export default {
});
},
normalizeTransportCargoRow(row = {}) {
const quantityUnit = String(
row.quantityUnit || row.goodsQuantityUnit || row.unit || ''
).trim();
const cargo = {
...defaultTransportCargo(),
...row,
@@ -5162,10 +5278,14 @@ export default {
packageType: row.packageType || row.package || '',
specification: row.specification || row.spec || '',
freightAmount: row.freightAmount ?? row.amount ?? row.totalAmount ?? '',
quantityUnit,
};
['quantity', 'mileage', 'unitPrice', 'freightAmount'].forEach(prop => {
if (Number(cargo[prop]) === -1) cargo[prop] = '';
});
if (quantityUnit) {
this.ensureQuantityUnitOptions(quantityUnit);
}
return cargo;
},
hasMultipleConfiguredGoods(source = {}, rows = []) {
@@ -5183,7 +5303,7 @@ export default {
},
addTransportCargoRow(index = -1, row = {}) {
const nextRow = this.normalizeTransportCargoRow({
priceUnit: this.form.priceUnit || '元/吨',
priceUnit: this.form.priceUnit || this.defaultPriceUnit,
...row,
});
if (
@@ -7594,6 +7714,9 @@ export default {
...baseRow,
...(index >= 0 ? row : {}),
};
this.dispatchItemForm.quantityUnit =
this.dispatchItemForm.quantityUnit || defaultUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
this.ensureQuantityUnitOptions(this.dispatchItemForm.quantityUnit);
this.dispatchItemForm.otherFeeTotal = this.normalizeDispatchFeeValue(
this.dispatchItemForm.otherFeeTotal
);
@@ -7603,7 +7726,11 @@ export default {
});
const goodsRows = this.parseJsonArray(this.dispatchItemForm.goodsJson);
this.dispatchItemCargoRows = (goodsRows.length ? goodsRows : [this.dispatchItemForm]).map(
item => this.normalizeTransportCargoRow(item)
item =>
this.normalizeTransportCargoRow({
...item,
quantityUnit: item.quantityUnit || this.dispatchItemForm.quantityUnit || defaultUnit,
})
);
const hasMultiplePlanGoods = this.dispatchPlanGoodsRows.length > 1;
const shouldAutoFillFullGoods =
@@ -7613,11 +7740,18 @@ export default {
const unscheduledGoodsRows = this.getDispatchUnscheduledGoodsRows();
this.dispatchItemForm.taskEntryMode = 'full';
this.dispatchItemForm.quantity = '';
this.dispatchItemCargoRows = unscheduledGoodsRows.length
? unscheduledGoodsRows
: this.dispatchPlanGoodsRows.map(goods =>
this.normalizeTransportCargoRow({ ...goods, quantity: '' })
);
this.dispatchItemCargoRows = (
unscheduledGoodsRows.length
? unscheduledGoodsRows
: this.dispatchPlanGoodsRows.map(goods =>
this.normalizeTransportCargoRow({ ...goods, quantity: '' })
)
).map(goods =>
this.normalizeTransportCargoRow({
...goods,
quantityUnit: goods.quantityUnit || defaultUnit || TRANSPORT_PLAN_QUANTITY_UNIT,
})
);
}
if (!this.dispatchItemCargoRows.length) {
this.dispatchItemCargoRows = [this.normalizeTransportCargoRow()];
@@ -7632,7 +7766,10 @@ export default {
this.dispatchItemCargoRows = this.dispatchItemCargoRows.map((cargo, itemIndex) => ({
...cargo,
unitPrice: cargo.unitPrice || freightInfo.freightItems[itemIndex]?.unitPrice || '',
priceUnit: cargo.priceUnit || freightInfo.freightItems[itemIndex]?.priceUnit || '元/吨',
priceUnit:
cargo.priceUnit ||
freightInfo.freightItems[itemIndex]?.priceUnit ||
this.defaultPriceUnit,
freightAmount:
cargo.freightAmount || freightInfo.freightItems[itemIndex]?.freightAmount || '',
}));
@@ -7881,7 +8018,7 @@ export default {
return value;
},
addDispatchCargoRow(index = -1) {
const nextRow = this.normalizeTransportCargoRow({ priceUnit: '元/吨' });
const nextRow = this.normalizeTransportCargoRow({ priceUnit: this.defaultPriceUnit });
if (index > -1) this.dispatchItemCargoRows.splice(index + 1, 0, nextRow);
else this.dispatchItemCargoRows.push(nextRow);
},
@@ -7925,7 +8062,14 @@ export default {
return prices[0];
},
dispatchFreightGroupPriceUnit(group = {}) {
return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : '');
if (group.rows?.[0]?.priceUnit) return group.rows[0].priceUnit;
if (!group.quantityUnit) return '';
return (
resolvePriceUnitByQuantityUnit(
group.quantityUnit,
this.dispatchItemForm.freightCurrency || this.dispatchContractCurrency || 'RMB'
) || `${currencyUnit(this.dispatchContractCurrency || 'RMB')}/${group.quantityUnit}`
);
},
dispatchFreightGroupAmount(group = {}) {
const amount = (group.rows || []).reduce(
@@ -8309,7 +8453,10 @@ export default {
this.dispatchItemForm.cargoName ||
'',
quantity: quantityText,
quantityUnit: firstGoods.quantityUnit || this.dispatchItemForm.quantityUnit || '',
quantityUnit:
firstGoods.quantityUnit ||
this.dispatchItemForm.quantityUnit ||
TRANSPORT_PLAN_QUANTITY_UNIT,
unitPrice: firstGoods.unitPrice || '',
priceUnit: firstGoods.priceUnit || '',
freightTotal: this.dispatchItemFreightTotal,
@@ -8324,7 +8471,7 @@ export default {
unitPrice: cargo.unitPrice || '',
priceUnit: cargo.priceUnit || '',
quantity: cargo.quantity || '',
quantityUnit: cargo.quantityUnit || '',
quantityUnit: cargo.quantityUnit || firstGoods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT,
freightAmount: this.dispatchCargoFreightAmount(cargo),
})),
}),
@@ -8413,26 +8560,40 @@ export default {
return {
id: this.dispatchRow.id,
mode,
waybills: rows.map(row => ({
...row,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: this.formatDispatchDate(row.estimatedStartTime),
estimatedEndTime: this.formatDispatchDate(row.estimatedEndTime),
goodsJson:
row.goodsJson ||
JSON.stringify([
waybills: rows.map(row => {
const quantityUnit = this.getDispatchQuantityUnit(row);
let goodsJson = row.goodsJson;
const goodsRows = this.parseJsonArray(goodsJson);
if (goodsRows.length) {
goodsJson = JSON.stringify(
goodsRows.map(goods => ({
...goods,
quantityUnit: goods.quantityUnit || quantityUnit,
}))
);
} else {
goodsJson = JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit: row.quantityUnit,
quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
},
]),
})),
]);
}
return {
...row,
quantityUnit,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: this.formatDispatchDate(row.estimatedStartTime),
estimatedEndTime: this.formatDispatchDate(row.estimatedEndTime),
goodsJson,
};
}),
};
},
formatDispatchDate(value) {
@@ -8680,7 +8841,7 @@ export default {
&__dispatch-shipping-form {
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
}
@@ -9871,7 +10032,7 @@ export default {
}
:global(.transport-plan-form-page-dialog .avue-group .el-form-item) {
margin-bottom: 16px;
margin-bottom: 24px;
}
:global(.transport-plan-form-page-dialog .avue-group .dialog-section-title) {
@@ -1,7 +1,7 @@
<template>
<div class="voucher-folder-page voucher-folder-page--embedded">
<section class="voucher-folder-page__search">
<el-form :model="query" label-position="right" label-width="160px">
<el-form :model="query" label-position="right">
<div class="voucher-folder-page__search-grid">
<el-form-item label="车牌号">
<el-input v-model="query.plateNo" clearable placeholder="请输入" />
@@ -196,7 +196,7 @@ watch(
}
&__search-grid {
display: grid;
grid-template-columns: 360px 360px 1fr;
grid-template-columns: 220px 220px 1fr;
gap: 8px 24px;
}
&__search-actions {
@@ -270,15 +270,22 @@
prop="mileage"
label="里程(km)"
width="100"
/><el-table-column prop="unitPrice" label="单价" width="90" /><el-table-column
prop="freight"
label="运费"
width="100"
/><el-table-column prop="otherFeeTotal" label="其他费用合计" width="120" /><el-table-column
prop="freightTotal"
label="运费合计"
width="100"
/><el-table-column
>
<template #default="{ row }">{{ formatOptionalNumber(row.mileage) }}</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" width="90">
<template #default="{ row }">{{ formatOptionalNumber(row.unitPrice) }}</template>
</el-table-column>
<el-table-column prop="freight" label="运费" width="100">
<template #default="{ row }">{{ formatOptionalNumber(row.freight) }}</template>
</el-table-column>
<el-table-column prop="otherFeeTotal" label="其他费用合计" width="120">
<template #default="{ row }">{{ formatOptionalNumber(row.otherFeeTotal) }}</template>
</el-table-column>
<el-table-column prop="freightTotal" label="运费合计" width="100">
<template #default="{ row }">{{ formatOptionalNumber(row.freightTotal) }}</template>
</el-table-column>
<el-table-column
prop="actualStartDate"
label="实际发货时间"
width="160"
@@ -374,7 +381,7 @@
<el-option
v-for="item in contracts"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -610,6 +617,7 @@ import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { getDictionary } from '@/api/system/dictbiz';
import * as api from '@/api/business/waybill-manage';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
import SectionCard from '@/components/section-card/main.vue';
@@ -879,11 +887,14 @@ const syncCarrierOptions = () => {
carrierOptions.value = [];
return;
}
const carrierId =
const carrierId = resolveCarrierArchiveId(
customerContract?.partyBId ||
customerContract?.partyBUserId ||
customerContract?.customerId ||
'';
customerContract?.partyBUserId ||
customerContract?.contractPartyBId ||
customerContract?.customerId ||
'',
carrierName
);
carrierOptions.value = [{ id: carrierId, carrierName, label: carrierName, value: carrierName }];
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
@@ -1079,19 +1090,40 @@ const transportTypeLabel = value => {
);
return matched?.dictValue || matched?.label || named || text || '-';
};
const buildBatchQueryParams = () => ({
batchNo: query.batchNo || undefined,
carrierId: query.carrierId || undefined,
createUser: query.createUser || undefined,
createTimeStart: query.createTimeRange?.[0] || undefined,
createTimeEnd: query.createTimeRange?.[1] || undefined,
current: page.current,
size: page.size,
});
const loadListOptions = async () => {
const res = await api.getImportOptions?.();
const data = res?.data?.data || {};
queryCarriers.value = data.carriers || [];
creators.value = data.creators || [];
};
const loadBatches = async () => {
const res = await api.getImportBatches({ ...query, current: page.current, size: page.size });
const res = await api.getImportBatches(buildBatchQueryParams());
batches.value = res.data?.data?.records || [];
page.total = res.data?.data?.total || 0;
};
onMounted(() => {
if (props.standalone) loadBatches();
if (props.standalone) {
loadListOptions();
loadBatches();
}
if (props.createPage) openCreatePage();
});
watch(
() => props.modelValue,
value => {
if (value) loadBatches();
if (value) {
loadListOptions();
loadBatches();
}
}
);
const resetQuery = () => {
@@ -1106,7 +1138,7 @@ const loadDetails = async () => {
current: detailPage.current,
size: detailPage.size,
});
details.value = res.data?.data?.records || [];
details.value = (res.data?.data?.records || []).map(normalizeDetailRow);
detailPage.total = res.data?.data?.total || 0;
};
const openCreate = () => prepareCreateForm(true);
@@ -1155,7 +1187,7 @@ const loadBatchRows = async batchId => {
if (!batchId) return;
const res = await api.getImportDetails({ batchId, current: 1, size: 9999 });
rows.value = extractRecords(res).map(({ id, ...item }, index) => ({
...item,
...normalizeDetailRow(item),
_key: `${id || 'row'}-${index}`,
batchNo: item.batchNo || form.batchNo,
}));
@@ -1288,15 +1320,60 @@ const contractChange = id => {
const carrierTypeChange = () => {
syncCarrierOptions();
};
const resolveCarrierArchiveId = (carrierId, carrierName) => {
if (carrierId) return carrierId;
const name = String(carrierName || '').trim();
if (!name) return '';
const matched = queryCarriers.value.find(item => String(item.name || '').trim() === name);
return matched?.id || '';
};
const carrierChange = value => {
const item = carrierOptions.value.find(option => String(option.value) === String(value || ''));
clearCarrier();
if (!item) return;
form.carrierId = item.id || '';
form.carrierIds = item.id ? [item.id] : [];
const carrierId = resolveCarrierArchiveId(item.id, item.carrierName);
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
form.carrierName = item.carrierName || '';
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
};
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
const OPTIONAL_NUMBER_EMPTY_SENTINELS = [-1];
const OPTIONAL_NUMBER_PROPS = ['mileage', 'unitPrice', 'freight', 'otherFeeTotal', 'freightTotal'];
const normalizeOptionalImportNumber = (value, emptySentinels = OPTIONAL_NUMBER_EMPTY_SENTINELS) => {
if (!String(value ?? '').trim()) return null;
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
};
const formatOptionalNumber = value => {
const normalized = normalizeOptionalImportNumber(value);
return normalized === null || normalized === undefined ? '' : normalized;
};
const normalizeDetailRow = row => {
const next = { ...(row || {}) };
OPTIONAL_NUMBER_PROPS.forEach(prop => {
next[prop] = formatOptionalNumber(next[prop]);
});
return next;
};
const normalizeImportRow = row => ({
...row,
mileage: normalizeOptionalImportNumber(
firstNotEmpty(row.mileage, row['里程'], row['里程(km)'], row['里程(公里)'])
),
otherFeeTotal: normalizeOptionalImportNumber(
firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
),
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
transportType: firstNotEmpty(
row.transportType,
row['运输类型'],
row['*运输类型'],
row['运输方式'],
row['*运输方式']
),
});
const fileChange = async (file, list) => {
if (!projectInfoReady.value) {
ElMessage.warning('请先选择项目、客户合同和承运信息,再导入运单明细');
@@ -1370,6 +1447,9 @@ const fileChange = async (file, list) => {
item[key] ??
'';
});
['mileage', 'unitPrice', 'freight', 'otherFeeTotal', 'freightTotal'].forEach(prop => {
row[prop] = normalizeOptionalImportNumber(row[prop]);
});
row.actualStartDate = normalizeImportDate(row.actualStartDate);
row.actualEndDate = normalizeImportDate(row.actualEndDate);
row.planStartDate = normalizeImportDate(row.planStartDate);
@@ -1618,6 +1698,7 @@ const downloadTemplate = async () => {
ElMessage.error('模板下载失败');
}
};
const isValidMobile = value => /^\d{11}$/.test(String(value || '').trim());
const validateImportRows = () => {
if (!rows.value.length) {
ElMessage.warning('请上传至少一条运单明细');
@@ -1629,6 +1710,11 @@ const validateImportRows = () => {
ElMessage.warning(`第${index + 1}行${missingField.label}不能为空`);
return false;
}
const driverPhone = String(row.driverPhone ?? '').trim();
if (driverPhone && !isValidMobile(driverPhone)) {
ElMessage.warning(`第${index + 1}行司机/船长手机号格式不正确,应为11位数字`);
return false;
}
}
return true;
};
@@ -1656,31 +1742,6 @@ const removeSelectedRows = () => {
rowSelection.value = [];
refreshDuplicateFlags();
};
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
const normalizeOptionalImportNumber = (value, emptySentinels = []) => {
if (!String(value ?? '').trim()) return null;
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
};
const normalizeImportRow = row => ({
...row,
mileage: normalizeOptionalImportNumber(
firstNotEmpty(row.mileage, row['里程'], row['里程(km)'], row['里程(公里)']),
[-1]
),
otherFeeTotal: normalizeOptionalImportNumber(
firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
),
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
transportType: firstNotEmpty(
row.transportType,
row['运输类型'],
row['*运输类型'],
row['运输方式'],
row['*运输方式']
),
});
const normalizeOptionalPlanId = value => {
if (value === undefined || value === null || value === '') return null;
return Number(value) === -1 || Number(value) <= 0 ? null : value;
@@ -1688,11 +1749,23 @@ const normalizeOptionalPlanId = value => {
const buildImportPayload = (status = form.status, rowList = rows.value, extra = {}) => {
const { file, ...batch } = form;
const planId = normalizeOptionalPlanId(batch.planId);
const carrierId = resolveCarrierArchiveId(
batch.carrierId || batch.carrierIds?.[0] || '',
batch.carrierName
);
const carrierIds =
Array.isArray(batch.carrierIds) && batch.carrierIds.length
? batch.carrierIds
: carrierId
? [carrierId]
: [];
return {
...batch,
planId,
// 未选计划时不带占位名称,避免落库/回显 -1
planName: planId ? batch.planName || undefined : null,
carrierId: carrierId || undefined,
carrierIds,
status,
rows: rowList.map(normalizeImportRow).map(stripInternalRowFields),
...extra,
File diff suppressed because it is too large Load Diff
+83 -16
View File
@@ -45,7 +45,7 @@
<el-form-item label="项目">
<el-input v-model="form.projectName" disabled />
</el-form-item>
<el-form-item label="所属组织">
<el-form-item label="合同负责部门">
<el-input v-model="form.organizationName" disabled />
</el-form-item>
<el-form-item label="甲方">
@@ -63,6 +63,15 @@
disabled
/>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="form.applyDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
disabled
/>
</el-form-item>
<el-form-item label="合同期限">
<div class="contract-basic-section__date-range">
<el-date-picker
@@ -91,12 +100,13 @@
<template #suffix>天</template>
</el-input>
</el-form-item>
<el-form-item label="合同金额">
<el-form-item label="合同金额" prop="contractAmount">
<el-input
:model-value="contractAmountDisplay"
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
@@ -154,7 +164,7 @@
</el-input>
</el-form-item>
<el-form-item label="是否需要加盖法人章">
<el-select v-model="form.legalSealFlag" placeholder="请选择">
<el-select v-model="form.legalSealFlag" placeholder="请选择" clearable>
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
@@ -173,20 +183,30 @@
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
</div>
<el-form-item label="备注" class="contract-basic-section__remark">
<el-form-item label="合同主要内容" class="contract-basic-section__remark">
<el-input
v-model="form.mainContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入合同主要内容"
/>
</el-form-item>
<el-form-item label="其他事项说明" class="contract-basic-section__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入备注"
placeholder="请输入其他事项说明"
/>
</el-form-item>
</section>
<contract-attachment-section
title="合同文件"
title="用印材料"
description
attachment-type
:attachment-location-options="changeAttachmentLocationOptions"
@@ -251,13 +271,20 @@
<el-button type="primary" plain @click="addPaymentRatioRow">添加</el-button>
</section>
<billing-plan-editor v-model="planDialogVisible" :value="planEditor" :index="planEditorIndex" @save="savePlan" />
<billing-plan-editor
v-model="planDialogVisible"
:value="planEditor"
:index="planEditorIndex"
:settlement-currency="form.settlementCurrency"
@save="savePlan"
/>
<contract-attachment-section
title="其它附件"
title="附件(佐证材料等)"
description
attachment-type
attachment-location="其它附件"
attachment-location="附件(佐证材料等)"
attachment-type-dict-code="contract_supporting_attachment_type"
:attachment-location-options="changeAttachmentLocationOptions"
:rows="attachments"
:preview="previewAttachment"
@@ -308,6 +335,8 @@ import * as api from '@/api/business/contract-manage';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ContractAttachmentSection, {
isContractSealMaterialLocation,
isContractSupportingAttachmentLocation,
normalizeContractFileRows,
} from './components/contract-attachment-section.vue';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
@@ -358,6 +387,19 @@ const normalizeOptionalAmount = value => {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : null;
};
const formatContractAmountInput = (value, emptyValue = null) => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue;
};
const normalizeCustomPeriods = (periods = []) =>
@@ -424,6 +466,21 @@ export default {
],
rules: {
contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }],
contractAmount: [
{ required: true, message: '请输入合同金额', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
return callback(new Error('请输入合同金额'));
}
if (Number(value) < 0) {
return callback(new Error('合同金额不能小于0'));
}
callback();
},
trigger: 'blur',
},
],
changeReason: [{ max: 500, message: '变更原因不能超过500个字符', trigger: 'blur' }],
},
};
@@ -433,7 +490,7 @@ export default {
return this.$route.query.name || '合同变更';
},
changeAttachmentLocationOptions() {
return ['合同文件', '其它附件', '变更材料'];
return ['用印材料', '附件(佐证材料等)', '变更材料'];
},
contractAmountDisplay() {
const value = this.form.contractAmount;
@@ -498,6 +555,9 @@ export default {
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.form.contractAmount = normalized === '' ? null : normalized;
},
formatContractAmountField() {
this.form.contractAmount = formatContractAmountInput(this.form.contractAmount);
},
loadDictionaries() {
Promise.all([
getSystemDictionary({ code: 'currency_type' }),
@@ -540,7 +600,7 @@ export default {
copyCount: normalizeOptionalPositiveInteger(data.copyCount),
invoiceCycle: normalizeOptionalPositiveInteger(data.invoiceCycle),
paymentDays: normalizeOptionalPositiveInteger(data.paymentDays),
contractAmount: normalizeOptionalAmount(data.contractAmount),
contractAmount: formatContractAmountInput(data.contractAmount),
templateFlag: data.templateFlag ?? 0,
electronicSealFlag: data.electronicSealFlag ?? 0,
archiveStatus: data.archiveStatus || '未归档',
@@ -601,7 +661,7 @@ export default {
return [];
}
},
// 审核通过后上一次的变更材料归集到「其它附件」,按文件地址/文件名去重,避免重复展示
// 审核通过后上一次的变更材料归集到「附件(佐证材料等)」,按文件地址/文件名去重,避免重复展示
mergeChangeAttachments(attachments = [], changeAttachments = []) {
const key = item => this.attachmentUrl(item) || this.attachmentName(item);
const exists = new Set(attachments.map(key));
@@ -655,11 +715,11 @@ export default {
previewAttachment(row, rows = this.attachments) { const url = this.attachmentUrl(row); if (!url) { this.$message.warning('附件地址为空,无法预览'); return; } if (this.isAttachmentImage(row)) { this.imagePreviewUrls = (rows || []).filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item)).map(item => this.attachmentUrl(item)); this.imagePreviewIndex = Math.max(this.imagePreviewUrls.indexOf(url), 0); this.imagePreviewVisible = true; return; } this.previewFile = { name: this.attachmentName(row), url, mimeType: row.mimeType || row.contentType || '' }; this.documentPreviewVisible = true; },
handleAttachmentUploadToLocation({ location, rows = [] }) {
if (!rows.length) return;
if (location === '合同文件') {
if (isContractSealMaterialLocation(location)) {
this.contractFileRows = [...(this.contractFileRows || []), ...rows];
return;
}
if (location === '其它附件') {
if (isContractSupportingAttachmentLocation(location)) {
this.attachments = [...(this.attachments || []), ...rows];
return;
}
@@ -692,11 +752,18 @@ export default {
.contract-basic-section > .dialog-section-title { margin-bottom: 20px; color: #303133; }
.contract-basic-section__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 48px; }
.contract-basic-section__change-type { grid-column: 1 / -1; }
.contract-basic-section__remark { width: 100%; margin-bottom: 0 !important; }
.contract-basic-section__remark {
width: 100%;
margin-bottom: 23px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
.contract-basic-section__date-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; width: 360px; max-width: 100%; }
.contract-basic-section__date-range span { color: #606266; }
.contract-basic-section__date-range :deep(.el-date-editor) { width: auto; min-width: 0; }
.contract-basic-section :deep(.el-form-item) { align-items: center; margin-bottom: 16px; }
.contract-basic-section :deep(.el-form-item) { align-items: center; }
.contract-basic-section :deep(.el-form-item__label) { flex: 0 0 96px !important; width: 96px !important; height: auto; padding-right: 12px; white-space: normal; word-break: break-all; line-height: 18px; }
.contract-basic-section :deep(.el-form-item__content) { min-width: 0; }
.contract-basic-section :deep(.el-input),
+196 -49
View File
@@ -138,7 +138,7 @@
<el-descriptions-item label="甲方">{{ detailValue('partyA') }}</el-descriptions-item>
<el-descriptions-item label="乙方">{{ detailValue('partyB') }}</el-descriptions-item>
<el-descriptions-item label="合同期限">{{ detailContractPeriod }}</el-descriptions-item>
<el-descriptions-item label="所属组织">{{
<el-descriptions-item label="合同负责部门">{{
detailValue('organizationName')
}}</el-descriptions-item>
<el-descriptions-item label="经办人">{{
@@ -150,6 +150,9 @@
<el-descriptions-item label="签订日期">{{
detailValue('signDate')
}}</el-descriptions-item>
<el-descriptions-item label="申请日期">{{
detailValue('applyDate')
}}</el-descriptions-item>
<el-descriptions-item label="合同格式">{{
detailValue('contractFormat')
}}</el-descriptions-item>
@@ -181,7 +184,10 @@
<el-descriptions-item label="是否电子章">
{{ detailElectronicSealText }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{
<el-descriptions-item label="合同主要内容" :span="3">{{
detailValue('mainContent')
}}</el-descriptions-item>
<el-descriptions-item label="其他事项说明" :span="3">{{
detailValue('remark')
}}</el-descriptions-item>
<el-descriptions-item v-if="detailRiskTipText" label="风险客商提示" :span="3">
@@ -197,7 +203,7 @@
/>
<contract-attachment-section
title="合同文件"
title="用印材料"
description
attachment-type
readonly
@@ -313,7 +319,7 @@
</section>
<contract-attachment-section
title="其它附件"
title="附件(佐证材料等)"
description
readonly
:rows="detailAttachmentRows"
@@ -396,8 +402,14 @@
<el-form-item label="合同编号">
<el-input
v-model="form.contractNo"
:disabled="formMode === 'edit'"
placeholder="不填写,则系统自动生成"
disabled
:placeholder="
formMode === 'add'
? form.organizationId
? '系统按部门规则自动生成'
: '请先选择合同负责部门'
: ''
"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
@@ -436,7 +448,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="所属组织" prop="organizationName">
<el-form-item label="合同负责部门" prop="organizationName">
<el-cascader
v-model="organizationCascaderValue"
:options="organizationOptions"
@@ -492,6 +504,16 @@
placeholder="请选择签订日期"
/>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="form.applyDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="提交正式合同时自动填充"
disabled
/>
</el-form-item>
<el-form-item label="合同期限" prop="startDate">
<div class="contract-manage-form__date-range">
<el-date-picker
@@ -518,12 +540,13 @@
@input="value => integerInput('paymentDays', value)"
><template #suffix>天</template></el-input>
</el-form-item>
<el-form-item label="合同金额">
<el-form-item label="合同金额" prop="contractAmount">
<el-input
:model-value="contractAmountDisplay"
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
@@ -578,7 +601,7 @@
>
</el-form-item>
<el-form-item label="是否需要加盖法人章" prop="legalSealFlag">
<el-select v-model="form.legalSealFlag" placeholder="请选择">
<el-select v-model="form.legalSealFlag" placeholder="请选择" clearable>
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
@@ -597,14 +620,24 @@
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
</div>
<el-form-item label="备注" prop="remark" class="contract-manage-form__remark">
<el-form-item label="合同主要内容" prop="mainContent" class="contract-manage-form__remark">
<el-input
v-model="form.mainContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入合同主要内容"
/>
</el-form-item>
<el-form-item label="其他事项说明" prop="remark" class="contract-manage-form__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入备注"
placeholder="请输入其他事项说明"
/>
</el-form-item>
<!-- 风险客商提示(基本信息备注后):外部客商高风险/黑名单时展示,文案由后端 riskTip 拼装 -->
@@ -615,7 +648,7 @@
</section>
<contract-attachment-section
title="合同文件"
title="用印材料"
description
attachment-type
:attachment-location-options="contractAttachmentLocationOptions"
@@ -835,10 +868,11 @@
</section>
<contract-attachment-section
title="其它附件"
title="附件(佐证材料等)"
description
attachment-type
attachment-location="其它附件"
attachment-location="附件(佐证材料等)"
attachment-type-dict-code="contract_supporting_attachment_type"
:attachment-location-options="contractAttachmentLocationOptions"
:readonly="isAttachmentUploadMode"
:rows="attachmentRows"
@@ -946,18 +980,19 @@
v-model="billingPlanBox"
:value="billingPlanForm"
:index="billingPlanIndex"
:settlement-currency="form.settlementCurrency"
@save="saveBillingPlan"
/>
<contract-attachment-section
ref="listAttachmentUploader"
dialog-only
title="合同文件"
title="用印材料"
description
attachment-type
use-upload-dialog
mark-approved-on-upload
:attachment-location-options="['合同文件', '其它附件']"
:attachment-location-options="['用印材料', '附件(佐证材料等)']"
:rows="listUploadContractFileRows"
@update:rows="listUploadContractFileRows = $event"
@upload-to-location="handleListAttachmentUploadToLocation"
@@ -1014,6 +1049,7 @@
v-model="detailBillingPlanBox"
:value="detailBillingPlanForm"
:index="detailBillingPlanIndex"
:settlement-currency="detailRow.settlementCurrency || form.settlementCurrency"
readonly
/>
@@ -1046,12 +1082,15 @@ import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
import { config, option } from '@/option/business/contract-manage';
import { getToken, isAdministrator } from '@/utils/auth';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ChangeRecordDetailDialog from '@/components/change-record-detail-dialog/main.vue';
import ContractAttachmentSection, {
attachmentName as sharedAttachmentName,
attachmentUrl as sharedAttachmentUrl,
isContractSealMaterialLocation,
isContractSupportingAttachmentLocation,
normalizeContractFileRows,
} from './components/contract-attachment-section.vue';
import HcsContractPanel from './components/hcs-contract-panel.vue';
@@ -1088,6 +1127,19 @@ const normalizeOptionalAmount = (value, emptyValue = null) => {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : emptyValue;
};
const formatContractAmountInput = (value, emptyValue = null) => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue;
};
const normalizeProjectValue = value => {
if (value === undefined || value === null || value === '' || String(value) === '-1') return '';
return value;
@@ -1120,8 +1172,9 @@ const defaultForm = () => ({
partyB: '',
handlerUserName: '',
signDate: '',
applyDate: '',
contractFormat: '',
legalSealFlag: 0,
legalSealFlag: null,
copyCount: '',
settlementCurrency: '',
invoiceCycle: '',
@@ -1137,6 +1190,7 @@ const defaultForm = () => ({
effectiveType: '',
feeGenerationMode: 'system',
archiveStatus: '未归档',
mainContent: '',
remark: '',
});
// 复制新增:仅保留可编辑字段与明细,剔除主键、编号、审批与流程信息
@@ -1148,6 +1202,7 @@ const createCopiedDetail = (detail = {}) => {
return {
...form,
contractNo: '',
applyDate: '',
archiveStatus: '未归档',
feeGenerationMode: detail.feeGenerationMode || '',
billingEnabled: detail.billingEnabled,
@@ -1243,13 +1298,12 @@ export default {
signType: [{ required: true, message: '请选择签约类型', trigger: 'change' }],
settlementCurrency: [{ required: true, message: '请选择结算币种', trigger: 'change' }],
projectName: [{ required: true, message: '请选择所属项目', trigger: 'change' }],
organizationName: [{ required: true, message: '请选择所属组织', trigger: 'change' }],
organizationName: [{ required: true, message: '请选择合同负责部门', trigger: 'change' }],
partyA: [{ required: true, message: '请选择甲方', trigger: 'change' }],
partyB: [{ required: true, message: '请选择乙方', trigger: 'change' }],
signDate: [{ required: true, message: '请选择签订日期', trigger: 'change' }],
settlementMode: [{ required: true, message: '请选择结算方式', trigger: 'change' }],
contractFormat: [{ required: true, message: '请选择合同格式', trigger: 'change' }],
legalSealFlag: [{ required: true, message: '请选择是否需要加盖法人章', trigger: 'change' }],
invoiceCycle: [
{
validator: (rule, value, callback) => {
@@ -1264,6 +1318,22 @@ export default {
],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }],
contractAmount: [
{ required: true, message: '请输入合同金额', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
return callback(new Error('请输入合同金额'));
}
if (Number(value) < 0) {
return callback(new Error('合同金额不能小于0'));
}
callback();
},
trigger: 'blur',
},
],
mainContent: [{ max: 1000, message: '合同主要内容不能超过1000个字符', trigger: 'blur' }],
},
submitAction: '',
projectOptions: [],
@@ -1335,6 +1405,7 @@ export default {
flowBox: false,
flowUrl: '',
processInstanceId: '',
mkEnabled: false,
// 页面形态在实例创建时锁定,避免 keep-alive 缓存后随全局 $route 翻转
formPageLocked: false,
detailPageLocked: false,
@@ -1377,8 +1448,8 @@ export default {
return this.$route.query.attachmentUpload === '1';
},
contractAttachmentLocationOptions() {
if (this.isAttachmentUploadMode) return ['合同文件'];
return ['合同文件', '其它附件'];
if (this.isAttachmentUploadMode) return ['用印材料'];
return ['用印材料', '附件(佐证材料等)'];
},
contractAmountDisplay() {
const value = this.form.contractAmount;
@@ -1445,7 +1516,7 @@ export default {
expandTrigger: 'click',
};
},
// 所属组织级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径,
// 合同负责部门级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径,
// 两者在此双向转换;依赖 organizationOptions,树加载完成后回显自动刷新
organizationCascaderValue: {
get() {
@@ -1580,6 +1651,13 @@ export default {
this.loadProjectOptions();
this.loadSettlementDictionaries();
this.loadOrganizationOptions();
loadMkEnabled()
.then(enabled => {
this.mkEnabled = !!enabled;
})
.catch(() => {
this.mkEnabled = false;
});
}
if (this.isFormPage) this.initFormPage();
if (this.isDetailPage) this.initDetailPage();
@@ -1828,7 +1906,7 @@ export default {
copyCount: normalizeOptionalInteger(detail.copyCount, ''),
invoiceCycle: normalizeOptionalInteger(detail.invoiceCycle, ''),
paymentDays: normalizeOptionalInteger(detail.paymentDays, ''),
contractAmount: normalizeOptionalAmount(detail.contractAmount, null),
contractAmount: formatContractAmountInput(detail.contractAmount, null),
archiveStatus: detail.archiveStatus || '未归档',
feeGenerationMode:
detail.feeGenerationMode || (Number(detail.billingEnabled) === 0 ? 'manual' : 'system'),
@@ -1864,6 +1942,7 @@ export default {
this.applyDetail(createCopiedDetail(detail));
this.form.handlerUserName =
this.userInfo?.realName || this.userInfo?.userName || this.userInfo?.account || '';
this.refreshPreviewContractNo();
this.$nextTick(() => this.$refs.contractForm?.clearValidate());
},
closeForm() {
@@ -1878,9 +1957,10 @@ export default {
['contractName', '合同名称'],
['contractCategory', '合同类型'],
['projectName', '所属项目'],
['organizationName', '所属组织'],
['organizationName', '合同负责部门'],
['partyA', '甲方'],
['partyB', '乙方'],
['contractAmount', '合同金额'],
];
const empty = required.find(([prop]) => {
const value = this.form[prop];
@@ -1888,10 +1968,16 @@ export default {
});
if (empty) {
this.$message.warning(
empty[0] === 'contractName' ? `请输入${empty[1]}` : `请选择${empty[1]}`
['contractName', 'contractAmount'].includes(empty[0])
? `请输入${empty[1]}`
: `请选择${empty[1]}`
);
return false;
}
if (Number(this.form.contractAmount) < 0) {
this.$message.warning('合同金额不能小于0');
return false;
}
return true;
}
if (!this.form.contractName?.trim()) {
@@ -1914,7 +2000,7 @@ export default {
if (action !== 'formal' && action !== 'edit') return true;
const required = [
['projectName', '所属项目'],
['organizationName', '所属组织'],
['organizationName', '合同负责部门'],
['partyA', '甲方'],
['partyB', '乙方'],
['startDate', '合同开始日期'],
@@ -1922,10 +2008,20 @@ export default {
['signDate', '签订日期'],
['settlementMode', '结算方式'],
['contractFormat', '合同格式'],
['contractAmount', '合同金额'],
];
const empty = required.find(([prop]) => !this.form[prop]);
const empty = required.find(([prop]) => {
const value = this.form[prop];
return value === undefined || value === null || String(value).trim() === '';
});
if (empty) {
this.$message.warning(`请选择或输入${empty[1]}`);
this.$message.warning(
empty[0] === 'contractAmount' ? `请输入${empty[1]}` : `请选择或输入${empty[1]}`
);
return false;
}
if (Number(this.form.contractAmount) < 0) {
this.$message.warning('合同金额不能小于0');
return false;
}
return (
@@ -2115,11 +2211,11 @@ export default {
},
handleListAttachmentUploadToLocation({ location, rows = [] }) {
if (!rows.length) return;
if (location === '合同文件') {
if (isContractSealMaterialLocation(location)) {
this.listUploadContractFileRows = [...(this.listUploadContractFileRows || []), ...rows];
return;
}
if (location === '其它附件') {
if (isContractSupportingAttachmentLocation(location)) {
this.listUploadAttachmentRows = [...(this.listUploadAttachmentRows || []), ...rows];
}
},
@@ -2277,6 +2373,9 @@ export default {
column.renderSearch = scope => this.renderOrganizationSearch(scope);
}
this.syncCurrentOrganizationOption();
if (this.isFormPage && this.formMode === 'add' && this.form.organizationId) {
this.refreshPreviewContractNo();
}
})
.finally(() => {
this.organizationLoading = false;
@@ -2339,6 +2438,29 @@ export default {
}
: { organizationId: '', organizationName: '' }
);
if (this.formMode === 'add') {
this.refreshPreviewContractNo();
}
},
refreshPreviewContractNo() {
if (this.formMode !== 'add') return;
const organizationId = this.form.organizationId;
if (!organizationId) {
this.form.contractNo = '';
return;
}
const requestId = (this.contractNoPreviewRequestId =
(this.contractNoPreviewRequestId || 0) + 1);
api
.nextContractNo(organizationId)
.then(res => {
if (requestId !== this.contractNoPreviewRequestId) return;
this.form.contractNo = res.data?.data || '';
})
.catch(() => {
if (requestId !== this.contractNoPreviewRequestId) return;
this.form.contractNo = '';
});
},
syncCurrentOrganizationOption() {
if (!this.form.organizationId || !this.form.organizationName) return;
@@ -2352,7 +2474,7 @@ export default {
this.organizationFlatOptions.unshift(item);
},
renderOrganizationSearch(scope) {
// 搜索区所属组织:单选级联(与新增/编辑表单一致)
// 搜索区合同负责部门:单选级联(与新增/编辑表单一致)
// 级联返回 id 路径数组,提交时由 normalizeSearch 取末级 id 转组织名称
return h(ElCascader, {
modelValue: Array.isArray(scope.row?.organizationName) ? scope.row.organizationName : [],
@@ -2371,7 +2493,7 @@ export default {
filterable: true,
clearable: true,
style: 'width: 100%',
placeholder: '请选择 所属组织',
placeholder: '请选择 合同负责部门',
});
},
integerInput(prop, value) {
@@ -2384,6 +2506,9 @@ export default {
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.form.contractAmount = normalized === '' ? null : normalized;
},
formatContractAmountField() {
this.form.contractAmount = formatContractAmountInput(this.form.contractAmount, null);
},
positiveIntegerInput(prop, value) {
const normalized = String(value || '').replace(/\D/g, '').replace(/^0+/, '');
this.form[prop] = normalized;
@@ -2416,11 +2541,11 @@ export default {
},
handleAttachmentUploadToLocation({ location, rows = [] }) {
if (!rows.length) return;
if (location === '合同文件') {
if (isContractSealMaterialLocation(location)) {
this.contractFileRows = [...(this.contractFileRows || []), ...rows];
return;
}
if (location === '其它附件') {
if (isContractSupportingAttachmentLocation(location)) {
this.attachmentRows = [...(this.attachmentRows || []), ...rows];
}
},
@@ -2781,15 +2906,16 @@ export default {
templateFlag: '是否范本',
originalContractNo: '原件合同编号',
electronicSealFlag: '是否电子章',
remark: '备注',
mainContent: '合同主要内容',
remark: '其他事项说明',
feeGenerationMode: '费用生成模式',
billingPlanJson: '计费方案',
settlementRuleJson: '结算单规则',
preSettlementConfigJson: '预结算配置',
formalSettlementConfigJson: '正式结算配置',
paymentRatioJson: '付款比例设置',
contractFileJson: '合同文件',
attachmentsJson: '其它附件',
contractFileJson: '用印材料',
attachmentsJson: '附件(佐证材料等)',
changeAttachmentsJson: '变更材料',
};
return labels[field] || field;
@@ -2956,13 +3082,8 @@ export default {
detailValue(prop) {
const value = this.detailRow[prop];
if (prop === 'contractAmount') {
return value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
? '-'
: value;
const amountText = formatContractAmountInput(value, '');
return amountText === '' ? '-' : amountText;
}
if (
prop === 'contractStage' ||
@@ -2994,6 +3115,10 @@ export default {
return;
}
if (operation.action === 'flow') {
if (this.mkEnabled) {
this.handleMkAudit(row);
return;
}
this.openFlow(row);
return;
}
@@ -3047,6 +3172,24 @@ export default {
query: { mode: 'add', copyId: row.id, name: '新增合同管理' },
});
},
handleMkAudit(row) {
if (!row?.id) {
this.$message.warning('合同ID为空,无法打开审核页');
return;
}
getMKApprovalUrl({ bizId: String(row.id) })
.then(res => {
const url = res?.data?.data;
if (!url) {
this.$message.error('获取审核跳转地址失败');
return;
}
window.open(url, '_blank');
})
.catch(() => {
this.$message.error('获取审核跳转地址失败');
});
},
openFlow(row, loaded = false) {
const processInstanceId = row.processInstanceId || row.processInstanceID || row.procInstId;
if (!processInstanceId && !loaded && row.id) {
@@ -3276,7 +3419,11 @@ export default {
&__remark {
width: 100%;
margin-bottom: 0 !important;
margin-bottom: 24px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
&__date-range {
@@ -3350,7 +3497,7 @@ export default {
:deep(.el-form-item) {
align-items: center;
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.el-form-item__label) {
+263 -92
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="loading-manage-page">
<div v-if="!isStandalonePage" class="loading-manage-page__search">
<div v-if="!isStandalonePage && searchVisible" class="loading-manage-page__search">
<el-form :model="searchForm" label-position="right" label-width="88px">
<div class="loading-manage-page__search-grid">
<el-form-item label="配载单号">
@@ -228,17 +228,13 @@
<el-button type="primary" plain @click="handleExport">批量导出</el-button>
<el-button type="primary" plain @click="handleBatchComplete">批量完成</el-button>
</div>
<div class="loading-manage-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
<el-tooltip content="表格密度" placement="top">
<el-button :icon="Rank" text @click="toggleTableSize" />
</el-tooltip>
<el-tooltip content="列设置" placement="top">
<el-button :icon="Setting" text @click="handleColumnSetting" />
</el-tooltip>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
@@ -254,7 +250,12 @@
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="58" fixed="left" align="center" />
<el-table-column label="配载单号" prop="loadingNo" min-width="170">
<el-table-column
v-if="isColumnVisible('loadingNo')"
label="配载单号"
prop="loadingNo"
min-width="170"
>
<template #default="{ row }">
<el-link
type="primary"
@@ -264,7 +265,12 @@
>
</template>
</el-table-column>
<el-table-column label="运单号" prop="loadingSubNos" min-width="220">
<el-table-column
v-if="isColumnVisible('loadingSubNos')"
label="运单号"
prop="loadingSubNos"
min-width="220"
>
<template #default="{ row }">
<span class="loading-manage-page__sub-nos">
<template v-if="splitText(row.loadingSubNos).length">
@@ -283,11 +289,40 @@
</span>
</template>
</el-table-column>
<el-table-column label="车牌号" prop="vehicleNo" min-width="170" show-overflow-tooltip />
<el-table-column label="司机" prop="driverName" min-width="120" show-overflow-tooltip />
<el-table-column label="联系电话" prop="driverPhone" min-width="140" show-overflow-tooltip />
<el-table-column label="承运类型" prop="carrierType" min-width="120" show-overflow-tooltip />
<el-table-column label="发货地" prop="departureAddress" min-width="180">
<el-table-column
v-if="isColumnVisible('vehicleNo')"
label="车牌号"
prop="vehicleNo"
min-width="170"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverName')"
label="司机"
prop="driverName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverPhone')"
label="联系电话"
prop="driverPhone"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('carrierType')"
label="承运类型"
prop="carrierType"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('departureAddress')"
label="发货地"
prop="departureAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'departure')"
@@ -304,7 +339,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="途经地" prop="transitAddress" min-width="190">
<el-table-column
v-if="isColumnVisible('transitAddress')"
label="途经地"
prop="transitAddress"
min-width="190"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'transit')"
@@ -321,7 +361,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="到货地" prop="arrivalAddress" min-width="180">
<el-table-column
v-if="isColumnVisible('arrivalAddress')"
label="到货地"
prop="arrivalAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'arrival')"
@@ -338,20 +383,40 @@
</div>
</template>
</el-table-column>
<el-table-column label="承运商" prop="carrierName" min-width="160" show-overflow-tooltip />
<el-table-column label="运输方式" prop="transportType" min-width="150" show-overflow-tooltip>
<el-table-column
v-if="isColumnVisible('carrierName')"
label="承运商"
prop="carrierName"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('transportType')"
label="运输方式"
prop="transportType"
min-width="150"
show-overflow-tooltip
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip />
<el-table-column
v-if="isColumnVisible('dataSource')"
label="数据来源"
prop="dataSource"
min-width="130"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
label="创建人"
prop="createUserName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createTime')"
label="创建时间"
prop="createTime"
min-width="170"
@@ -359,6 +424,7 @@
sortable
/>
<el-table-column
v-if="isColumnVisible('updateTime')"
label="更新时间"
prop="updateTime"
min-width="170"
@@ -366,12 +432,20 @@
sortable
/>
<el-table-column
v-if="isColumnVisible('currentProcessNode')"
label="过程节点"
prop="currentProcessNode"
min-width="130"
show-overflow-tooltip
/>
<el-table-column label="状态" prop="businessStatus" width="110" fixed="right" align="center">
<el-table-column
v-if="isColumnVisible('businessStatus')"
label="状态"
prop="businessStatus"
width="110"
fixed="right"
align="center"
>
<template #default="{ row }">
<span :class="['loading-manage-page__status', `is-${row.businessStatus || 'unknown'}`]">
{{ statusText(row) }}
@@ -484,12 +558,16 @@
<div class="loading-detail__field loading-detail__attachment-field">
<span class="loading-detail__label">附件</span>
<span class="loading-detail__value"
><el-link
><span
v-for="file in detailAttachments"
:key="file.url || file.name"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ file.name }}</el-link
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.name }}</span
><span v-if="!detailAttachments.length">-</span></span
>
</div>
@@ -555,15 +633,12 @@
prop="packageType"
label="包装"
min-width="100" /><el-table-column
prop="weight"
label="重量(吨)"
min-width="110" /><el-table-column
prop="volume"
label="体积(方)"
min-width="110" /><el-table-column
prop="quantity"
label="数量"
min-width="90" /><el-table-column
prop="quantityUnit"
label="数量单位"
min-width="100" /><el-table-column
prop="materialCode"
label="物料编码"
min-width="120" /><el-table-column
@@ -665,13 +740,6 @@
record.statusName || (record.punched ? '已打卡' : '未打卡')
}}</el-tag
>
<el-tag
v-if="record.type === 'enroute'"
size="small"
type="info"
effect="plain"
>在途</el-tag
>
<el-tag
v-if="record.exceptionFlag"
size="small"
@@ -701,22 +769,12 @@
>
备注:{{ record.remark }}
</div>
<div
<punch-record-photos
v-if="record.photos && record.photos.length"
class="loading-detail__punch-record-photos"
>
<div
v-for="(photo, pi) in record.photos"
:key="`${record.nodeCode || index}-${pi}`"
class="loading-detail__driver-upload-item"
@click="previewDriverUploadPhoto(record.photos, pi)"
>
<el-image :src="photo.url" fit="cover" lazy />
<div class="loading-detail__driver-upload-label">
{{ photo.label || `${record.nodeName}-凭证` }}
</div>
</div>
</div>
:photos="record.photos"
lazy
@preview="previewDriverUploadPhoto(record.photos, $event)"
/>
</template>
<div v-else class="loading-detail__punch-record-line">暂未打卡</div>
</div>
@@ -1066,6 +1124,7 @@
label="运单号"
min-width="150"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link v-if="row.waybillNo" type="primary" @click="openWaybillDetail(row)">
@@ -1357,7 +1416,7 @@
<el-table-column prop="cargoType" label="货物类型" min-width="140" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="quantity" label="数量" min-width="100" />
<el-table-column prop="quantityUnit" label="单位" min-width="100" />
<el-table-column prop="quantityUnit" label="数量单位" min-width="100" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="120" />
@@ -1400,7 +1459,11 @@
<el-option
v-for="item in taskCarrierOptions"
:key="item.id || item.carrierName"
:label="item.carrierName || item.partyB"
:label="
dialogForm.carrierType === '承运商'
? formatTaskCarrierContractLabel(item)
: item.carrierName || item.partyB
"
:value="
dialogForm.carrierType === '承运商'
? item.id
@@ -1620,10 +1683,55 @@
><el-table-column prop="changeTime" label="变更时间" min-width="170" /><el-table-column
prop="changeUser"
label="变更人"
min-width="120" /><el-table-column prop="content" label="变更内容" min-width="360"
min-width="120" /><el-table-column
prop="content"
label="变更内容"
min-width="360"
:show-overflow-tooltip="false"
><template #default="{ row }"
><div class="loading-route-change-dialog__record-content">
{{ row.content || '-' }}
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="loading-route-change-dialog__change-group"
>
<div
v-if="group.title"
class="loading-route-change-dialog__change-group-title"
>
{{ group.title }}
</div>
<div v-if="group.text" class="loading-route-change-dialog__change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="loading-route-change-dialog__change-field"
>
<span class="loading-route-change-dialog__change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="loading-route-change-dialog__change-before">
{{ field.before }}
</span>
<span class="loading-route-change-dialog__change-arrow">→</span>
<span class="loading-route-change-dialog__change-after">
{{ field.after }}
</span>
</template>
<span v-else class="loading-route-change-dialog__change-single">
{{ field.single }}
<span
v-if="field.unchanged"
class="loading-route-change-dialog__change-flag"
>
未变更
</span>
</span>
</div>
</div>
</div></template
></el-table-column
><el-table-column
@@ -1716,12 +1824,14 @@ import { getParentOptions as getCargoTypeOptions } from '@/api/base/cargo-type';
import { getList as getCustomerList } from '@/api/vehicle/customer-archive';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { fetchDriversByCarrierOrganizations } from '@/utils/carrier-org-resource';
import { formatContractOptionLabel } from '@/utils/contract';
import {
getList as getProcessConfigList,
getDetail as getProcessConfigDetail,
getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config';
import VoucherExecutionDetail from './components/voucher-execution-detail.vue';
import PunchRecordPhotos from './components/punch-record-photos.vue';
import {
carrierTypeOptions,
loadingManageConfig,
@@ -1730,10 +1840,12 @@ import {
} from '@/option/business/loading-manage';
import { option as waybillOption } from '@/option/business/waybill-manage';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Location, Rank, Refresh, Search, Setting } from '@element-plus/icons-vue';
import { Location, Rank, Search } from '@element-plus/icons-vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import dayjs from 'dayjs';
import { isMobile } from '@/utils/validate';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { parseRouteChangeContent } from '@/utils/route-change';
const defaultTransportType = '';
const defaultCandidateTransportType = '';
@@ -1927,21 +2039,44 @@ const uniqueText = (rows, prop) =>
),
].join('、');
const LOADING_TABLE_COLUMNS = [
{ prop: 'loadingNo', label: '配载单号' },
{ prop: 'loadingSubNos', label: '运单号' },
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'driverName', label: '司机' },
{ prop: 'driverPhone', label: '联系电话' },
{ prop: 'carrierType', label: '承运类型' },
{ prop: 'departureAddress', label: '发货地' },
{ prop: 'transitAddress', label: '途经地' },
{ prop: 'arrivalAddress', label: '到货地' },
{ prop: 'carrierName', label: '承运商' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'currentProcessNode', label: '过程节点' },
{ prop: 'businessStatus', label: '状态' },
];
export default {
components: {
Rank,
Search,
TableHeaderTools,
VoucherExecutionDetail,
PunchRecordPhotos,
},
data() {
return {
Rank,
Location,
Refresh,
Search,
Setting,
loading: false,
tableSize: 'default',
searchVisible: true,
columnSettingOptions: LOADING_TABLE_COLUMNS,
visibleColumnKeys: LOADING_TABLE_COLUMNS.map(column => column.prop),
tableData: [],
page: {
currentPage: 1,
@@ -2189,6 +2324,15 @@ export default {
},
},
methods: {
parseRouteChangeContent,
formatTaskCarrierContractLabel(item = {}) {
const carrierName = item.carrierName || item.partyB || '';
const contractLabel = formatContractOptionLabel(item);
if (carrierName && contractLabel) {
return `${carrierName}(${contractLabel})`;
}
return carrierName || contractLabel;
},
syncStandaloneTagTitle() {
if (!this.isStandalonePage) return;
const title = this.isStandaloneDetailPage ? this.detailPageTitle : this.formPageDefaultTitle;
@@ -2248,13 +2392,8 @@ export default {
selectionChange(rows) {
this.selectionRows = rows || [];
},
toggleTableSize() {
const order = ['default', 'small', 'large'];
const index = order.indexOf(this.tableSize);
this.tableSize = order[(index + 1) % order.length];
},
handleColumnSetting() {
ElMessage.info('列设置按原型保留入口,当前默认展示全部列表字段');
isColumnVisible(prop) {
return this.visibleColumnKeys.includes(prop);
},
statusText(row) {
return row.businessStatusName || loadingStatusMap[row.businessStatus] || '未知';
@@ -2757,7 +2896,6 @@ export default {
if (status === 'draft') {
return [
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'delete', label: '删除' },
];
}
@@ -2769,36 +2907,24 @@ export default {
if (row.driverRejected) {
actions.push({ type: 'reassign', label: '重新派单' });
}
actions.push(
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'cancel', label: '取消' }
);
actions.push({ type: 'edit', label: '编辑' }, { type: 'cancel', label: '取消' });
return actions;
}
if (status === 'running') {
return [
{ type: 'view', label: '详情' },
{ type: 'complete', label: '完成' },
{ type: 'copy', label: '复制' },
];
}
if (status === 'completed' || status === 'cancelled') {
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
return [{ type: 'view', label: '详情' }];
}
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
return [{ type: 'view', label: '详情' }];
},
handleRowAction(type, row) {
const actionMap = {
view: () => this.gotoLoadingDetail(row),
edit: () => this.openLoadingDialog('edit', row),
copy: () => this.gotoLoadingCopy(row),
reassign: () => this.gotoLoadingReassign(row),
delete: () => this.deleteLoading(row),
start: () => this.startLoading(row),
@@ -4473,8 +4599,7 @@ export default {
margin: 12px 0;
}
.loading-manage-page__toolbar-left,
.loading-manage-page__toolbar-right {
.loading-manage-page__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
@@ -4733,7 +4858,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.el-form-item__label) {
@@ -4928,7 +5053,6 @@ export default {
margin-left: 12px;
}
}
.loading-detail__punch-record-photos,
.loading-detail__driver-upload-grid,
.loading-detail__voucher-image-grid {
display: grid;
@@ -5052,9 +5176,56 @@ export default {
gap: 4px;
}
.loading-route-change-dialog__record-content {
white-space: normal;
overflow-wrap: anywhere;
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
.loading-route-change-dialog__change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
.loading-route-change-dialog__change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
.loading-route-change-dialog__change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
.loading-route-change-dialog__change-field-label {
color: #909399;
white-space: nowrap;
}
.loading-route-change-dialog__change-before,
.loading-route-change-dialog__change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-arrow {
color: #c0c4cc;
text-align: right;
}
.loading-route-change-dialog__change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
}
.loading-route-change-map-dialog {
+24 -4
View File
@@ -1,7 +1,7 @@
<template>
<basic-container class="master-order-page">
<template v-if="mode === 'list'">
<div class="master-order-page__search">
<div v-show="searchVisible" class="master-order-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="master-order-page__search-grid">
<el-form-item v-for="field in primaryFields" :key="field.prop" :label="field.label">
@@ -55,8 +55,16 @@
</div>
<section class="master-list-panel">
<div class="toolbar">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button
><el-button type="primary" plain @click="download">批量导出</el-button>
<div class="toolbar-left">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button>
<el-button type="primary" plain @click="download">批量导出</el-button>
</div>
<table-header-tools
:show-column="false"
:show-grid="false"
v-model:search-visible="searchVisible"
@refresh="load"
/>
</div>
<div v-loading="loading" class="master-cards">
<article v-for="row in records" :key="row.id" class="master-card">
@@ -147,12 +155,13 @@
<script>
import * as api from '@/api/business/master-order';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import MasterOrderEditor from './components/master-order-editor.vue';
import MasterOrderDispatch from './components/master-order-dispatch.vue';
import MasterOrderDetail from './components/master-order-detail.vue';
export default {
components: { MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
components: { TableHeaderTools, MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
data() {
return {
// 实例创建时锁定所属路由。keep-alive 缓存后 $route 会变成其它页面,
@@ -160,6 +169,7 @@ export default {
//(mode=add 且无 id)会落入详情分支并请求缺少 id 的详情接口。
routePathLocked: this.$route.path,
searchExpanded: false,
searchVisible: true,
loading: false,
records: [],
query: { businessStatus: '' },
@@ -633,8 +643,18 @@ export default {
}
.master-list-panel {
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
}
.master-card {
display: flex;
+57 -21
View File
@@ -429,7 +429,7 @@ const NODE_TEMPLATES = [
type: 'confirm',
supportVoucher: false,
},
{ key: 'arrive_scene', name: '到场', type: 'punch', enabled: false },
{ key: 'arrive_scene', name: '到场', type: 'punch', enabled: false, supportCargo: false },
{
key: 'load',
name: '装货',
@@ -485,6 +485,7 @@ const NODE_TEMPLATES = [
key: 'return',
name: '回单',
type: 'confirm',
supportCargo: false,
supportVoucher: true,
voucherOptions: ['磅单', '签收单'],
},
@@ -565,7 +566,13 @@ export default {
});
},
defaultDeptId() {
return (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return (
String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || ''
);
},
applyDefaultDeptSearch() {
const deptId = this.defaultDeptId();
@@ -580,15 +587,32 @@ export default {
});
},
flattenDept(tree, level = 0) {
return tree.flatMap(item => [
{
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
},
...(item.children && item.children.length
? this.flattenDept(item.children, level + 1)
: []),
]);
return tree.flatMap(item => {
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return [];
return [
{
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: id,
},
...(item.children && item.children.length
? this.flattenDept(item.children, level + 1)
: []),
];
});
},
normalizeListQuery(params = {}) {
const query = { ...params, ...this.query };
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;
},
displayStatus(status) {
const statusMap = {
@@ -631,7 +655,9 @@ export default {
enabled: false,
punch: true,
location: true,
uploadCargo: true,
// 不支持货量的节点(到场/回单/接单等)不得默认勾上,否则会写出
// 「上传货量为是、货量类型为空」的配置,而这类节点在界面上没有货量开关可关
uploadCargo: !!template?.supportCargo,
cargoTypes: [],
uploadVoucher: undefined,
voucherTypes: [],
@@ -646,6 +672,10 @@ export default {
confirmInternal: false,
...template,
};
if (!node.supportCargo) {
node.uploadCargo = false;
node.cargoTypes = [];
}
if ((node.key === 'accept' || node.name === '接单') && node.confirmMode === 'yes') {
node.confirmDriver = true;
} else if ((node.key === 'accept' || node.name === '接单') && node.confirmMode !== 'yes') {
@@ -918,6 +948,15 @@ export default {
return false;
}
}
// 勾了上传货量却没选类型,在小程序侧等同于不采集货量;此处直接拦住,
// 避免保存出「显示采集、实际不采集」的配置
const cargoWithoutTypes = this.nodeRows.find(
item => item.enabled && item.supportCargo && item.uploadCargo && !(item.cargoTypes || []).length
);
if (cargoWithoutTypes) {
this.$message.warning(`「${cargoWithoutTypes.name}」已选择上传货量,请至少勾选一个货量类型`);
return false;
}
return true;
},
needsDuplicateVoucherConfirm() {
@@ -1074,9 +1113,9 @@ export default {
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -1099,12 +1138,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, {
...params,
...this.query,
allDept: this.isAdmin ? 1 : 0,
})
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data || {};
this.page.total = result.total || 0;
@@ -1117,8 +1154,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: this.isAdmin ? 1 : 0,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
+121 -34
View File
@@ -249,6 +249,9 @@
<el-select
v-model="form.undertakeDeptId"
placeholder="请选择"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:disabled="isBasicInfoReadonly"
@@ -591,7 +594,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -601,7 +604,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -611,7 +614,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -621,7 +624,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -663,11 +666,22 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240" align="center">
@@ -864,15 +878,15 @@
v-model="userQuery.realName"
placeholder="用户姓名"
clearable
@keyup.enter="loadUsers"
@keyup.enter="searchUsers"
/>
<el-input
v-model="userQuery.phone"
placeholder="手机号"
clearable
@keyup.enter="loadUsers"
@keyup.enter="searchUsers"
/>
<el-button type="primary" @click="loadUsers">查询</el-button>
<el-button type="primary" @click="searchUsers">查询</el-button>
<el-button @click="resetUserQuery">重置</el-button>
</div>
<el-table :data="userData" border v-loading="userLoading" @row-dblclick="selectUser">
@@ -964,7 +978,7 @@ const emptyForm = () => ({
projectShortName: '',
businessDeptId: '',
businessDeptName: '',
undertakeDeptId: '',
undertakeDeptId: [],
undertakeDeptName: '',
projectSource: '',
sourceRemark: '',
@@ -1156,7 +1170,15 @@ export default {
{ max: 20, message: '项目简称不能超过20个字符', trigger: 'blur' },
],
businessDeptId: [{ required: true, message: '请选择业务部门', trigger: 'change' }],
undertakeDeptId: [{ required: true, message: '请选择平台公司', trigger: 'change' }],
undertakeDeptId: [
{
type: 'array',
required: true,
min: 1,
message: '请选择平台公司',
trigger: 'change',
},
],
businessMode: [{ required: true, message: '请选择业务模式', trigger: 'change' }],
customerNames: [{ required: true, message: '请选择客户名称', trigger: 'change' }],
carrierNames: [{ required: true, message: '请选择下游承运商', trigger: 'change' }],
@@ -1209,6 +1231,18 @@ export default {
changeReason: [
{ max: 2000, message: '变更原因不能超过2000个字符', trigger: 'blur' },
],
projectIntro: [
{ max: 1000, message: '项目简介不能超过1000个字符', trigger: 'blur' },
],
profitRemark: [
{ max: 1000, message: '盈利模式及利润测算情况说明不能超过1000个字符', trigger: 'blur' },
],
riskPoint: [
{ max: 1000, message: '项目风险点不能超过1000个字符', trigger: 'blur' },
],
emergencyPlan: [
{ max: 1000, message: '风险防范措施及应急预案不能超过1000个字符', trigger: 'blur' },
],
},
deptOptions: [],
businessDeptTreeOptions: [],
@@ -1506,13 +1540,13 @@ export default {
this.businessDeptTreeOptions = [{ label, value: detail.businessDeptId }];
this.deptOptions = [{ label, rawLabel: label, value: detail.businessDeptId }];
}
if (detail.undertakeDeptId) {
this.platformCompanyOptions = [
{
label: detail.undertakeDeptName || String(detail.undertakeDeptId),
value: detail.undertakeDeptId,
},
];
const undertakeDeptIds = this.parseUndertakeDeptIds(detail.undertakeDeptId);
const undertakeDeptNames = this.parseUndertakeDeptNames(detail.undertakeDeptName);
if (undertakeDeptIds.length) {
this.platformCompanyOptions = undertakeDeptIds.map((id, index) => ({
label: undertakeDeptNames[index] || String(id),
value: id,
}));
}
},
resolvePublicDictOptions(options, currentValue) {
@@ -1897,6 +1931,8 @@ export default {
this.form = {
...form,
...situation,
undertakeDeptId: this.parseUndertakeDeptIds(form.undertakeDeptId),
undertakeDeptName: form.undertakeDeptName || '',
customerNames: this.getCustomerRowNames(customerRows, 'customer') || form.customerNames,
carrierNames: this.getCustomerRowNames(carrierRows, 'carrier') || form.carrierNames,
};
@@ -2063,8 +2099,13 @@ export default {
});
},
normalizeSubmitForm(options = {}) {
const undertakeDeptIds = this.parseUndertakeDeptIds(this.form.undertakeDeptId);
const undertakeDeptName =
this.resolveUndertakeDeptNames(undertakeDeptIds) || this.form.undertakeDeptName || '';
const submitRow = {
...this.form,
undertakeDeptId: undertakeDeptIds.join(','),
undertakeDeptName,
fundLimit: this.formatAmount(this.form.fundLimit),
receivableLimit: this.formatAmount(this.form.receivableLimit),
...Object.fromEntries(
@@ -2456,11 +2497,40 @@ export default {
this.$refs.projectForm?.validateField('businessDeptId');
},
handleUndertakeDeptChange(value) {
const id = Array.isArray(value) ? value.at(-1) : value;
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
this.form.undertakeDeptName = dept ? dept.label : '';
const ids = this.parseUndertakeDeptIds(value);
this.form.undertakeDeptId = ids;
this.form.undertakeDeptName = this.resolveUndertakeDeptNames(ids);
this.$refs.projectForm?.validateField('undertakeDeptId');
},
parseUndertakeDeptIds(value) {
if (Array.isArray(value)) {
return value
.map(item => String(item ?? '').trim())
.filter(item => item && item !== '-1');
}
return String(value ?? '')
.split(/[,,]/)
.map(item => item.trim())
.filter(item => item && item !== '-1');
},
parseUndertakeDeptNames(value) {
return String(value ?? '')
.split(/[,,、]/)
.map(item => item.trim())
.filter(Boolean);
},
resolveUndertakeDeptNames(ids = []) {
return ids
.map(id => {
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
return dept?.label || '';
})
.filter(Boolean)
.join('、');
},
firstUndertakeDeptId(value) {
return this.parseUndertakeDeptIds(value)[0] || '';
},
// 由部门 id 反查「根 → 该节点」的 id 路径,供级联回显(找不到返回 null)
findDeptPath(id, tree = [], parents = []) {
const target = Array.isArray(id) ? id.at(-1) : id;
@@ -2499,15 +2569,23 @@ export default {
});
},
ensureCurrentPlatformOption() {
const id = this.form.undertakeDeptId;
if (!id) return;
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: this.form.undertakeDeptName || String(id),
value: id,
});
}
const ids = this.parseUndertakeDeptIds(this.form.undertakeDeptId);
if (!ids.length) return;
const names = this.parseUndertakeDeptNames(this.form.undertakeDeptName);
ids.forEach((id, index) => {
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: names[index] || String(id),
value: id,
});
}
});
// 选项 value 与已选 ID 类型对齐,避免回显失败
this.form.undertakeDeptId = ids.map(id => {
const matched = this.platformCompanyOptions.find(item => String(item.value) === String(id));
return matched ? matched.value : id;
});
},
excludeExternalOrganization(list = []) {
return list.reduce((result, item) => {
@@ -2816,11 +2894,20 @@ export default {
if (this.dialogReadonly) return;
this.userPickType = type;
this.userBox = true;
this.userPage.currentPage = 1;
this.loadUsers();
},
searchUsers() {
this.userPage.currentPage = 1;
this.loadUsers();
},
loadUsers() {
this.userLoading = true;
getUserList(this.userPage.currentPage, this.userPage.pageSize, this.userQuery)
const params = {
realName: String(this.userQuery.realName || '').trim(),
phone: String(this.userQuery.phone || '').trim(),
};
getUserList(this.userPage.currentPage, this.userPage.pageSize, params)
.then(res => {
const result = res.data.data || {};
this.userData = result.records || [];
@@ -2870,7 +2957,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 14px;
//margin-bottom: 24px;
}
:deep(.el-form-item__label) {
+50 -10
View File
@@ -99,11 +99,21 @@
<el-table :data="attachmentRows" border @selection-change="selectedAttachments = $event">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名称" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -184,6 +194,7 @@
title="查看临时额度申请"
append-to-body
destroy-on-close
align-center
:width="formDialogWidth"
class="temporary-credit-limit-dialog temporary-credit-limit-detail-dialog"
>
@@ -216,11 +227,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名称" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" show-overflow-tooltip>
@@ -615,6 +636,12 @@ export default {
}
const value = row[prop];
if (value === undefined || value === null || value === '') return '-';
if (
(prop === 'usedFundLimit' || prop === 'remainingFundLimit') &&
Number(value) === -1
) {
return '-';
}
return value;
},
applyDetail(detail) {
@@ -1188,10 +1215,10 @@ export default {
}
// 新增/编辑/查看:宽度与高度规则统一(Avue setPx 对 '1100px' 会拼成 '1100pxpx',故 option 用数字 1100)
// margin:auto 配合下方 unscoped 的 overlay flex,实现垂直水平居中
.temporary-credit-limit-dialog.el-dialog {
width: 1100px !important;
margin-top: 20px !important;
margin-bottom: 20px !important;
margin: auto !important;
height: auto !important;
max-height: calc(100vh - 40px) !important;
display: flex;
@@ -1329,3 +1356,16 @@ export default {
}
</style>
<!-- append-to-body 弹窗挂到 body,overlay 无 scoped 属性,需全局样式才能居中 -->
<style lang="scss">
.el-overlay-dialog:has(> .temporary-credit-limit-dialog) {
display: flex;
align-items: center;
justify-content: center;
}
.temporary-credit-limit-dialog.el-dialog {
margin: auto !important;
}
</style>
+51 -22
View File
@@ -585,6 +585,10 @@ export default {
this.$message.warning(`第${index + 1}条调度明细缺少车牌号`);
return;
}
if (!this.resolveDispatchQuantityUnit(row)) {
this.$message.warning(`第${index + 1}条调度明细缺少数量单位`);
return;
}
}
this.$confirm(
@@ -600,32 +604,57 @@ export default {
this.submitDispatch();
});
},
resolveDispatchQuantityUnit(row = {}) {
const topUnit = String(row.quantityUnit || '').trim();
if (topUnit) return topUnit;
const goodsList = this.parseGoodsList(row.goodsJson);
for (const goods of goodsList) {
const unit = String(
goods?.quantityUnit || goods?.goodsQuantityUnit || goods?.unit || ''
).trim();
if (unit) return unit;
}
return TRANSPORT_PLAN_QUANTITY_UNIT;
},
enrichDispatchGoodsJson(row = {}, quantityUnit) {
const goodsList = this.parseGoodsList(row.goodsJson);
if (goodsList.length) {
const nextGoods = goodsList.map(goods => ({
...goods,
quantityUnit: String(goods.quantityUnit || quantityUnit || '').trim() || quantityUnit,
}));
return JSON.stringify(nextGoods);
}
return JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
brand: row.brand,
},
]);
},
submitDispatch() {
this.saving = true;
const payload = {
id: this.planId,
mode: 'submit',
waybills: this.dispatchList.map(row => ({
...row,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: row.estimatedStartTime || this.planData.planStartDate || '',
estimatedEndTime: row.estimatedEndTime || this.planData.planEndDate || '',
goodsJson:
row.goodsJson ||
JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit: row.quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
brand: row.brand,
},
]),
})),
waybills: this.dispatchList.map(row => {
const quantityUnit = this.resolveDispatchQuantityUnit(row);
return {
...row,
quantityUnit,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: row.estimatedStartTime || this.planData.planStartDate || '',
estimatedEndTime: row.estimatedEndTime || this.planData.planEndDate || '',
goodsJson: this.enrichDispatchGoodsJson(row, quantityUnit),
};
}),
};
dispatch(payload)
@@ -639,7 +668,7 @@ export default {
})
.catch(err => {
console.error('调度失败:', err);
this.$message.error('调度失败');
this.$message.error(err?.response?.data?.msg || err?.message || '调度失败');
})
.finally(() => {
this.saving = false;
+4 -2
View File
@@ -51,7 +51,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -165,6 +165,7 @@ import { getList as getContractList } from '@/api/business/contract-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { importTransportPlan, validateTransportPlan } from '@/api/business/transport-plan';
import { config } from '@/option/business/transport-plan';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
import dayjs from 'dayjs';
@@ -271,6 +272,7 @@ export default {
this.loadProjectOptions();
},
methods: {
formatContractOptionLabel,
loadProjectOptions() {
if (this.projectLoading) return;
this.projectLoading = true;
@@ -651,7 +653,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 16px;
margin-bottom: 24px;
}
}
+18 -4
View File
@@ -96,11 +96,25 @@ const uploadReplaceFile = async file => {
if (!task?.id || !task.objectKey) throw new Error('创建分片上传任务失败');
replaceProgress.value = 0;
for (let partNumber = 1; partNumber <= task.chunkTotal; partNumber += 1) {
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
const part = file.slice((partNumber - 1) * chunkSize, Math.min(partNumber * chunkSize, file.size));
const response = await fetch(urlRes.data?.data, { method: 'PUT', body: part });
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
const url = urlRes.data?.data;
let etag = '';
const needProxy =
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
String(url || '')
.trim()
.toLowerCase()
.startsWith('http:');
if (!url || needProxy) {
const proxyRes = await api.uploadPartByProxy(task.id, partNumber, part);
etag = String(proxyRes.data?.data || '').replaceAll('"', '');
} else {
const response = await fetch(url, { method: 'PUT', body: part });
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
etag = String(response.headers.get('etag') || '').replaceAll('"', '');
}
if (!etag) throw new Error('未获取到分片 ETag');
await api.updateFileTask({ id: task.id, partNumber, etag });
replaceProgress.value = Math.round((partNumber / task.chunkTotal) * 100);
+139 -22
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="voucher-manage-page">
<section class="voucher-manage-page__search">
<section v-show="searchVisible" class="voucher-manage-page__search">
<el-form :model="query" label-position="right" label-width="88px">
<div class="voucher-manage-page__search-grid">
<el-form-item label="凭证批次号"
@@ -55,10 +55,25 @@
</section>
<section>
<div class="voucher-manage-page__toolbar">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button
><el-button plain @click="openProgress">上传进度</el-button>
<div class="voucher-manage-page__toolbar-left">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button>
<el-button plain @click="openProgress">上传进度</el-button>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="load"
/>
</div>
<el-table v-loading="loading" :data="rows" border class="voucher-manage-page__table">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
class="voucher-manage-page__table"
>
<el-table-column type="selection" width="52" fixed="left" align="center" /><el-table-column
type="index"
label="序号"
@@ -66,27 +81,47 @@
fixed="left"
align="center"
/>
<el-table-column prop="voucherBatchNo" label="凭证批次号" min-width="150" fixed="left"
<el-table-column
v-if="isColumnVisible('voucherBatchNo')"
prop="voucherBatchNo"
label="凭证批次号"
min-width="150"
fixed="left"
><template #default="{ row }"
><el-link class="voucher-manage-page__link" type="primary" @click="view(row)">{{
row.voucherBatchNo
}}</el-link></template
></el-table-column
>
<el-table-column prop="waybillBatchNo" label="运单批次号" min-width="170" /><el-table-column
<el-table-column
v-if="isColumnVisible('waybillBatchNo')"
prop="waybillBatchNo"
label="运单批次号"
min-width="170"
/><el-table-column
v-if="isColumnVisible('fileName')"
prop="fileName"
label="文件名"
min-width="200"
show-overflow-tooltip
/>
<el-table-column v-if="!isCarrier" prop="uploadSource" label="上传来源" width="100" /><el-table-column
v-if="!isCarrier"
<el-table-column
v-if="!isCarrier && isColumnVisible('uploadSource')"
prop="uploadSource"
label="上传来源"
width="100"
/><el-table-column
v-if="!isCarrier && isColumnVisible('carrierName')"
prop="carrierName"
label="承运商名称"
min-width="160"
><template #default="{ row }">{{ row.carrierName || '-' }}</template></el-table-column
>
<el-table-column prop="processStatus" label="处理状态" width="120"
<el-table-column
v-if="isColumnVisible('processStatus')"
prop="processStatus"
label="处理状态"
width="120"
><template #default="{ row }"
><span
:class="{ 'voucher-manage-page__process-failed': row.processStatus === '处理失败' }"
@@ -94,17 +129,35 @@
></template
></el-table-column
><el-table-column
v-if="isColumnVisible('voucherCount')"
prop="voucherCount"
label="凭证数量"
width="100"
/><el-table-column
v-if="isColumnVisible('relatedWaybillCount')"
prop="relatedWaybillCount"
label="凭证已关联数量"
width="125"
/> <el-table-column prop="unRelatedWaybillCount" label="凭证未关联数量" width="125" />
<el-table-column prop="createUserName" label="创建人" min-width="120" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
/><el-table-column
v-if="isColumnVisible('unRelatedWaybillCount')"
prop="unRelatedWaybillCount"
label="凭证未关联数量"
width="125"
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
prop="createUserName"
label="创建人"
min-width="120"
/>
<el-table-column
v-if="isColumnVisible('createTime')"
prop="createTime"
label="创建时间"
min-width="170"
/>
<el-table-column
v-if="isColumnVisible('auditStatus')"
prop="auditStatus"
label="审核状态"
width="120"
@@ -512,6 +565,7 @@ import { WarnTriangleFilled } from '@element-plus/icons-vue';
import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage';
import SectionCard from '@/components/section-card/main.vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
const router = useRouter();
const store = useStore();
@@ -539,6 +593,47 @@ const checkIfCarrier = () => {
isCarrier.value = topDeptName === '外部组织';
};
const VOUCHER_BASE_COLUMNS = [
{ prop: 'voucherBatchNo', label: '凭证批次号' },
{ prop: 'waybillBatchNo', label: '运单批次号' },
{ prop: 'fileName', label: '文件名' },
{ prop: 'uploadSource', label: '上传来源', carrierHidden: true },
{ prop: 'carrierName', label: '承运商名称', carrierHidden: true },
{ prop: 'processStatus', label: '处理状态' },
{ prop: 'voucherCount', label: '凭证数量' },
{ prop: 'relatedWaybillCount', label: '凭证已关联数量' },
{ prop: 'unRelatedWaybillCount', label: '凭证未关联数量' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'auditStatus', label: '审核状态' },
];
const searchVisible = ref(true);
const tableSize = ref('default');
const visibleColumnKeys = ref([]);
const columnSettingOptions = computed(() =>
VOUCHER_BASE_COLUMNS.filter(column => !isCarrier.value || !column.carrierHidden).map(column => ({
prop: column.prop,
label: column.label,
}))
);
const isColumnVisible = prop => visibleColumnKeys.value.includes(prop);
watch(
columnSettingOptions,
options => {
const props = options.map(column => column.prop);
if (!visibleColumnKeys.value.length) {
visibleColumnKeys.value = props;
return;
}
const visible = new Set(visibleColumnKeys.value);
visibleColumnKeys.value = props.filter(prop => visible.has(prop));
if (!visibleColumnKeys.value.length) visibleColumnKeys.value = props;
},
{ immediate: true }
);
onMounted(() => {
checkIfCarrier();
});
@@ -742,6 +837,30 @@ const calculateMd5 = async file => {
const buffer = await file.arrayBuffer();
return md5.base64(buffer);
};
const needsUploadProxy = url =>
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
String(url || '')
.trim()
.toLowerCase()
.startsWith('http:');
const uploadPartWithFallback = async (taskId, partNumber, part, signal) => {
const urlRes = await api.getPartUploadUrl(taskId, partNumber);
if (signal?.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
// HTTPS 页面直连 http://IP MinIO 会被 Mixed Content 拦截,回退服务端代传
if (!url || needsUploadProxy(url)) {
const proxyRes = await api.uploadPartByProxy(taskId, partNumber, part);
return String(proxyRes.data?.data || '').replaceAll('"', '');
}
const response = await fetch(url, {
method: 'PUT',
body: part,
signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
return String(response.headers.get('etag') || '').replaceAll('"', '');
};
const uploadParts = async (file, task, sessionId) => {
uploading.value = true;
activeUploadTaskId.value = task.id;
@@ -756,21 +875,13 @@ const uploadParts = async (file, task, sessionId) => {
) {
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
const part = file.slice(
(partNumber - 1) * chunkSize,
Math.min(partNumber * chunkSize, file.size)
);
const response = await fetch(url, {
method: 'PUT',
body: part,
signal: abortController.signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
const etag = await uploadPartWithFallback(task.id, partNumber, part, abortController.signal);
if (!etag) throw new Error('MinIO 未返回分片 ETag,请确认跨域配置已暴露 ETag 响应头');
await api.updateFileTask({ id: task.id, partNumber, etag });
ensureUploadSession(sessionId, task.id);
@@ -1329,9 +1440,15 @@ load();
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 12px 0;
}
&__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
&__table {
width: 100%;
}
@@ -1370,7 +1487,7 @@ load();
display: none;
}
&__upload-form :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
&__upload-tip {
margin-left: 16px;