✨ 运单详情页新增异常上报卡片

在运费信息之后展示该运单关联的异常处置记录,表格沿用异常处置页的操作与权限,
列只保留上报人、异常类型、上报说明、承运商、最新跟进说明、状态。
跟进/编辑/详情弹窗在本页内完成,操作后局部刷新该卡片,不跳转列表页。
This commit is contained in:
2026-10-05 08:43:01 +08:00
parent d0770c8e83
commit d67240bc3b
@@ -2057,6 +2057,100 @@
</div>
</div>
</section-card>
<section-card title="异常上报">
<el-table v-loading="waybillExceptionLoading" :data="waybillExceptionRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="reporterName" label="上报人" min-width="120" align="center" />
<el-table-column
prop="exceptionTypeName"
label="异常类型"
min-width="160"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="reportDescription"
label="上报说明"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="carrierName"
label="承运商"
min-width="160"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="latestFollowContent"
label="最新跟进说明"
min-width="200"
align="center"
show-overflow-tooltip
/>
<el-table-column label="状态" min-width="120" align="center">
<template #default="{ row }">
<el-link
:type="row.disposalStatus === 'completed' ? 'info' : 'primary'"
@click="
openWaybillException(
row,
row.disposalStatus === 'completed' ? 'detail' : 'follow'
)
"
>
{{ row.disposalStatusName || '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<div class="waybill-manage-page__exception-actions">
<template v-if="row.disposalStatus !== 'completed'">
<el-link
v-if="permission.exception_disposal_follow"
type="primary"
@click="openWaybillException(row, 'follow')"
>
跟进
</el-link>
<el-link
v-if="permission.exception_disposal_edit"
type="primary"
@click="openWaybillException(row, 'edit')"
>
编辑
</el-link>
<el-link
v-if="permission.exception_disposal_complete"
type="primary"
@click="completeWaybillExceptionRow(row)"
>
完成
</el-link>
</template>
<template v-else>
<el-link
v-if="permission.exception_disposal_view"
type="primary"
@click="openWaybillException(row, 'detail')"
>
详情
</el-link>
<el-link
v-if="permission.exception_disposal_edit"
type="primary"
@click="openWaybillException(row, 'edit')"
>
编辑
</el-link>
</template>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
<div class="waybill-manage-page__waybill-detail-bottom">
<section-card title="执行详情">
<el-tabs v-model="waybillProcessDetailTab" type="border-card">
@@ -2356,6 +2450,284 @@
</template>
</component>
<el-dialog
v-model="waybillExceptionDialog.visible"
:title="waybillExceptionDialogTitle"
:width="waybillExceptionDialog.mode === 'edit' ? '1080px' : '960px'"
top="6vh"
append-to-body
destroy-on-close
>
<div
v-loading="waybillExceptionDialog.loading"
class="waybill-manage-page__exception-dialog"
>
<el-form
v-if="waybillExceptionDialog.mode === 'edit'"
ref="waybillExceptionEditFormRef"
:model="waybillExceptionEditForm"
:rules="waybillExceptionEditRules"
label-position="right"
label-width="96px"
>
<section-card title="运单信息">
<el-descriptions :column="3" border size="small">
<el-descriptions-item
v-for="field in waybillExceptionWaybillFields"
:key="field.prop"
:label="field.label"
>
{{ waybillExceptionValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="异常信息">
<el-row :gutter="18">
<el-col :span="12">
<el-form-item label="上报人">
<span class="waybill-manage-page__exception-readonly">{{
waybillExceptionValue('reporterName')
}}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上报时间">
<span class="waybill-manage-page__exception-readonly">{{
waybillExceptionValue('reportTime')
}}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="异常类型" prop="exceptionType">
<el-select
v-model="waybillExceptionEditForm.exceptionType"
placeholder="请选择"
@change="handleWaybillExceptionTypeChange"
>
<el-option
v-for="item in waybillExceptionTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="异常原因" prop="exceptionReason">
<el-select
v-model="waybillExceptionEditForm.exceptionReason"
clearable
placeholder="请选择"
:disabled="!waybillExceptionEditForm.exceptionType"
>
<el-option
v-for="item in waybillExceptionReasonOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="上报说明" prop="reportDescription">
<el-input
v-model="waybillExceptionEditForm.reportDescription"
type="textarea"
maxlength="500"
show-word-limit
:rows="3"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="现场照片">
<div class="waybill-manage-page__exception-edit-photos">
<div
v-for="(url, index) in waybillExceptionEditForm.scenePhotoList"
:key="url"
class="waybill-manage-page__exception-edit-photo"
>
<el-image
:src="url"
:preview-src-list="waybillExceptionEditForm.scenePhotoList"
:initial-index="index"
fit="cover"
/>
<el-link
type="danger"
:underline="false"
@click="removeWaybillExceptionPhoto(index)"
>删除</el-link
>
</div>
<el-upload
action="/api/blade-resource/oss/endpoint/put-file"
name="file"
:headers="waybillExceptionUploadHeaders"
:show-file-list="false"
:before-upload="beforeWaybillExceptionPhotoUpload"
:on-success="handleWaybillExceptionPhotoSuccess"
:on-error="handleWaybillExceptionPhotoError"
>
<div class="waybill-manage-page__exception-edit-photo-add">+ 添加</div>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
</section-card>
</el-form>
<template v-else>
<section-card title="运单信息">
<el-descriptions :column="3" border size="small">
<el-descriptions-item
v-for="field in waybillExceptionWaybillFields"
:key="field.prop"
:label="field.label"
>
{{ waybillExceptionValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="异常信息">
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="field in waybillExceptionDetailFields"
:key="field.prop"
:label="field.label"
:span="field.span === 2 ? 2 : 1"
>
{{ waybillExceptionValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="现场照片">
<div class="waybill-manage-page__exception-photos">
<el-image
v-for="(url, index) in waybillExceptionPhotoUrls"
:key="url"
:src="url"
:preview-src-list="waybillExceptionPhotoUrls"
:initial-index="index"
fit="cover"
/>
<el-empty
v-if="!waybillExceptionPhotoUrls.length"
description="暂无现场照片"
:image-size="56"
/>
</div>
</section-card>
<section-card v-if="waybillExceptionDialog.mode === 'follow'" title="跟进">
<el-form
ref="waybillExceptionFollowFormRef"
:model="waybillExceptionFollowForm"
:rules="waybillExceptionFollowRules"
label-position="right"
label-width="96px"
>
<el-row :gutter="18">
<el-col :span="24">
<el-form-item label="跟进说明" prop="followContent">
<el-input
v-model="waybillExceptionFollowForm.followContent"
type="textarea"
maxlength="500"
show-word-limit
:rows="3"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跟进附件">
<vehicle-attachment-upload
v-model="waybillExceptionFollowForm.attachments"
:limit="20"
:max-size="500"
button-text="上传附件"
:show-tip="false"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section-card>
<section-card title="历史跟进">
<el-table :data="waybillExceptionDetail.followRecords || []" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
v-for="column in waybillExceptionFollowColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
/>
<el-table-column label="附件" min-width="120" align="center">
<template #default="{ row }">
<template v-if="parseWaybillExceptionAttachments(row.attachmentsJson).length">
<el-link
v-for="(file, index) in parseWaybillExceptionAttachments(
row.attachmentsJson
)"
:key="index"
type="primary"
class="waybill-manage-page__exception-attachment-link"
:href="file.url"
target="_blank"
>
{{ file.name || '附件' + (index + 1) }}
</el-link>
</template>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</section-card>
</template>
</div>
<template #footer>
<el-button
v-if="waybillExceptionDialog.mode === 'edit' && permission.exception_disposal_edit"
type="primary"
:loading="waybillExceptionDialog.submitting"
@click="submitWaybillExceptionEdit"
>
提交
</el-button>
<el-button
v-if="waybillExceptionDialog.mode === 'follow' && permission.exception_disposal_follow"
type="primary"
:loading="waybillExceptionDialog.submitting"
@click="submitWaybillExceptionFollow"
>
提交
</el-button>
<el-button
v-if="
waybillExceptionDialog.mode === 'follow' && permission.exception_disposal_complete
"
type="primary"
plain
@click="completeWaybillExceptionFromDialog"
>
完成
</el-button>
<el-button @click="waybillExceptionDialog.visible = false">取消</el-button>
</template>
</el-dialog>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
@@ -3120,6 +3492,12 @@ import { h, resolveComponent } from 'vue';
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import {
disposalStatusOptions,
exceptionCategoryOptions,
} from '@/option/transit/exception-disposal';
import { getUploadHeaders } from '@/utils/upload';
import * as exceptionDisposalApi from '@/api/transit/exception-disposal';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
@@ -3349,6 +3727,32 @@ export default {
waybillVoucherFolderView: false,
waybillVoucherImagesLoading: false,
waybillVoucherImagesLoaded: false,
waybillExceptionRows: [],
waybillExceptionLoading: false,
waybillExceptionDialog: {
visible: false,
mode: 'follow',
loading: false,
submitting: false,
},
waybillExceptionDetail: {},
waybillExceptionFollowForm: { followContent: '', attachments: [] },
waybillExceptionFollowRules: {
followContent: [{ max: 500, message: '跟进说明不能超过500个字符', trigger: 'blur' }],
},
waybillExceptionEditForm: {
exceptionType: '',
exceptionReason: '',
reportDescription: '',
scenePhotoList: [],
},
waybillExceptionEditRules: {
exceptionType: [{ required: true, message: '请选择异常类型', trigger: 'change' }],
reportDescription: [
{ required: true, message: '请输入上报说明', trigger: 'blur' },
{ max: 500, message: '上报说明不能超过500个字符', trigger: 'blur' },
],
},
waybillRouteChangeBox: false,
waybillRouteChangeTab: 'change',
waybillRouteChangeRows: [],
@@ -3648,6 +4052,54 @@ export default {
},
computed: {
...mapGetters(['permission', 'userInfo']),
waybillExceptionDialogTitle() {
return (
{ detail: '异常详情', follow: '异常跟进', edit: '编辑异常' }[
this.waybillExceptionDialog.mode
] || '异常详情'
);
},
waybillExceptionWaybillFields() {
return [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
];
},
waybillExceptionDetailFields() {
return [
{ label: '上报人', prop: 'reporterName' },
{ label: '上报时间', prop: 'reportTime' },
{ label: '异常类型', prop: 'exceptionTypeName' },
{ label: '异常原因', prop: 'exceptionReason' },
{ label: '上报说明', prop: 'reportDescription', span: 2 },
];
},
waybillExceptionFollowColumns() {
return [
{ label: '跟进说明', prop: 'followContent', minWidth: 260 },
{ label: '跟进人', prop: 'followUserName', minWidth: 130 },
{ label: '跟进时间', prop: 'followTime', minWidth: 170 },
];
},
waybillExceptionTypeOptions() {
return exceptionCategoryOptions.map(item => ({ label: item.label, value: item.value }));
},
waybillExceptionReasonOptions() {
return (
exceptionCategoryOptions.find(
item => item.value === this.waybillExceptionEditForm.exceptionType
)?.children || []
);
},
waybillExceptionPhotoUrls() {
return this.parseWaybillExceptionPhotos(this.waybillExceptionDetail.scenePhotos);
},
waybillExceptionUploadHeaders() {
return getUploadHeaders();
},
permissionList() {
return {
addBtn: this.canCreate,
@@ -4629,6 +5081,7 @@ export default {
this.waybillVoucherFolder = null;
this.waybillVoucherFolderView = false;
this.waybillVoucherImagesLoaded = false;
this.waybillExceptionRows = [];
const request =
typeof this.api.getDetail === 'function'
? this.api.getDetail(row.id)
@@ -4639,6 +5092,7 @@ export default {
this.detailRow = detail;
this.loadWaybillPunchRecords();
this.loadWaybillVoucherImages();
this.loadWaybillExceptions(detail);
if (detail.contractId) {
return getContractDetail(detail.contractId)
.then(contractRes => {
@@ -4668,6 +5122,206 @@ export default {
this.detailLoading = false;
});
},
// 异常处置列表接口不支持按 waybillId 精确过滤,只有 waybillNo 模糊匹配,
// 而运单号存在前缀包含(YD001 会命中 YD0011),故取回后必须再按 waybillId 精确筛一次。
// 多取一页降低「同前缀运单占满首页」导致漏数据的概率。
// 注意 String() 比较只是避免类型不一致,挡不住 int64 精度:运单 id 超出 JS 安全整数范围,
// 两个相差很小的 id 在响应 JSON 解析时就已被解析成同一个浮点值,此处无从分辨。
async loadWaybillExceptions(detail) {
const waybillId = detail?.id;
const waybillNo = detail?.waybillNo;
if (!waybillId || !waybillNo) {
this.waybillExceptionRows = [];
return;
}
this.waybillExceptionLoading = true;
try {
const res = await exceptionDisposalApi.getList(1, 100, { waybillNo });
const data = res?.data?.data || res?.data || res || {};
this.waybillExceptionRows = (data.records || [])
.filter(item => String(item.waybillId) === String(waybillId))
.map(this.decorateWaybillExceptionRow);
} catch (error) {
this.waybillExceptionRows = [];
} finally {
this.waybillExceptionLoading = false;
}
},
decorateWaybillExceptionRow(row) {
return {
...row,
exceptionTypeName: this.waybillExceptionName(row.exceptionType),
disposalStatusName:
disposalStatusOptions.find(item => item.value === row.disposalStatus)?.label ||
row.disposalStatus ||
'-',
};
},
waybillExceptionName(value) {
if (!value) return '-';
// 一级存中文大类名(如「交通异常」),二级存子类名(如「货损」),两级都查
const group = exceptionCategoryOptions.find(item => item.value === value);
if (group) return group.label;
for (const item of exceptionCategoryOptions) {
const match = item.children.find(child => child.value === value);
if (match) return match.label;
}
return value;
},
async openWaybillException(row, mode) {
this.waybillExceptionDialog = {
visible: true,
mode,
loading: true,
submitting: false,
};
this.waybillExceptionFollowForm = { followContent: '', attachments: [] };
try {
const res = await exceptionDisposalApi.getDetail(row.id);
const detail = res?.data?.data || res?.data || res || {};
this.waybillExceptionDetail = this.decorateWaybillExceptionRow(detail);
if (mode === 'edit') {
this.waybillExceptionEditForm = {
exceptionType: this.waybillExceptionDetail.exceptionType || '',
exceptionReason: this.waybillExceptionDetail.exceptionReason || '',
reportDescription: this.waybillExceptionDetail.reportDescription || '',
scenePhotoList: this.parseWaybillExceptionPhotos(
this.waybillExceptionDetail.scenePhotos
),
};
}
} finally {
this.waybillExceptionDialog.loading = false;
}
},
waybillExceptionValue(prop) {
const value = this.waybillExceptionDetail[prop];
return value === null || value === undefined || value === '' ? '-' : value;
},
handleWaybillExceptionTypeChange() {
// 一级类型变了,旧二级可能不属于新类型
const stillValid = this.waybillExceptionReasonOptions.some(
item => item.value === this.waybillExceptionEditForm.exceptionReason
);
if (!stillValid) {
this.waybillExceptionEditForm.exceptionReason = '';
}
},
beforeWaybillExceptionPhotoUpload(file) {
const validType = ['image/jpeg', 'image/png', 'image/jpg', 'image/bmp'].includes(file.type);
if (!validType) {
this.$message.error('仅支持 JPG、PNG、BMP 图片');
return false;
}
if (file.size / 1024 / 1024 >= 5) {
this.$message.error('图片大小不能超过 5MB');
return false;
}
return true;
},
handleWaybillExceptionPhotoSuccess(res) {
if (res.code === 200 && res.data) {
const url = res.data.link || res.data.url || res.data.domain || '';
if (url) {
this.waybillExceptionEditForm.scenePhotoList = [
...this.waybillExceptionEditForm.scenePhotoList,
url,
];
}
} else {
this.$message.error(res.msg || '上传失败');
}
},
handleWaybillExceptionPhotoError() {
this.$message.error('上传失败');
},
removeWaybillExceptionPhoto(index) {
const list = [...this.waybillExceptionEditForm.scenePhotoList];
list.splice(index, 1);
this.waybillExceptionEditForm.scenePhotoList = list;
},
parseWaybillExceptionPhotos(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed.filter(Boolean);
} catch (error) {
return String(value)
.split(',')
.map(item => item.trim())
.filter(Boolean);
}
return [];
},
parseWaybillExceptionAttachments(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter(item => item && item.url) : [];
} catch (error) {
return [];
}
},
async submitWaybillExceptionEdit() {
await this.$refs.waybillExceptionEditFormRef.validate();
this.waybillExceptionDialog.submitting = true;
try {
await exceptionDisposalApi.update({
id: this.waybillExceptionDetail.id,
exceptionType: this.waybillExceptionEditForm.exceptionType,
exceptionReason: this.waybillExceptionEditForm.exceptionReason,
reportDescription: this.waybillExceptionEditForm.reportDescription,
scenePhotos: this.waybillExceptionEditForm.scenePhotoList.join(','),
});
this.$message.success('编辑成功');
this.waybillExceptionDialog.visible = false;
this.refreshWaybillExceptions();
} finally {
this.waybillExceptionDialog.submitting = false;
}
},
async submitWaybillExceptionFollow() {
await this.$refs.waybillExceptionFollowFormRef.validate();
const followContent = (this.waybillExceptionFollowForm.followContent || '').trim();
const attachmentsJson = this.waybillExceptionFollowForm.attachments.length
? JSON.stringify(this.waybillExceptionFollowForm.attachments)
: '';
// 后端要求二者至少有其一
if (!followContent && !attachmentsJson) {
this.$message.warning('请填写跟进说明或上传附件');
return;
}
this.waybillExceptionDialog.submitting = true;
try {
await exceptionDisposalApi.follow({
id: this.waybillExceptionDetail.id,
followContent,
attachmentsJson,
});
this.$message.success('跟进成功');
this.waybillExceptionDialog.visible = false;
this.refreshWaybillExceptions();
} finally {
this.waybillExceptionDialog.submitting = false;
}
},
async completeWaybillExceptionRow(row) {
await this.$confirm('确认完成该异常处置?', '提示', { type: 'warning' });
await exceptionDisposalApi.complete({ id: row.id });
this.$message.success('完成成功');
this.refreshWaybillExceptions();
},
async completeWaybillExceptionFromDialog() {
await this.$confirm('确认完成该异常处置?', '提示', { type: 'warning' });
await exceptionDisposalApi.complete({ id: this.waybillExceptionDetail.id });
this.$message.success('完成成功');
this.waybillExceptionDialog.visible = false;
this.refreshWaybillExceptions();
},
refreshWaybillExceptions() {
this.loadWaybillExceptions(this.detailRow);
},
closeDetail() {
this.destroyWaybillLocateMap();
this.destroyWaybillTrackMap();
@@ -10046,6 +10700,81 @@ export default {
padding-top: 12px;
}
&__exception-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
&__exception-dialog {
padding: 4px 0 0;
:deep(.section-card + .section-card) {
margin-top: 16px;
}
}
&__exception-readonly {
color: #606266;
word-break: break-all;
}
&__exception-attachment-link {
display: block;
margin-bottom: 2px;
}
&__exception-photos {
display: flex;
flex-wrap: wrap;
gap: 12px;
min-height: 80px;
:deep(.el-image) {
width: 96px;
height: 96px;
border: 1px solid #eff1f7;
}
}
&__exception-edit-photos {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
&__exception-edit-photo {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
:deep(.el-image) {
width: 96px;
height: 96px;
border: 1px solid #eff1f7;
}
}
&__exception-edit-photo-add {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
color: #909399;
cursor: pointer;
&:hover {
border-color: #409eff;
color: #409eff;
}
}
&__waybill-detail-summary {
:deep(.el-card__body) {
padding: 18px 22px;