This commit is contained in:
2026-08-21 12:07:56 +08:00
parent b07205be8c
commit 868ec4aac0
5 changed files with 394 additions and 232 deletions
+372 -215
View File
@@ -69,7 +69,7 @@
<el-link
v-if="column.link && row[column.prop]"
type="primary"
@click="openDetailDialog(row)"
@click="handleTableLink(row, column)"
>
{{ row[column.prop] }}
</el-link>
@@ -103,186 +103,166 @@
</div>
</section>
<el-dialog
v-model="detailDialog.visible"
:title="detailDialog.title"
width="92%"
append-to-body
destroy-on-close
class="settlement-detail-dialog"
>
<el-tabs type="border-card" style="background-color: #ffffff" v-model="detailDialog.activeTab" @tab-change="handleDetailTabChange">
<el-tab-pane label="费用明细" name="fee">
<el-table v-loading="detailDialog.loading" :data="feeRows" border>
<teleport to="body">
<section
v-if="detailDialog.visible && !adjustDialog.visible"
class="settlement-detail-page__detail-panel"
aria-label="单据详情"
>
<header class="settlement-detail-page__detail-panel-header">
<h3>单据详情{{ detailDialog.title }}</h3>
<el-tooltip content="关闭" placement="top">
<el-button
:icon="Close"
text
aria-label="关闭单据详情"
@click="closeDetailPanel"
/>
</el-tooltip>
</header>
<div class="settlement-detail-page__detail-panel-body">
<el-tabs v-model="detailDialog.activeTab" @tab-change="handleDetailTabChange">
<el-tab-pane label="费用明细" name="fee">
<el-table v-loading="detailDialog.loading" :data="feeRows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in feeDetailColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
:align="column.align || 'center'"
show-overflow-tooltip
>
<template #default="{ row }">{{ formatDetailCell(row, column.prop) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="变更记录" name="change">
<el-table v-loading="changeDialog.loading" :data="changeRows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in changeRecordColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
/>
</el-table>
<div class="settlement-detail-page__pagination">
<el-pagination
v-model:current-page="changePage.current"
v-model:page-size="changePage.size"
:total="changePage.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadChangeRecords"
@size-change="handleChangeSizeChange"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
<footer class="settlement-detail-page__detail-panel-footer">
<el-button @click="closeDetailPanel">关闭</el-button>
</footer>
</section>
</teleport>
<teleport to="body">
<section
v-if="adjustDialog.visible"
class="settlement-detail-page__detail-panel"
aria-label="调整费用"
>
<header class="settlement-detail-page__detail-panel-header">
<h3>调整费用{{ adjustDialog.row?.documentNo || '-' }}</h3>
<el-tooltip content="关闭" placement="top">
<el-button
:icon="Close"
text
aria-label="关闭调整费用"
@click="closeAdjustPanel"
/>
</el-tooltip>
</header>
<div
class="settlement-detail-page__detail-panel-body settlement-detail-page__adjust-panel-body"
>
<el-table v-loading="adjustDialog.loading" :data="adjustRows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in feeDetailColumns"
:key="column.prop"
:prop="column.prop"
v-for="column in adjustFeeColumns"
:key="column.prop || column.feeItemName"
:label="column.label"
:min-width="column.minWidth"
:align="column.align || 'center'"
show-overflow-tooltip
>
<template #default="{ row }">{{ formatDetailCell(row, column.prop) }}</template>
<template #default="{ row }">
<el-input
v-if="column.prop === 'transportQuantityText' && !row.manualFee"
class="settlement-detail-page__adjust-input"
:model-value="row.transportQuantity"
inputmode="decimal"
@input="value => handleAdjustDecimalInput(row, 'transportQuantity', value)"
/>
<el-input
v-else-if="column.prop === 'mileage' && !row.manualFee"
class="settlement-detail-page__adjust-input"
:model-value="row.mileage"
inputmode="decimal"
@input="value => handleAdjustDecimalInput(row, 'mileage', value)"
/>
<el-input
v-else-if="column.prop === 'freightAmount' && !row.manualFee"
class="settlement-detail-page__adjust-input"
:model-value="row.freightAmount"
inputmode="decimal"
@input="value => handleAdjustDecimalInput(row, 'freightAmount', value, 'freight')"
/>
<el-input
v-else-if="column.dynamic && !row.manualFee"
class="settlement-detail-page__adjust-input"
:model-value="row.feeItems[column.feeItemName]"
inputmode="decimal"
@input="value => handleAdjustFeeItemInput(row, column.feeItemName, value)"
/>
<el-input
v-else-if="column.prop === 'remark'"
v-model="row.remark"
clearable
maxlength="200"
placeholder="请输入备注"
/>
<el-input
v-else-if="column.prop === 'changeReason'"
v-model="row.changeReason"
clearable
maxlength="300"
placeholder="请输入变更原因"
/>
<span v-else-if="column.prop === 'adjustAmountText'">
{{ fixedTwoDecimals(row.adjustAmount) }}
</span>
<span v-else-if="column.prop === 'afterAmountText'">
{{ fixedTwoDecimals(row.afterAmount) }}
</span>
<span v-else>{{ formatDetailCell(row, column.prop) }}</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="变更记录" name="change">
<el-table v-loading="changeDialog.loading" :data="changeRows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in changeRecordColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
/>
</el-table>
<div class="settlement-detail-page__pagination">
<el-pagination
v-model:current-page="changePage.current"
v-model:page-size="changePage.size"
:total="changePage.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadChangeRecords"
@size-change="handleChangeSizeChange"
/>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<el-dialog
v-model="adjustDialog.visible"
title="调整费用"
width="96%"
append-to-body
destroy-on-close
>
<div class="settlement-detail-page__adjust-toolbar">
<el-button type="primary" plain @click="addManualAdjustRow">新增费用项</el-button>
</div>
<el-table v-loading="adjustDialog.loading" :data="adjustRows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column label="手工费用项目" min-width="180" align="center">
<template #default="{ row }">
<el-input
v-if="row.manualFee"
v-model="row.feeItemName"
clearable
placeholder="请输入收费或扣费项目"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="收费/扣费" width="130" align="center">
<template #default="{ row }">
<el-select
v-if="row.manualFee"
v-model="row.feeType"
placeholder="请选择"
@change="recalculateManualAdjustRow(row)"
>
<el-option label="收费" value="charge" />
<el-option label="扣费" value="deduct" />
</el-select>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="手工金额" width="150" align="right">
<template #default="{ row }">
<el-input-number
v-if="row.manualFee"
v-model="row.amount"
:min="0"
:precision="2"
controls-position="right"
@change="recalculateManualAdjustRow(row)"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ row }">
<el-link
v-if="row.manualFee && !row.id"
type="danger"
@click="removeManualAdjustRow(row)"
>
删除
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
v-for="column in adjustFeeColumns"
:key="column.prop || column.feeItemName"
:label="column.label"
:min-width="column.minWidth"
:align="column.align || 'center'"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input-number
v-if="column.prop === 'transportQuantityText' && !row.manualFee"
v-model="row.transportQuantity"
:min="0"
:precision="2"
controls-position="right"
@change="recalculateAdjustRow(row)"
/>
<el-input-number
v-else-if="column.prop === 'mileage' && !row.manualFee"
v-model="row.mileage"
:min="0"
:precision="2"
controls-position="right"
@change="recalculateAdjustRow(row)"
/>
<el-input-number
v-else-if="column.prop === 'freightAmount' && !row.manualFee"
v-model="row.freightAmount"
:min="0"
:precision="2"
controls-position="right"
@change="recalculateAdjustRow(row, 'freight')"
/>
<el-input-number
v-else-if="column.dynamic && !row.manualFee"
v-model="row.feeItems[column.feeItemName]"
:min="0"
:precision="2"
controls-position="right"
@change="recalculateAdjustRow(row, column.feeItemName)"
/>
<el-input
v-else-if="column.prop === 'remark'"
v-model="row.remark"
clearable
maxlength="200"
placeholder="请输入备注"
/>
<span v-else-if="column.prop === 'adjustAmountText'">
{{ fixedTwoDecimals(row.adjustAmount) }}
</span>
<span v-else-if="column.prop === 'afterAmountText'">
{{ fixedTwoDecimals(row.afterAmount) }}
</span>
<span v-else>{{ formatDetailCell(row, column.prop) }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="adjustDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="adjustDialog.submitting" @click="saveAdjustFee">
保存
</el-button>
</template>
</el-dialog>
</div>
<footer class="settlement-detail-page__detail-panel-footer">
<el-button @click="closeAdjustPanel">取消</el-button>
<el-button type="primary" :loading="adjustDialog.submitting" @click="saveAdjustFee">
保存
</el-button>
</footer>
</section>
</teleport>
<el-dialog v-model="updateFeeDialog.visible" title="更新费用" width="620px" append-to-body>
<el-form
@@ -409,8 +389,10 @@
<el-form-item label="运单合同" required>
<el-select
v-model="generateQuery.contractId"
class="settlement-detail-page__generate-select"
filterable
clearable
:loading="contractLoading"
placeholder="请选择合同"
@change="handleGenerateContractChange"
>
@@ -423,7 +405,14 @@
</el-select>
</el-form-item>
<el-form-item label="计费方案" required>
<el-select v-model="generateQuery.billingPlanId" filterable clearable placeholder="请选择">
<el-select
v-model="generateQuery.billingPlanId"
class="settlement-detail-page__generate-select"
filterable
clearable
:disabled="!generateQuery.contractId"
:placeholder="generateQuery.contractId ? '请选择' : '请先选择合同'"
>
<el-option
v-for="item in billingPlanOptions"
:key="item.id"
@@ -488,7 +477,13 @@
</div>
<template #footer>
<el-button @click="generateDialog.visible = false">取消</el-button>
<el-button type="primary" @click="openGeneratePreview">生成费用</el-button>
<el-button
type="primary"
:loading="previewDialog.loading"
@click="openGeneratePreview"
>
生成费用
</el-button>
</template>
</el-dialog>
@@ -528,7 +523,7 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh, Setting } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp, Close, Refresh, Setting } from '@element-plus/icons-vue';
import {
changeRecordColumns,
feeDetailBaseColumns,
@@ -557,6 +552,7 @@ export default {
return {
ArrowDown,
ArrowUp,
Close,
Refresh,
Setting,
searchFields,
@@ -577,6 +573,7 @@ export default {
adjustDialog: { visible: false, loading: false, submitting: false, row: null },
adjustRows: [],
adjustDynamicFeeColumns: [],
adjustCalculateTimers: {},
changeRows: [],
changePage: { current: 1, size: 10, total: 0 },
changeDialog: { loading: false },
@@ -586,6 +583,7 @@ export default {
contractId: [{ required: true, message: '请选择更新范围', trigger: 'change' }],
},
contractOptions: [],
contractLoading: false,
updateContractOptions: [],
transportTypeOptions: [],
billingPlanOptions: [],
@@ -639,12 +637,12 @@ export default {
...feeDetailBaseColumns,
...this.adjustDynamicFeeColumns,
...feeDetailTailColumns,
{ label: '变更原因', prop: 'changeReason', minWidth: 220 },
];
},
},
mounted() {
this.loadTransportTypeOptions();
this.loadContracts();
this.loadTable();
},
methods: {
@@ -664,6 +662,7 @@ export default {
},
formatColumnValue(row, column) {
if (column.prop === 'transportType') return this.transportTypeLabel(row[column.prop]);
if (column.prop === 'transportQuantity') return this.fixedTwoDecimals(row[column.prop]);
if (column.dynamic) {
return this.money(this.normalizeFeeItems(row.feeItems)[column.feeItemName], row.currency || 'RMB');
}
@@ -688,12 +687,17 @@ export default {
async loadContracts() {
const contractCategory =
this.settlementType === 'payable' ? '承运商合同' : '客户合同';
const res = await getContractList(1, 999, {
approvalStatus: 'approved',
contractCategory,
});
const data = this.unwrapPage(res);
this.contractOptions = data.records || [];
this.contractLoading = true;
try {
const res = await getContractList(1, 999, {
approvalStatuses: 'approved,change_approved',
contractCategory,
});
const data = this.unwrapPage(res);
this.contractOptions = data.records || [];
} finally {
this.contractLoading = false;
}
},
handleSearch() {
this.page.current = 1;
@@ -713,11 +717,40 @@ export default {
selectionChange(selection) {
this.selection = selection;
},
closeDetailPanel() {
this.detailDialog.visible = false;
this.detailDialog.row = null;
},
closeAdjustPanel() {
Object.values(this.adjustCalculateTimers).forEach(timer => clearTimeout(timer));
this.adjustCalculateTimers = {};
this.adjustDialog.visible = false;
this.adjustDialog.row = null;
},
handleTableLink(row, column) {
if (column.prop === 'waybillNo') {
this.openWaybillDetail(row);
return;
}
this.openDetailDialog(row);
},
openWaybillDetail(row) {
if (!row.waybillId) {
this.$message.info('当前记录未关联运单详情');
return;
}
this.$router.push({
path: '/business/waybill-manage',
query: { detailId: row.waybillId },
});
},
async openDetailDialog(row) {
this.closeAdjustPanel();
this.detailDialog = { ...this.detailDialog, visible: true, row, title: row.documentNo, activeTab: 'fee' };
await this.loadFeeDetail();
},
async openAdjustDialog(row) {
this.closeDetailPanel();
this.adjustDialog = { ...this.adjustDialog, visible: true, row, loading: true };
try {
const res = await api.getFeeDetail(row.id);
@@ -761,25 +794,6 @@ export default {
this.adjustDialog.loading = false;
}
},
addManualAdjustRow() {
this.adjustRows.push({
id: null,
manualFee: true,
feeItemName: '',
feeType: 'charge',
amount: 0,
transportQuantity: null,
mileage: null,
freightAmount: 0,
originalAmount: 0,
feeItems: {},
remark: '',
});
},
removeManualAdjustRow(row) {
const index = this.adjustRows.indexOf(row);
if (index >= 0) this.adjustRows.splice(index, 1);
},
recalculateManualAdjustRow(row) {
const amount = Number(row.amount || 0);
row.afterAmount = Number((row.feeType === 'deduct' ? -amount : amount).toFixed(2));
@@ -788,6 +802,61 @@ export default {
fixedTwoDecimals(value) {
return Number(value || 0).toFixed(2);
},
normalizeAdjustDecimal(value) {
const text = String(value ?? '').replace(/[^\d.]/g, '');
const decimalIndex = text.indexOf('.');
if (decimalIndex < 0) return text;
const integer = text.slice(0, decimalIndex).replace(/\D/g, '') || '0';
const decimal = text
.slice(decimalIndex + 1)
.replace(/\D/g, '')
.slice(0, 2);
return `${integer}.${decimal}`;
},
handleAdjustDecimalInput(row, prop, value, changedField) {
row[prop] = this.normalizeAdjustDecimal(value);
if (['transportQuantity', 'mileage'].includes(prop)) {
this.scheduleAdjustedFeeCalculation(row);
return;
}
this.recalculateAdjustRow(row, changedField);
},
handleAdjustFeeItemInput(row, feeItemName, value) {
row.feeItems[feeItemName] = this.normalizeAdjustDecimal(value);
this.recalculateAdjustRow(row, feeItemName);
},
scheduleAdjustedFeeCalculation(row) {
const key = String(row.id);
clearTimeout(this.adjustCalculateTimers[key]);
row.calculateSequence = Number(row.calculateSequence || 0) + 1;
const sequence = row.calculateSequence;
this.adjustCalculateTimers[key] = setTimeout(
() => this.calculateAdjustedFee(row, sequence),
350
);
},
async calculateAdjustedFee(row, sequence) {
if (!this.adjustDialog.row || !row.id) return;
row.calculating = true;
try {
const res = await api.calculateAdjustedFee({
detailId: this.adjustDialog.row.id,
feeId: row.id,
transportQuantity: row.transportQuantity,
mileage: row.mileage,
freightAmount: row.freightAmount,
feeItems: row.feeItems,
});
if (sequence !== row.calculateSequence || !this.adjustDialog.visible) return;
const data = res.data?.data || res.data || res || {};
row.freightAmount = Number(data.freightAmount || 0);
row.feeItems = { ...row.feeItems, ...(data.feeItems || {}) };
row.adjustAmount = Number(data.adjustAmount || 0);
row.afterAmount = Number(data.afterAmount || 0);
} finally {
if (sequence === row.calculateSequence) row.calculating = false;
}
},
recalculateAdjustRow(row, changedField) {
if (changedField && changedField !== 'freight' && this.isFreightFeeItem(changedField)) {
row.freightAmount = Number(row.feeItems[changedField] || 0);
@@ -834,10 +903,11 @@ export default {
feeType: row.feeType,
amount: row.amount,
remark: row.remark,
changeReason: row.changeReason,
})),
});
this.$message.success('保存成功');
this.adjustDialog.visible = false;
this.closeAdjustPanel();
await this.loadTable();
} finally {
this.adjustDialog.submitting = false;
@@ -1049,9 +1119,12 @@ export default {
openGenerateDialog() {
this.generateDialog.visible = true;
this.generateQuery = {};
this.contractOptions = [];
this.billingPlanOptions = [];
this.generateRows = [];
this.generateSelection = [];
this.generatePage = { current: 1, size: 10, total: 0 };
this.loadContracts();
},
handleGenerateContractChange(contractId) {
this.syncBillingPlanOptions(contractId);
@@ -1088,15 +1161,16 @@ export default {
generateSelectionChange(selection) {
this.generateSelection = selection;
},
openGeneratePreview() {
async openGeneratePreview() {
if (!this.generateSelection.length) {
this.$message.warning('请选择需要生成费用的运单');
return;
}
this.previewPage.current = 1;
const loaded = await this.loadGeneratePreview();
if (!loaded) return;
this.previewDialog.visible = true;
this.generateDialog.visible = false;
this.previewPage.current = 1;
this.loadGeneratePreview();
},
async loadGeneratePreview() {
this.previewDialog.loading = true;
@@ -1122,6 +1196,9 @@ export default {
return { ...row, ...dynamic };
});
this.previewPage.total = data.total || 0;
return true;
} catch (error) {
return false;
} finally {
this.previewDialog.loading = false;
}
@@ -1168,7 +1245,13 @@ export default {
const contract = contracts.find(item => String(item.id) === String(contractId));
const plans = this.parseJson(contract?.billingPlanJson);
this.billingPlanOptions = plans.map((item, index) => ({
id: item.id || item.planId || item.name || `plan-${index}`,
id:
item.id ||
item.planId ||
item.name ||
item.planName ||
item.billingPlanName ||
`plan-${index}`,
name: item.name || item.planName || item.billingPlanName || `计费方案${index + 1}`,
defaultPlan:
item.defaultPlan === true || String(item.defaultPlan).toLowerCase() === 'true',
@@ -1358,8 +1441,13 @@ export default {
}
}
.settlement-detail-page__adjust-toolbar {
margin-bottom: 8px;
.settlement-detail-page__adjust-input {
width: 100%;
min-width: 0;
:deep(.el-input__inner) {
text-align: right;
}
}
.settlement-detail-page__generate-search {
@@ -1370,6 +1458,10 @@ export default {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.settlement-detail-page__generate-select {
width: 220px;
}
.settlement-detail-page__transfer-form {
margin-bottom: 16px;
padding: 12px 12px 4px;
@@ -1390,16 +1482,81 @@ export default {
}
}
/* 详情弹窗 tabs 白底 */
.settlement-detail-dialog {
.settlement-detail-page__detail-panel {
position: fixed;
z-index: 100;
bottom: 0;
left: $sidebar_width;
width: calc(100% - #{$sidebar_width});
max-height: min(60vh, 560px);
border-top: 1px solid #eff1f7;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
:global(.avue--collapse) .settlement-detail-page__detail-panel {
left: $sidebar_collapse;
width: calc(100% - #{$sidebar_collapse});
}
:global(.avue-layout--horizontal) .settlement-detail-page__detail-panel {
left: 0;
width: 100%;
}
.settlement-detail-page__detail-panel-header {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid #eff1f7;
h3 {
margin: 0;
padding-left: 12px;
border-left: 4px solid #409eff;
font-size: 16px;
font-weight: 600;
}
}
.settlement-detail-page__detail-panel-body {
max-height: calc(min(60vh, 560px) - 106px);
padding: 0 20px 16px;
overflow: auto;
:deep(.el-tabs__content) {
background: #fff;
padding: 16px;
border-radius: 0 0 6px 6px;
overflow: visible;
}
:deep(.el-tab-pane) {
background: transparent;
:deep(.el-table) {
--el-table-border-color: #eff1f7;
}
:deep(.el-table__row:nth-child(even) td.el-table__cell) {
background: #fafafa;
}
}
.settlement-detail-page__detail-panel-footer {
display: flex;
min-height: 57px;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 20px;
border-top: 1px solid #eff1f7;
}
.settlement-detail-page__adjust-panel-body {
padding-top: 16px;
}
@media screen and (max-width: 992px) {
.settlement-detail-page__detail-panel {
left: 0;
width: 100%;
}
}
</style>