1、新增总单

2、fix bug
This commit is contained in:
2026-08-11 23:50:51 +08:00
parent 1f669bfd29
commit 3e585b89fd
11 changed files with 2356 additions and 18 deletions
+549
View File
@@ -0,0 +1,549 @@
<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">{{
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">
<strong>{{ node.name }}</strong>
<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>
<master-order-editor
v-else-if="mode === 'editor'"
:id="routeId"
@back="goList"
@dispatch="goDispatch"
/>
<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;
},
},
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 blob = await api.exportList(this.query);
const url = URL.createObjectURL(new Blob([blob.data || 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: 8px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.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 {
background: #fff;
.toolbar {
padding: 12px 12px 0;
}
}
.master-card {
display: flex;
min-height: 150px;
margin-bottom: 8px;
border: 1px solid #eff1f7;
background: #fff;
.card-info {
flex: 0 0 380px;
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;
}
.route-progress {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
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;
}
}
.route-connector {
width: clamp(180px, 20vw, 344px);
flex: 0 0 clamp(180px, 20vw, 344px);
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: 8px !important;
background-color: #f1f2f5;
}
:deep(.el-progress-bar__inner) {
background-color: #409eff;
}
}
.card-actions {
width: 110px;
padding: 16px;
header {
margin-bottom: 10px;
font-weight: 600;
}
.el-link {
margin: 0 8px 8px 0;
}
}
}
.page-bar {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 8px;
}
</style>