✨ 异常处置新增编辑功能

列表操作列新增「编辑」,弹窗内只呈现运单信息与可编辑项,
按项目既有习惯用 section-card 分组、el-descriptions 展示只读信息、
el-row/el-col 排可编辑字段。

异常类型与异常原因的下拉值改为中文原文:库里 exception_type 存大类中文名、
exception_reason 存子类中文名(小程序上报即如此)。原先一级 value 是英文码
(traffic),而查询按该值做精确匹配,导致按异常类型筛选一行都查不到。

现场照片支持增删,上传走与我方 OSS 端点一致的 put-file 接口。
This commit is contained in:
2026-10-05 00:54:22 +08:00
parent 785ad08b7e
commit 2994539de4
3 changed files with 387 additions and 75 deletions
+2
View File
@@ -9,6 +9,8 @@ export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get'
export const follow = data => request({ url: `${baseUrl}/follow`, method: 'post', data });
export const update = data => request({ url: `${baseUrl}/update`, method: 'post', data });
export const complete = data => request({ url: `${baseUrl}/complete`, method: 'post', data });
export const batchComplete = ids =>
+35 -4
View File
@@ -1,7 +1,10 @@
// 一级 value 与二级 value 都用中文原文:库里 exception_type 存的是大类中文名、
// exception_reason 存子类中文名(小程序上报即如此),此处必须与之一致,
// 否则按异常类型做精确匹配的查询会一行都查不到。
export const exceptionCategoryOptions = [
{
label: '交通异常',
value: 'traffic',
value: '交通异常',
children: [
{ label: '道路拥堵', value: '道路拥堵' },
{ label: '交通事故', value: '交通事故' },
@@ -12,7 +15,7 @@ export const exceptionCategoryOptions = [
},
{
label: '车辆异常',
value: 'vehicle',
value: '车辆异常',
children: [
{ label: '车辆故障维修', value: '车辆故障维修' },
{ label: '车辆事故', value: '车辆事故' },
@@ -24,7 +27,7 @@ export const exceptionCategoryOptions = [
},
{
label: '货物异常',
value: 'cargo',
value: '货物异常',
children: [
{ label: '货损', value: '货损' },
{ label: '货差', value: '货差' },
@@ -35,7 +38,7 @@ export const exceptionCategoryOptions = [
},
{
label: '其他异常',
value: 'other',
value: '其他异常',
children: [
{ label: '变更路线地址', value: '变更路线地址' },
{ label: '其他', value: '其他异常-其他' },
@@ -43,6 +46,16 @@ export const exceptionCategoryOptions = [
},
];
/** 一级类型的中文名列表,供编辑表单的级联选择使用 */
export const exceptionTypeOptions = exceptionCategoryOptions.map(item => ({
label: item.label,
value: item.value,
}));
/** 按一级类型取二级选项 */
export const exceptionReasonOptions = typeValue =>
exceptionCategoryOptions.find(item => item.value === typeValue)?.children || [];
export const disposalStatusOptions = [
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
@@ -89,3 +102,21 @@ export const followColumns = [
{ label: '跟进人', prop: 'followUserName', minWidth: 130 },
{ label: '跟进时间', prop: 'followTime', minWidth: 170 },
];
// 编辑表单只开放上报四件套。车牌、项目、承运商、配载单号由上报时从运单回填,
// 允许手改会让记录与运单脱钩;处置状态由跟进与完成流转,不在此处。
export const editFormFields = [
{ label: '异常类型', prop: 'exceptionType', type: 'select', required: true },
{ label: '异常原因', prop: 'exceptionReason', type: 'select' },
{ label: '上报说明', prop: 'reportDescription', type: 'textarea', required: true, span: 2 },
];
// 编辑弹窗顶部的运单信息,只读展示,帮助运管员确认改的是哪一单
export const editWaybillFields = [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
{ label: '上报人', prop: 'reporterName' },
];
+350 -71
View File
@@ -93,7 +93,11 @@
show-overflow-tooltip
>
<template #default="{ row }">
<el-link v-if="column.link && row[column.prop]" type="primary" @click="openWaybill(row)">
<el-link
v-if="column.link && row[column.prop]"
type="primary"
@click="openWaybill(row)"
>
{{ row[column.prop] }}
</el-link>
<el-link
@@ -106,7 +110,7 @@
<span v-else>{{ row[column.prop] || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<div class="exception-disposal-page__actions">
<template v-if="row.disposalStatus !== 'completed'">
@@ -117,6 +121,13 @@
>
跟进
</el-link>
<el-link
v-if="permission.exception_disposal_edit"
type="primary"
@click="openDisposal(row, 'edit')"
>
编辑
</el-link>
<el-link
v-if="permission.exception_disposal_complete"
type="primary"
@@ -125,13 +136,22 @@
完成
</el-link>
</template>
<el-link
v-else-if="permission.exception_disposal_view"
type="primary"
@click="openDisposal(row, 'detail')"
>
详情
</el-link>
<template v-else>
<el-link
v-if="permission.exception_disposal_view"
type="primary"
@click="openDisposal(row, 'detail')"
>
详情
</el-link>
<el-link
v-if="permission.exception_disposal_edit"
type="primary"
@click="openDisposal(row, 'edit')"
>
编辑
</el-link>
</template>
</div>
</template>
</el-table-column>
@@ -151,76 +171,191 @@
<el-dialog
v-model="dialog.visible"
:title="dialog.mode === 'detail' ? '异常详情' : '异常跟进'"
width="960px"
:title="dialogTitle"
:width="dialog.mode === 'edit' ? '1080px' : '960px'"
top="6vh"
append-to-body
destroy-on-close
>
<div v-loading="dialog.loading" class="exception-disposal-page__dialog">
<div class="dialog-section-title">异常信息</div>
<dl class="exception-disposal-page__detail-grid">
<template v-for="field in detailFields" :key="field.prop">
<div :class="{ 'is-wide': field.span === 2 }">
<dt>{{ field.label }}</dt>
<dd>
<el-link v-if="field.link && detail[field.prop]" type="primary">
{{ detail[field.prop] }}
</el-link>
<span v-else>{{ detail[field.prop] || '-' }}</span>
</dd>
</div>
</template>
</dl>
<div class="dialog-section-title">现场照片</div>
<div class="exception-disposal-page__photos">
<el-image
v-for="(url, index) in photoUrls"
:key="url"
:src="url"
:preview-src-list="photoUrls"
:initial-index="index"
fit="cover"
/>
<el-empty v-if="!photoUrls.length" description="暂无现场照片" :image-size="56" />
</div>
<!-- 编辑态:只呈现运单信息与可编辑项,不再重复详情页的只读信息 -->
<el-form
v-if="dialog.mode === 'follow'"
ref="followFormRef"
:model="followForm"
:rules="followRules"
v-if="dialog.mode === 'edit'"
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-position="right"
label-width="auto"
class="exception-disposal-page__follow-form"
label-width="96px"
>
<el-form-item label="跟进说明" prop="followContent">
<el-input
v-model="followForm.followContent"
type="textarea"
maxlength="500"
show-word-limit
:rows="2"
placeholder="请输入"
/>
</el-form-item>
<section-card title="运单信息">
<el-descriptions :column="3" border size="small">
<el-descriptions-item
v-for="field in editWaybillFields"
:key="field.prop"
:label="field.label"
>
{{ detailValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="异常信息">
<el-row :gutter="18">
<el-col :span="12">
<el-form-item label="异常类型" prop="exceptionType">
<el-select
v-model="editForm.exceptionType"
placeholder="请选择"
@change="handleEditTypeChange"
>
<el-option
v-for="item in exceptionTypeOptions"
: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="editForm.exceptionReason"
clearable
placeholder="请选择"
:disabled="!editForm.exceptionType"
>
<el-option
v-for="item in editReasonOptions"
: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="editForm.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="exception-disposal-page__edit-photos">
<div
v-for="(url, index) in editForm.scenePhotoList"
:key="url"
class="exception-disposal-page__edit-photo"
>
<el-image
:src="url"
:preview-src-list="editForm.scenePhotoList"
:initial-index="index"
fit="cover"
/>
<el-link type="danger" :underline="false" @click="removeEditPhoto(index)"
>删除</el-link
>
</div>
<el-upload
action="/api/blade-resource/oss/endpoint/put-file"
name="file"
:headers="uploadHeaders"
:show-file-list="false"
:before-upload="beforePhotoUpload"
:on-success="handlePhotoSuccess"
:on-error="handlePhotoError"
>
<div class="exception-disposal-page__edit-photo-add">+ 添加</div>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
</section-card>
</el-form>
<div class="dialog-section-title">历史跟进</div>
<el-table :data="detail.followRecords || []" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
v-for="column in followColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
/>
</el-table>
<template v-else>
<div class="dialog-section-title">异常信息</div>
<dl class="exception-disposal-page__detail-grid">
<template v-for="field in detailFields" :key="field.prop">
<div :class="{ 'is-wide': field.span === 2 }">
<dt>{{ field.label }}</dt>
<dd>
<el-link v-if="field.link && detail[field.prop]" type="primary">
{{ detail[field.prop] }}
</el-link>
<span v-else>{{ detail[field.prop] || '-' }}</span>
</dd>
</div>
</template>
</dl>
<div class="dialog-section-title">现场照片</div>
<div class="exception-disposal-page__photos">
<el-image
v-for="(url, index) in photoUrls"
:key="url"
:src="url"
:preview-src-list="photoUrls"
:initial-index="index"
fit="cover"
/>
<el-empty v-if="!photoUrls.length" description="暂无现场照片" :image-size="56" />
</div>
<el-form
v-if="dialog.mode === 'follow'"
ref="followFormRef"
:model="followForm"
:rules="followRules"
label-position="right"
label-width="auto"
class="exception-disposal-page__follow-form"
>
<el-form-item label="跟进说明" prop="followContent">
<el-input
v-model="followForm.followContent"
type="textarea"
maxlength="500"
show-word-limit
:rows="2"
placeholder="请输入"
/>
</el-form-item>
</el-form>
<div class="dialog-section-title">历史跟进</div>
<el-table :data="detail.followRecords || []" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
v-for="column in followColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
/>
</el-table>
</template>
</div>
<template #footer>
<el-button
v-if="dialog.mode === 'edit' && permission.exception_disposal_edit"
type="primary"
:loading="dialog.submitting"
@click="submitEdit"
>
提交
</el-button>
<el-button
v-if="dialog.mode === 'follow' && permission.exception_disposal_follow"
type="primary"
@@ -249,11 +384,15 @@ import { mapGetters } from 'vuex';
import {
detailFields,
disposalStatusOptions,
editWaybillFields,
exceptionCategoryOptions,
exceptionReasonOptions,
exceptionTypeOptions,
followColumns,
searchFields,
tableColumns,
} from '@/option/transit/exception-disposal';
import { getUploadHeaders } from '@/utils/upload';
import * as api from '@/api/transit/exception-disposal';
export default {
@@ -268,6 +407,8 @@ export default {
tableColumns,
detailFields,
followColumns,
exceptionTypeOptions,
editWaybillFields,
loading: false,
query: {},
rows: [],
@@ -283,10 +424,34 @@ export default {
{ max: 500, message: '跟进说明不能超过500个字符', trigger: 'blur' },
],
},
editForm: {
exceptionType: '',
exceptionReason: '',
reportDescription: '',
scenePhotoList: [],
},
editRules: {
exceptionType: [{ required: true, message: '请选择异常类型', trigger: 'change' }],
reportDescription: [
{ required: true, message: '请输入上报说明', trigger: 'blur' },
{ max: 500, message: '上报说明不能超过500个字符', trigger: 'blur' },
],
},
};
},
computed: {
...mapGetters(['permission']),
dialogTitle() {
return (
{ detail: '异常详情', follow: '异常跟进', edit: '编辑异常' }[this.dialog.mode] || '异常详情'
);
},
editReasonOptions() {
return exceptionReasonOptions(this.editForm.exceptionType);
},
uploadHeaders() {
return getUploadHeaders();
},
visibleSearchFields() {
return this.searchExpanded ? this.searchFields : this.searchFields.slice(0, 4);
},
@@ -342,10 +507,79 @@ export default {
try {
const res = await api.getDetail(row.id);
this.detail = this.decorateRow(res.data?.data || res.data || res || {});
if (mode === 'edit') {
this.editForm = {
exceptionType: this.detail.exceptionType || '',
exceptionReason: this.detail.exceptionReason || '',
reportDescription: this.detail.reportDescription || '',
scenePhotoList: this.parsePhotos(this.detail.scenePhotos),
};
}
} finally {
this.dialog.loading = false;
}
},
detailValue(prop) {
const value = this.detail[prop];
return value === null || value === undefined || value === '' ? '-' : value;
},
handleEditTypeChange() {
// 一级类型变了,旧二级可能不属于新类型
const stillValid = this.editReasonOptions.some(
item => item.value === this.editForm.exceptionReason
);
if (!stillValid) {
this.editForm.exceptionReason = '';
}
},
beforePhotoUpload(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;
},
handlePhotoSuccess(res) {
if (res.code === 200 && res.data) {
const url = res.data.link || res.data.url || res.data.domain || '';
if (url) {
this.editForm.scenePhotoList = [...this.editForm.scenePhotoList, url];
}
} else {
this.$message.error(res.msg || '上传失败');
}
},
handlePhotoError() {
this.$message.error('上传失败');
},
removeEditPhoto(index) {
const list = [...this.editForm.scenePhotoList];
list.splice(index, 1);
this.editForm.scenePhotoList = list;
},
async submitEdit() {
await this.$refs.editFormRef.validate();
this.dialog.submitting = true;
try {
await api.update({
id: this.detail.id,
exceptionType: this.editForm.exceptionType,
exceptionReason: this.editForm.exceptionReason,
reportDescription: this.editForm.reportDescription,
scenePhotos: this.editForm.scenePhotoList.join(','),
});
this.$message.success('编辑成功');
this.dialog.visible = false;
this.loadTable();
} finally {
this.dialog.submitting = false;
}
},
async submitFollow() {
await this.$refs.followFormRef.validate();
this.dialog.submitting = true;
@@ -400,11 +634,15 @@ export default {
};
},
exceptionName(value) {
for (const group of exceptionCategoryOptions) {
const match = group.children.find(item => item.value === 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 || '-';
return value;
},
statusLinkType(status) {
return status === 'completed' ? 'info' : 'primary';
@@ -565,6 +803,47 @@ export default {
margin-top: 16px;
}
.exception-disposal-page__dialog :deep(.section-card + .section-card) {
margin-top: 16px;
}
.exception-disposal-page__edit-photos {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.exception-disposal-page__edit-photo {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
:deep(.el-image) {
width: 96px;
height: 96px;
border: 1px solid #eff1f7;
}
}
.exception-disposal-page__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;
}
}
@media screen and (max-width: 1200px) {
.exception-disposal-page__search-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));