Files
tms-erp-web/src/views/business/master-order.vue
T
gxwebsoft 21b7877ff1 feat(business-crud-page): 统一独立表单页顶部页面标题样式及展示
- 独立表单页 shipping-template、transport-plan、waybill-manage 加入统一的 archive-page-form__title 页面标题支持
- 全局样式抽离 archive-page-form__title 至 element-ui.scss,实现统一样式和竖条修饰
- loading-manage/form 页面新增页标题展示,保持风格一致
- master-order 编辑模式下新增页面标题支持,动态显示编辑或新增总单
- business-crud-page 组件新增 formPageTitle 属性,支持父级传递页面标题数据
- 优化多个业务页面表格和弹窗样式,调整列宽及边框圆角阴影,提高视觉一致性
- 删除 customer-archive.vue 中重复本地标题样式定义,使用全局统一样式
- 全站弹窗分组组件 section-card 相关设计规范文档更新,强化统一卡片风格
- 移除 cargo-type.vue 添加 "新建" 按钮逻辑,调整按钮显示权限
- 统一所有新增弹窗的底部操作区浮动效果及内容区最大高度限制,提升交互体验
2026-08-21 11:07:32 +08:00

568 lines
18 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'">
<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">
<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>