8883186344
- 新增全局 `.status-text` 样式,去除 el-tag 背景和边框,仅保留彩色文字和跟随文字颜色的圆点 - 业务状态相关的 el-tag 标签全部加上 `class="status-text"`,覆盖调度状态、审批状态、运单状态等全站业务状态展示 - 统一发货模板查看弹窗的卡片样式,新增分区块样式与运费信息展示板块 - 调整发货模板弹窗详情 label 宽度,所有 el-descriptions 的 label 宽度统一为 140px 以保证视觉一致 - 表格列内嵌输入控件全宽默认样式生效,无需单独设置跨度,解决输入框显示不足问题 - 弹窗中的部分表单和导入区域使用 `section-card` 统一卡片风格 - 客商档案查看模式改为独立路由,实现查看与编辑隔离并调整对应操作按钮文案 - 相关样式调整及细节优化,包括运费单位颜色等辅助样式,保证界面细节统一规范
568 lines
18 KiB
Vue
568 lines
18 KiB
Vue
<template>
|
||
<basic-container class="master-order-page">
|
||
<template v-if="mode === 'list'">
|
||
<el-form :model="query" class="master-search" label-width="160px" @submit.prevent>
|
||
<el-row :gutter="16">
|
||
<el-col v-for="field in primaryFields" :key="field.prop" :span="6"
|
||
><el-form-item :label="field.label"
|
||
><el-input v-model="query[field.prop]" placeholder="请输入" /></el-form-item
|
||
></el-col>
|
||
<el-col :span="6"
|
||
><el-form-item label="状态"
|
||
><el-select v-model="query.businessStatus" placeholder="全部"
|
||
><el-option label="全部" value="" /><el-option
|
||
v-for="item in statuses"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value" /></el-select></el-form-item
|
||
></el-col>
|
||
<template v-if="searchExpanded">
|
||
<el-col v-for="field in secondaryFields" :key="field.prop" :span="6"
|
||
><el-form-item :label="field.label"
|
||
><el-input v-model="query[field.prop]" placeholder="请输入" /></el-form-item
|
||
></el-col>
|
||
<el-col :span="6"
|
||
><el-form-item label="计划开始日期"
|
||
><el-date-picker
|
||
v-model="query.planStartRange"
|
||
type="datetimerange"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
start-placeholder="请选择"
|
||
end-placeholder="请选择" /></el-form-item
|
||
></el-col>
|
||
<el-col :span="6"
|
||
><el-form-item label="计划结束日期"
|
||
><el-date-picker
|
||
v-model="query.planEndRange"
|
||
type="datetimerange"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
start-placeholder="请选择"
|
||
end-placeholder="请选择" /></el-form-item
|
||
></el-col>
|
||
</template>
|
||
<el-col :span="24" class="search-actions"
|
||
><el-button type="primary" @click="search">查询</el-button
|
||
><el-button @click="reset">重置</el-button
|
||
><el-link type="primary" @click="searchExpanded = !searchExpanded"
|
||
><el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon
|
||
>{{ searchExpanded ? '折叠' : '展开' }}</el-link
|
||
></el-col
|
||
>
|
||
</el-row>
|
||
</el-form>
|
||
<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>
|
||
<div v-loading="loading" class="master-cards">
|
||
<article v-for="row in records" :key="row.id" class="master-card">
|
||
<section class="card-info">
|
||
<div class="card-title-row">
|
||
<el-link type="primary" @click="goDetail(row.id)">{{ row.masterNo }}</el-link
|
||
><el-tag :type="statusType(row.businessStatus)" class="status-tag status-text">{{
|
||
statusName(row.businessStatus)
|
||
}}</el-tag>
|
||
</div>
|
||
<p class="project-name">{{ row.projectName || '-' }}</p>
|
||
<p>{{ timeRange(row) }}</p>
|
||
<p>{{ goodsSummary(row) }}</p>
|
||
</section>
|
||
<section class="route-progress">
|
||
<div class="route-track">
|
||
<template v-for="(node, index) in routeNodes(row)" :key="node.key">
|
||
<div class="route-point">
|
||
<span :class="['route-node', node.type]">{{ node.text }}</span>
|
||
<div class="route-detail">
|
||
<el-tooltip placement="top"><template #content>{{ node.name }}</template><strong>{{ node.name }}</strong></el-tooltip>
|
||
<span v-for="line in node.lines" :key="line">{{ line }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="index < routeNodes(row).length - 1" class="route-connector">
|
||
<small>{{ (row.routeProgress || [])[index]?.transportType }}</small>
|
||
<el-progress
|
||
:percentage="progress((row.routeProgress || [])[index], row.totalQuantity)"
|
||
:show-text="false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
<aside class="card-actions">
|
||
<header>操作</header>
|
||
<el-link
|
||
v-for="action in actions(row)"
|
||
:key="action.text"
|
||
:type="action.danger ? 'danger' : 'primary'"
|
||
@click="action.run"
|
||
>{{ action.text }}</el-link
|
||
>
|
||
</aside>
|
||
</article>
|
||
<el-empty v-if="!loading && !records.length" description="暂无数据" />
|
||
</div>
|
||
<div class="page-bar">
|
||
<el-pagination
|
||
v-model:current-page="page.current"
|
||
v-model:page-size="page.size"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="prev, pager, next, total, sizes"
|
||
:total="page.total"
|
||
@current-change="load"
|
||
@size-change="sizeChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
<template v-else-if="mode === 'editor'">
|
||
<div class="archive-page-form__title">{{ masterEditorTitle }}</div>
|
||
<master-order-editor
|
||
:id="routeId"
|
||
@back="goList"
|
||
@dispatch="goDispatch"
|
||
/>
|
||
</template>
|
||
<master-order-dispatch v-else-if="mode === 'dispatch'" :id="routeId" @back="goList" />
|
||
<master-order-detail v-else :id="routeId" @back="goList" />
|
||
<el-dialog v-model="confirm.visible" title="提示" width="400px"
|
||
><span>{{ confirm.message }}</span
|
||
><template #footer
|
||
><el-button @click="confirm.visible = false">关闭</el-button
|
||
><el-button type="primary" @click="confirm.run">确认</el-button></template
|
||
></el-dialog
|
||
>
|
||
</basic-container>
|
||
</template>
|
||
|
||
<script>
|
||
import * as api from '@/api/business/master-order';
|
||
import { ArrowDown, ArrowUp } from '@element-plus/icons-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 },
|
||
data() {
|
||
return {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
searchExpanded: false,
|
||
loading: false,
|
||
records: [],
|
||
query: { businessStatus: '' },
|
||
page: { current: 1, size: 10, total: 0 },
|
||
confirm: { visible: false, message: '', run: () => {} },
|
||
statuses: [
|
||
{ label: '草稿', value: 'draft' },
|
||
{ label: '待调度', value: 'waiting_dispatch' },
|
||
{ label: '调度中', value: 'dispatching' },
|
||
{ label: '调度完成', value: 'completed' },
|
||
{ label: '调度关闭', value: 'closed' },
|
||
],
|
||
primaryFields: [
|
||
{ label: '总单号', prop: 'masterNos' },
|
||
{ label: '项目名称', prop: 'projectName' },
|
||
{ label: '客户名称', prop: 'customerName' },
|
||
],
|
||
secondaryFields: [
|
||
{ label: '货物名称', prop: 'cargoName' },
|
||
{ label: '货物类型', prop: 'cargoType' },
|
||
{ label: '发货地址', prop: 'departureAddress' },
|
||
{ label: '收货地址', prop: 'arrivalAddress' },
|
||
],
|
||
};
|
||
},
|
||
computed: {
|
||
mode() {
|
||
return this.$route.query.mode || 'list';
|
||
},
|
||
routeId() {
|
||
return this.$route.query.id;
|
||
},
|
||
masterEditorTitle() {
|
||
return this.routeId ? '编辑总单' : '新增总单';
|
||
},
|
||
},
|
||
watch: {
|
||
'$route.query': {
|
||
immediate: true,
|
||
handler() {
|
||
this.syncTagTitle();
|
||
if (this.mode === 'list') this.load();
|
||
},
|
||
},
|
||
},
|
||
methods: {
|
||
async load() {
|
||
this.loading = true;
|
||
const params = { ...this.query };
|
||
if (params.planStartRange) [params.planStartTime] = params.planStartRange;
|
||
if (params.planEndRange) [, params.planEndTime] = params.planEndRange;
|
||
try {
|
||
const res = await api.getList(this.page.current, this.page.size, params);
|
||
const data = res.data?.data || res.data || res;
|
||
this.records = data.records || [];
|
||
this.page.total = data.total || 0;
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
search() {
|
||
this.page.current = 1;
|
||
this.load();
|
||
},
|
||
reset() {
|
||
this.query = { businessStatus: '' };
|
||
this.search();
|
||
},
|
||
sizeChange() {
|
||
this.page.current = 1;
|
||
this.load();
|
||
},
|
||
goList() {
|
||
this.$router.push('/business/master-order');
|
||
},
|
||
goCreate(id) {
|
||
this.$router.push({ path: '/business/master-order', query: { mode: 'editor', id } });
|
||
},
|
||
goDetail(id) {
|
||
this.$router.push({ path: '/business/master-order', query: { mode: 'detail', id } });
|
||
},
|
||
goDispatch(id) {
|
||
this.$router.push({ path: '/business/master-order', query: { mode: 'dispatch', id } });
|
||
},
|
||
syncTagTitle() {
|
||
const title = this.mode === 'detail'
|
||
? '总单详情'
|
||
: this.mode === 'dispatch'
|
||
? '多联调度'
|
||
: this.mode === 'editor'
|
||
? (this.routeId ? '编辑总单' : '新增总单')
|
||
: '总单管理';
|
||
this.$store.commit('SET_TAG', { fullPath: this.$route.fullPath, name: title });
|
||
this.$router.$avueRouter.setTitle(title);
|
||
},
|
||
actions(row) {
|
||
const result = [
|
||
{
|
||
text: '复制',
|
||
run: async () => {
|
||
const res = await api.copy(row.id);
|
||
this.$message.success(`复制成功,新总单号:${(res.data || res).masterNo}`);
|
||
this.load();
|
||
},
|
||
},
|
||
];
|
||
if (['draft', 'waiting_dispatch', 'dispatching', 'completed'].includes(row.businessStatus))
|
||
result.unshift({ text: '编辑', run: () => this.goCreate(row.id) });
|
||
if (['waiting_dispatch', 'dispatching'].includes(row.businessStatus))
|
||
result.unshift({ text: '调度', run: () => this.goDispatch(row.id) });
|
||
if (row.businessStatus !== 'draft')
|
||
result.unshift({ text: '详情', run: () => this.goDetail(row.id) });
|
||
if (['dispatching', 'completed'].includes(row.businessStatus))
|
||
result.push({
|
||
text: '关闭调度',
|
||
run: () =>
|
||
this.confirmAction('确认关闭调度?关闭后将不可恢复!', () => api.closeDispatch(row.id)),
|
||
});
|
||
if (['draft', 'waiting_dispatch'].includes(row.businessStatus))
|
||
result.push({
|
||
text: '删除',
|
||
danger: true,
|
||
run: () =>
|
||
this.confirmAction('确认删除这条数据?删除后将不可恢复!', () => api.remove(row.id)),
|
||
});
|
||
return result;
|
||
},
|
||
confirmAction(message, request) {
|
||
this.confirm = {
|
||
visible: true,
|
||
message,
|
||
run: async () => {
|
||
await request();
|
||
this.confirm.visible = false;
|
||
this.$message.success('操作成功');
|
||
this.load();
|
||
},
|
||
};
|
||
},
|
||
statusName(value) {
|
||
return (this.statuses.find(item => item.value === value) || {}).label || '-';
|
||
},
|
||
statusType(value) {
|
||
return {
|
||
draft: 'info',
|
||
waiting_dispatch: 'warning',
|
||
dispatching: 'success',
|
||
completed: 'primary',
|
||
closed: 'danger',
|
||
}[value];
|
||
},
|
||
timeRange(row) {
|
||
return row.planStartTime && row.planEndTime
|
||
? `${String(row.planStartTime).slice(0, 16)} - ${String(row.planEndTime).slice(0, 16)}`
|
||
: '-';
|
||
},
|
||
goodsSummary(row) {
|
||
const item = (row.goods || [])[0] || {};
|
||
if (!item.cargoName && !item.cargoType) return '-';
|
||
return `${item.cargoName || '-'},${item.cargoType || '-'} | ${this.number(row.totalQuantity)} 吨`;
|
||
},
|
||
number(value) {
|
||
return Number(value || 0).toFixed(2);
|
||
},
|
||
routeNumber(value) {
|
||
const number = Number(value || 0);
|
||
return Number.isInteger(number) ? String(number) : number.toFixed(2);
|
||
},
|
||
routeArrivedQuantity(route) {
|
||
const keys = [
|
||
'arrivedQuantity',
|
||
'arrivalQuantity',
|
||
'arriveQuantity',
|
||
'reachedQuantity',
|
||
'receiveQuantity',
|
||
'unloadQuantity',
|
||
];
|
||
const key = keys.find(item => route[item] !== undefined && route[item] !== null);
|
||
return key ? route[key] : 0;
|
||
},
|
||
routeNodes(row = {}) {
|
||
const routes = row.routeProgress || [];
|
||
const total = this.routeNumber(row.totalQuantity);
|
||
if (!routes.length) {
|
||
return [
|
||
{
|
||
key: 'start',
|
||
type: 'start',
|
||
text: '起',
|
||
name: row.departureName || row.departureAddress || '-',
|
||
lines: [`已调度0/${total}吨`],
|
||
},
|
||
{
|
||
key: 'end',
|
||
type: 'end',
|
||
text: '终',
|
||
name: row.arrivalName || row.arrivalAddress || '-',
|
||
lines: [`到达0/${total}吨`],
|
||
},
|
||
];
|
||
}
|
||
return [
|
||
{
|
||
key: 'start',
|
||
type: 'start',
|
||
text: '起',
|
||
name: row.departureName || row.departureAddress || '-',
|
||
lines: [`已调度${this.routeNumber(routes[0]?.dispatchedQuantity)}/${total}吨`],
|
||
},
|
||
...routes.map((route, index) => {
|
||
const isEnd = index === routes.length - 1;
|
||
const arrived = this.routeNumber(this.routeArrivedQuantity(route));
|
||
const dispatched = this.routeNumber(routes[index + 1]?.dispatchedQuantity);
|
||
return {
|
||
key: route.segmentNo || `route-${index}`,
|
||
type: isEnd ? 'end' : 'middle',
|
||
text: isEnd ? '终' : '经',
|
||
name: route.arrivalName || route.departureName || row.arrivalName || '-',
|
||
lines: isEnd
|
||
? [`到达${arrived}/${total}吨`]
|
||
: [`到达 ${arrived}/${total}吨`, `已调度 ${dispatched}/到达${arrived}/${total}吨`],
|
||
};
|
||
}),
|
||
];
|
||
},
|
||
progress(route, total) {
|
||
return total
|
||
? Math.min(100, (Number(route?.dispatchedQuantity || 0) / Number(total)) * 100)
|
||
: 0;
|
||
},
|
||
async download() {
|
||
const response = await api.exportList(this.query);
|
||
const blob = response.data || response;
|
||
if (!(blob instanceof Blob) || !blob.size) {
|
||
this.$message.error('导出失败,未生成有效文件');
|
||
return;
|
||
}
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = '总单运单明细.xlsx';
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.master-search {
|
||
margin-bottom: 12px;
|
||
padding: 12px 12px 4px;
|
||
background: #fff;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||
.el-form-item {
|
||
margin-bottom: 8px;
|
||
}
|
||
.el-input,
|
||
.el-select,
|
||
.el-date-editor {
|
||
width: 100%;
|
||
}
|
||
.search-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-height: 40px;
|
||
.el-icon {
|
||
margin-right: 4px;
|
||
}
|
||
}
|
||
}
|
||
.master-list-panel {
|
||
.toolbar {
|
||
padding: 8px 0;
|
||
}
|
||
}
|
||
.master-card {
|
||
display: flex;
|
||
min-width: 0;
|
||
min-height: 150px;
|
||
margin-bottom: 8px;
|
||
border: 1px solid #eff1f7;
|
||
background: #fff;
|
||
.card-info {
|
||
flex: 0 0 266px;
|
||
padding: 18px 20px;
|
||
font-size: 14px;
|
||
color: #303133;
|
||
.card-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
p {
|
||
margin: 18px 0 0;
|
||
line-height: 1.4;
|
||
}
|
||
.project-name {
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
.status-tag {
|
||
flex-shrink: 0;
|
||
font-weight: 600;
|
||
}
|
||
.route-progress {
|
||
flex: 1 1 auto;
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
align-items: center;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
min-height: 170px;
|
||
padding: 22px 24px 18px;
|
||
border-right: 1px solid #eff1f7;
|
||
border-left: 1px solid #eff1f7;
|
||
}
|
||
.route-track {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
min-width: max-content;
|
||
}
|
||
.route-point {
|
||
width: 150px;
|
||
flex: 0 0 150px;
|
||
text-align: center;
|
||
}
|
||
.route-node {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 6px;
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
text-align: center;
|
||
&.start {
|
||
background: #409eff;
|
||
}
|
||
&.middle {
|
||
background: #67c23a;
|
||
}
|
||
&.end {
|
||
background: #e6a23c;
|
||
}
|
||
}
|
||
.route-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-top: 12px;
|
||
color: #303133;
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
white-space: nowrap;
|
||
strong {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
max-width: 180px;
|
||
}
|
||
}
|
||
.route-connector {
|
||
width: clamp(180px, 20vw, 266px);
|
||
flex: 0 0 clamp(180px, 20vw, 266px);
|
||
padding-top: 2px;
|
||
margin: 0 -6px;
|
||
small {
|
||
display: block;
|
||
text-align: center;
|
||
margin-bottom: 6px;
|
||
color: #a8abb2;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
}
|
||
:deep(.el-progress-bar__outer) {
|
||
height: 6px !important;
|
||
background-color: #e4e7ed;
|
||
}
|
||
:deep(.el-progress-bar__inner) {
|
||
background-color: #409eff;
|
||
}
|
||
}
|
||
.card-actions {
|
||
width: 88px;
|
||
padding: 16px 12px;
|
||
header {
|
||
margin-bottom: 10px;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
}
|
||
.el-link {
|
||
display: flex;
|
||
width: 100%;
|
||
margin: 0 0 8px;
|
||
}
|
||
}
|
||
}
|
||
.page-bar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
padding-top: 8px;
|
||
}
|
||
</style>
|