Files
tms-erp-web/src/views/business/master-order.vue
T
2026-09-16 06:30:30 +08:00

770 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<basic-container class="master-order-page">
<template v-if="mode === 'list'">
<div class="master-order-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="master-order-page__search-grid">
<el-form-item v-for="field in primaryFields" :key="field.prop" :label="field.label">
<el-input v-model="query[field.prop]" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.businessStatus" clearable 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>
<template v-if="searchExpanded">
<el-form-item v-for="field in secondaryFields" :key="field.prop" :label="field.label">
<el-input v-model="query[field.prop]" clearable placeholder="请输入" />
</el-form-item>
<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-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>
</template>
<div class="master-order-page__search-actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-link type="primary" @click="searchExpanded = !searchExpanded">{{
searchExpanded ? '收起' : '展开'
}}</el-link>
</div>
</div>
</el-form>
</div>
<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
:content="node.address || node.name || '-'"
placement="top"
:show-after="120"
>
<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" />
<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 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 {
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 (row.businessStatus === 'dispatching')
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, 10)} - ${String(row.planEndTime).slice(0, 10)}`
: '-';
},
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;
},
isRoadTransportType(type) {
const value = String(type || '').trim().toLowerCase();
return value === 'road' || value.includes('公路');
},
isStationLikeName(value) {
const text = String(value || '').trim();
if (!text) return false;
return /(?:火车站|高铁站|客运站|货运站|港口|码头|机场|空港|航站楼)$/.test(text) || /(?<!市|州|盟|区|县|旗)站$/.test(text);
},
isNonRoadLocation({ name, siteCode, transportType, nextTransportType } = {}) {
const code = String(siteCode || '').trim();
if (code && code !== '/') return true;
if (this.isStationLikeName(name)) return true;
if (transportType && !this.isRoadTransportType(transportType)) return true;
if (nextTransportType && !this.isRoadTransportType(nextTransportType)) return true;
return false;
},
formatRoadAddress(value) {
const text = String(value || '').replace(/\s+/g, '');
if (!text) return '-';
// 公路地址按“市 区县”展示,兼容省市区拼接及历史上只保存市/区县的值。
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
// “梅州市”同时包含“州”和“市”,避免误把城市截断为“梅州”。
const cityMatch = withoutProvince.match(/.+?(?:市|州(?!市)|盟(?!市))/);
if (!cityMatch) return text;
const city = cityMatch[0];
const districtSource = withoutProvince.slice(city.length);
const districtMatch = districtSource.match(/^[^市州盟]*?(?:区|县|旗)/);
if (!districtMatch) return city;
// 兼容“梅县区”这类名称,正则首次命中“县”后还需保留后面的“区”。
const district = `${districtMatch[0]}${
districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''
}`;
return `${city} ${district}`;
},
routeNodeName(value, address, transportType, region = {}) {
const text = String(value || '').trim();
if (!text) return '-';
if (
this.isNonRoadLocation({
name: text,
siteCode: region.siteCode,
transportType,
nextTransportType: region.nextTransportType,
})
) {
return text;
}
// 优先从完整的地址名称/详细地址解析,避免后端只返回“梅州”等不完整的市名称时
// 提前返回城市,导致区县被遗漏。
const parsedText = this.formatRoadAddress(text);
if (parsedText.includes(' ')) return parsedText;
const parsedAddress = this.formatRoadAddress(address);
if (parsedAddress.includes(' ')) return parsedAddress;
const city = String(region.cityName || '').trim();
const district = String(region.districtName || '').trim();
if (city && district) return `${city} ${district}`;
if (city) return city;
if (district && address) {
const formattedAddress = this.formatRoadAddress(address);
const addressCity = formattedAddress.split(' ')[0];
if (addressCity && addressCity !== formattedAddress) return `${addressCity} ${district}`;
}
const isRegional =
/省|自治区|特别行政区/.test(text) ||
/(?:市|州|盟).*?(?:区|县|旗)/.test(text) ||
/(?:区|县|旗)$/.test(text);
if (!isRegional) return text;
// 区县简称(如“西乡塘区”)从详细地址中补齐所属城市。
const source = /省|自治区|特别行政区|市|州|盟/.test(text) ? text : address || text;
const formatted = this.formatRoadAddress(source);
if (/(?:区|县|旗)$/.test(text) && !formatted.includes(text)) {
const cityName = formatted.split(' ')[0];
return cityName && cityName !== formatted ? `${cityName} ${text}` : formatted;
}
return formatted;
},
resolveRouteNodeLocation({
name,
address,
transportType,
nextTransportType,
cityName,
districtName,
siteCode,
} = {}) {
const rawName = String(name || '').trim();
const rawAddress = String(address || '').trim();
const regionText = [cityName, districtName].filter(Boolean).join(' ');
const nonRoad = this.isNonRoadLocation({
name: rawName,
siteCode,
transportType,
nextTransportType,
});
if (nonRoad) {
// 非公路:展示站点名称,悬停展示详细地址。
// 兼容历史数据把站点名称写在 address、区域信息写在 name 的情况。
if (this.isStationLikeName(rawAddress) && !this.isStationLikeName(rawName)) {
return {
name: rawAddress,
address: rawName || regionText || rawAddress,
};
}
const displayName = rawName || rawAddress || '-';
const tipAddress =
(rawAddress && rawAddress !== displayName ? rawAddress : '') ||
regionText ||
rawAddress ||
displayName;
return { name: displayName, address: tipAddress };
}
return {
name: this.routeNodeName(rawName || rawAddress, rawAddress, transportType, {
cityName,
districtName,
siteCode,
nextTransportType,
}),
address: rawAddress || rawName || regionText || '-',
};
},
routeNodes(row = {}) {
const routes = row.routeProgress || [];
const total = this.routeNumber(row.totalQuantity);
const firstTransportType = routes[0]?.transportType || row.routes?.[0]?.transportType || row.transportType || '';
if (!routes.length) {
const start = this.resolveRouteNodeLocation({
name: row.departureName || row.departureAddress,
address: row.departureAddress,
transportType: firstTransportType,
cityName: row.departureCityName,
districtName: row.departureDistrictName,
siteCode: row.departureSiteCode,
});
const end = this.resolveRouteNodeLocation({
name: row.arrivalName || row.arrivalAddress,
address: row.arrivalAddress,
transportType: row.finalTransportType || firstTransportType,
cityName: row.arrivalCityName,
districtName: row.arrivalDistrictName,
siteCode: row.arrivalSiteCode,
});
return [
{
key: 'start',
type: 'start',
text: '起',
...start,
lines: [`已调度0/${total}吨`],
},
{
key: 'end',
type: 'end',
text: '终',
...end,
lines: [`到达0/${total}吨`],
},
];
}
const start = this.resolveRouteNodeLocation({
name: row.departureName || row.departureAddress,
address: row.departureAddress,
transportType: firstTransportType,
nextTransportType: firstTransportType,
cityName: row.departureCityName,
districtName: row.departureDistrictName,
siteCode: row.departureSiteCode,
});
return [
{
key: 'start',
type: 'start',
text: '起',
...start,
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);
const address =
route.arrivalAddress ||
route.departureAddress ||
(isEnd ? row.arrivalAddress : '') ||
'';
const location = this.resolveRouteNodeLocation({
name:
route.arrivalName ||
route.departureName ||
(isEnd ? row.arrivalName : '') ||
'',
address,
transportType: route.transportType,
nextTransportType: isEnd
? row.finalTransportType || route.transportType
: routes[index + 1]?.transportType || '',
cityName:
route.arrivalCityName ||
route.departureCityName ||
(isEnd ? row.arrivalCityName : ''),
districtName:
route.arrivalDistrictName ||
route.departureDistrictName ||
(isEnd ? row.arrivalDistrictName : ''),
siteCode:
route.arrivalSiteCode ||
route.departureSiteCode ||
(isEnd ? row.arrivalSiteCode : ''),
});
return {
key: route.segmentNo || `route-${index}`,
type: isEnd ? 'end' : 'middle',
text: isEnd ? '终' : '经',
...location,
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-order-page {
&__search {
padding: 12px 12px 4px;
margin-bottom: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
&__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
&__search-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
align-items: center;
gap: 8px;
min-height: 32px;
}
:deep(.el-form-item) {
margin-bottom: 8px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-date-editor--datetimerange) {
width: 100%;
}
}
.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 {
display: inline-block;
max-width: 150px;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
cursor: default;
}
}
.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>