feat: 完成履约管理模块全量功能迭代

本次迭代包含以下核心功能:
1. 新增履约时效总览可视化页面,支持多维度数据统计
2. 实现物料/客户/供应商的Excel批量导入导出功能
3. 新增订单批量结单功能,优化结单流程校验
4. 完善日志配置,新增文件日志落地
5. 修复分类查询逻辑,优化多租户数据隔离
6. 新增甲方履约结单管理页面与权限控制
7. 重构部分Mapper与Service接口,增强代码健壮性
This commit is contained in:
2026-06-18 11:10:36 +08:00
parent 7a8e4297e0
commit 7b71822a32
37 changed files with 1759 additions and 66 deletions

View File

@@ -16,6 +16,8 @@
<el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">搜索</el-button>
<div class="toolbar-right">
<el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd">新增客户</el-button>
<el-button type="info" size="small" icon="el-icon-upload2" @click="handleImport" v-hasPermi="['bid:client:import']">导入</el-button>
<el-button type="warning" size="small" icon="el-icon-download" @click="handleExport" v-hasPermi="['bid:client:export']">导出</el-button>
</div>
</div>
@@ -197,15 +199,27 @@
<el-button @click="detailOpen = false">关闭</el-button>
</div>
</el-dialog>
<!-- Excel 导入对话框 -->
<excel-import-dialog
ref="importRef"
title="客户导入"
action="/bid/client/importData"
template-action="/bid/client/importTemplate"
template-file-name="client_template"
update-support-label="是否更新已经存在的客户数据"
@success="getList" />
</div>
</template>
<script>
import { listClient, getClient, addClient, updateClient, delClient, getClientOrders } from "@/api/bid/client"
import { getDelivery } from "@/api/bid/delivery"
import ExcelImportDialog from "@/components/ExcelImportDialog"
export default {
name: "Client",
components: { ExcelImportDialog },
data() {
return {
activeTab: "list",
@@ -238,6 +252,9 @@ export default {
},
handleSearch() { this.queryParams.pageNum = 1; this.getList(); this.loadClientOptions() },
handleAdd() { this.editId = null; this.form = { grade: "B", status: "0", clientNo: "", clientName: "", contact: "", phone: "", email: "", city: "", address: "", remark: "" }; this.dialogTitle = "新增客户"; this.dialogOpen = true },
// ═══ Excel 导入导出 ═══
handleImport() { this.$refs.importRef.open(); },
handleExport() { this.download('/bid/client/export', { ...this.queryParams }, `client_${new Date().getTime()}.xlsx`); },
handleEdit(row) { this.editId = row.clientId; this.form = { ...row }; this.dialogTitle = "编辑客户"; this.dialogOpen = true },
cancelDialog() { this.dialogOpen = false; this.$refs.form && this.$refs.form.clearValidate() },
submitForm() {

View File

@@ -0,0 +1,234 @@
<template>
<div class="jd-cd-page">
<!-- 统计卡片 -->
<el-row :gutter="12" class="stat-row">
<el-col :span="6" v-for="c in statCards" :key="c.key">
<div class="stat-card">
<div class="stat-body"><div class="stat-num">{{ stats[c.key] != null ? stats[c.key] : '-' }}</div><div class="stat-lbl">{{ c.label }}</div></div>
<i :class="c.icon" class="stat-icon"></i>
</div>
</el-col>
</el-row>
<!-- 筛选栏 -->
<div class="jd-filter">
<div class="filter-left">
<el-input v-model="q.doNo" placeholder="搜索单号" clearable size="small" class="filter-input" @keyup.enter.native="handleSearch" />
<el-input v-model="q.clientName" placeholder="搜索甲方客户" clearable size="small" class="filter-input" @keyup.enter.native="handleSearch" />
<el-select v-model="q.deliveryStatus" placeholder="状态" clearable size="small" style="width:110px" @change="handleSearch">
<el-option label="已签收" value="history" />
<el-option label="已结单" value="closed" />
</el-select>
<el-button type="primary" size="small" @click="handleSearch">搜索</el-button>
<el-button size="small" @click="resetSearch">重置</el-button>
</div>
<div class="filter-right">
<el-button size="small" icon="el-icon-refresh" @click="getList">刷新</el-button>
</div>
</div>
<div class="cd-body">
<!-- 左侧列表 -->
<div class="cd-left">
<div class="left-header">
<span class="left-title">{{ q.deliveryStatus === 'closed' ? '已结单订单' : '已签收订单' }}</span>
<el-tag :type="q.deliveryStatus === 'closed' ? 'info' : 'success'" size="small" effect="dark" style="margin-left:8px">
{{ q.deliveryStatus === 'closed' ? '已结单' : '已签收' }}
</el-tag>
</div>
<el-table ref="table" v-loading="loading" :data="list" border stripe size="small"
@selection-change="onSelectionChange" class="jd-table"
style="width:100%" :row-class-name="rowClass">
<el-table-column type="selection" width="38" align="center" />
<el-table-column label="单号" width="155">
<template slot-scope="s">
<span class="order-link">{{ s.row.doNo }}</span>
</template>
</el-table-column>
<el-table-column label="甲方客户" prop="clientName" min-width="130" show-overflow-tooltip />
<el-table-column label="金额" width="110" align="right">
<template slot-scope="s"><span class="amount">¥{{ s.row.totalAmount }}</span></template>
</el-table-column>
<el-table-column label="交货期" prop="deliveryDate" width="95" align="center" />
<el-table-column :label="q.deliveryStatus === 'closed' ? '结单日期' : '签收日期'" width="115" align="center">
<template slot-scope="s">
<el-date-picker v-if="q.deliveryStatus !== 'closed'" v-model="s.row._editDate" type="date" value-format="yyyy-MM-dd"
size="mini" style="width:105px" placeholder="选择日期" :clearable="true"
@change="onDateChange(s.row)" />
<span v-else class="closed-date">{{ s.row.actualCloseDate ? s.row.actualCloseDate.substring(0, 10) : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="周期" width="75" align="center">
<template slot-scope="s">
<span :class="cycleClass(s.row._cycleDays)">{{ s.row._cycleDays != null ? s.row._cycleDays + '天' : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="配送差异" width="85" align="center">
<template slot-scope="s">
<span :class="diffClass(s.row._diffDays)">{{ s.row._diffDays != null ? diffLabel(s.row._diffDays) : '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && list.length === 0" :description="q.deliveryStatus === 'closed' ? '暂无已结单订单' : '暂无已签收订单'" style="padding:40px 0" />
<pagination v-show="total>0" :total="total" :page.sync="q.pageNum" :limit.sync="q.pageSize" @pagination="getList" />
</div>
<!-- 右侧操作区 (已结单状态下隐藏批量操作) -->
<div v-if="q.deliveryStatus !== 'closed'" class="cd-right">
<div class="right-panel">
<div class="right-title">批量操作</div>
<div class="right-section">
<div class="rs-header">已选择 <b>{{ selected.length }}</b> </div>
<div v-if="selected.length" class="rs-list">
<div v-for="r in selected" :key="r.doId" class="rs-item">{{ r.doNo }} {{ r.clientName }}</div>
</div>
<div v-else class="rs-empty">请在左侧勾选已签收订单</div>
</div>
<div class="right-section">
<div class="rs-header">批量设置签收日期</div>
<div class="rs-date-row">
<el-date-picker v-model="batchDate" type="date" value-format="yyyy-MM-dd" size="small" style="width:140px" placeholder="选择日期" />
<el-button size="small" @click="applyBatchDate" :disabled="!selected.length || !batchDate">应用到选中</el-button>
</div>
<div class="rs-quick">
<el-button size="mini" @click="batchDate = todayStr()">今天</el-button>
<el-button size="mini" @click="batchDate = yesterdayStr()">昨天</el-button>
</div>
</div>
<div class="right-section">
<div class="rs-header">批量确认结单</div>
<el-button type="primary" size="small" style="width:100%" @click="batchConfirm"
:disabled="!selected.length || !allHaveDate">确认结单 ({{ selected.length }})</el-button>
<div v-if="selected.length && !allHaveDate" class="rs-warn">有订单未设置签收日期</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { listDelivery, batchSetCloseDate } from "@/api/bid/delivery"
import request from '@/utils/request'
export default {
name: "ClientCloseDate",
data() {
return {
loading: false, list: [], total: 0, stats: {},
selected: [],
batchDate: null,
q: { pageNum: 1, pageSize: 50, type: "client", deliveryStatus: "history", doNo: "", clientName: "" },
statCards: [
{ key: "totalHistory", label: "待结单数", icon: "el-icon-document", color: "#e4393c" },
{ key: "monthCompleted", label: "本月已结单", icon: "el-icon-circle-check", color: "#67c23a" },
{ key: "totalAmount", label: "结单总金额", icon: "el-icon-money", color: "#e6a23c" },
{ key: "avgDeliveryDays", label: "平均配送周期(天)", icon: "el-icon-time", color: "#8e44ad" }
]
}
},
computed: {
allHaveDate() { return this.selected.every(r => r._editDate) }
},
created() { this.getList(); this.getStats() },
methods: {
getList() {
this.loading = true
listDelivery(this.q).then(r => {
this.list = (r.rows || []).map(d => ({
...d,
deliveryDate: d.deliveryDate ? d.deliveryDate.substring(0, 10) : '',
_editDate: d.actualCloseDate ? d.actualCloseDate.substring(0, 10) : '',
_cycleDays: null,
_diffDays: null
})).map(d => { this.calcRow(d); return d })
this.total = r.total || 0; this.loading = false
}).catch(() => { this.loading = false })
},
getStats() {
request({ url: '/bid/delivery/history/stats?type=client', method: 'get' }).then(r => { this.stats = r.data || {} }).catch(() => {})
},
handleSearch() { this.q.pageNum = 1; this.getList() },
resetSearch() { this.q.doNo = ""; this.q.clientName = ""; this.q.deliveryStatus = "history"; this.handleSearch() },
onSelectionChange(rows) { this.selected = rows },
rowClass({ row }) { return this.selected.includes(row) ? 'selected-row' : '' },
onDateChange(row) { this.calcRow(row) },
calcRow(row) {
if (!row.deliveryDate || !row._editDate) { row._cycleDays = null; row._diffDays = null; return }
const cd = new Date(row._editDate)
const baseDate = row.delayDate || row.deliveryDate
const dd = new Date(baseDate)
row._cycleDays = Math.round((cd - dd) / 86400000)
row._diffDays = row._cycleDays
},
cycleClass(d) { if (d === null) return ''; return d <= 0 ? 'diff-early' : 'diff-late' },
diffClass(d) { if (d === null) return ''; return d <= 0 ? 'diff-early' : 'diff-late' },
diffLabel(d) { if (d === 0) return '准时'; if (d < 0) return '提前' + Math.abs(d) + '天'; return '延期' + d + '天' },
todayStr() { const d = new Date(); return d.toISOString().slice(0, 10) },
yesterdayStr() { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().slice(0, 10) },
applyBatchDate() {
if (!this.batchDate || !this.selected.length) return
this.selected.forEach(r => { r._editDate = this.batchDate; this.calcRow(r) })
this.$modal.msgSuccess("已应用到 " + this.selected.length + " 条")
},
batchConfirm() {
if (!this.selected.length) return
if (!this.allHaveDate) { this.$modal.msgError("有订单未设置签收日期"); return }
this.$modal.confirm("确认批量结单 " + this.selected.length + " 条?").then(() => {
const ids = this.selected.map(r => r.doId)
return batchSetCloseDate(ids, this.selected[0]._editDate)
}).then(() => {
this.$modal.msgSuccess("批量结单成功"); this.getList(); this.getStats(); this.selected = []
}).catch(() => {})
}
}
}
</script>
<style scoped>
.jd-cd-page { padding: 12px; min-height: calc(100vh - 84px); }
.stat-row { margin-bottom: 12px !important; }
.stat-card {
background: #fff; border-radius: 6px; padding: 16px; display: flex; align-items: center; justify-content: space-between;
box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: box-shadow .2s; cursor: default;
}
.stat-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.1); }
.stat-body { flex:1; }
.stat-num { font-size: 26px; font-weight: 700; color: #333333; line-height: 1.2; }
.stat-lbl { font-size: 12px; color: #909399; margin-top: 4px; }
.stat-icon { font-size: 32px; color: #ddd; }
.jd-filter { display: flex; align-items: center; background: #ffffff; padding: 10px 16px; border-radius: 2px; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
.filter-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filter-right { margin-left: auto; }
.filter-input { width: 130px; }
.cd-body { display: flex; gap: 12px; align-items: flex-start; }
.cd-left { flex: 1; background: #fff; border-radius: 2px; border: 1px solid #e5e5e5; padding: 14px; }
.left-header { display: flex; align-items: center; margin-bottom: 12px; }
.left-title { font-size: 14px; font-weight: 700; color: #333; }
.jd-table { border: none !important; }
.cd-right { width: 320px; flex-shrink: 0; background: #fff; border-radius: 2px; border: 1px solid #e5e5e5; padding: 16px; }
.right-title { font-size: 14px; font-weight: 700; color: #333; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid #4A6FA5; }
.right-section { margin-bottom: 20px; }
.rs-header { font-size: 12px; color: #666; margin-bottom: 8px; }
.rs-list { max-height: 150px; overflow-y: auto; border: 1px solid #e5e5e5; border-radius: 2px; padding: 4px; }
.rs-item { padding: 4px 8px; font-size: 12px; color: #333; border-bottom: 1px solid #ffffff; }
.rs-item:last-child { border-bottom: none; }
.rs-empty { text-align: center; padding: 20px; color: #999; font-size: 12px; }
.rs-date-row { display: flex; gap: 6px; margin-bottom: 8px; }
.rs-quick { display: flex; gap: 6px; }
.rs-warn { font-size: 11px; color: #f56c6c; margin-top: 6px; }
.order-link { color: #4A6FA5; cursor: pointer; }
.order-link:hover { color: #4A6FA5; text-decoration: underline; }
::v-deep .selected-row td { background: #f5faff !important; }
.amount { color: #e4393c; font-weight: 700; }
.closed-date { color: #909399; font-size: 12px; }
.diff-early { color: #67c23a; font-weight: 600; }
.diff-late { color: #f56c6c; font-weight: 600; }
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div class="timeline-page">
<!-- 统计卡片 -->
<el-row :gutter="12" class="stat-row">
<el-col :span="6" v-for="c in statCards" :key="c.key">
<div class="stat-card" :style="{borderLeftColor: c.color}">
<div class="stat-num" :style="{color: c.color}">{{ stats[c.key] != null ? stats[c.key] : '-' }}</div>
<div class="stat-lbl">{{ c.label }}</div>
</div>
</el-col>
</el-row>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="query.type" placeholder="履约类型" clearable size="small" style="width:120px" @change="loadData">
<el-option label="供应商履约" value="supplier" />
<el-option label="甲方履约" value="client" />
</el-select>
<el-select v-model="query.status" placeholder="订单状态" clearable size="small" style="width:120px" @change="loadData">
<el-option label="待发" value="pending" />
<el-option label="在途" value="transit" />
<el-option label="已签收" value="history" />
<el-option label="已结单" value="closed" />
</el-select>
<el-date-picker v-model="query.dateFrom" type="date" value-format="yyyy-MM-dd" placeholder="开始日期" size="small" style="width:140px" @change="loadData" />
<el-date-picker v-model="query.dateTo" type="date" value-format="yyyy-MM-dd" placeholder="结束日期" size="small" style="width:140px" @change="loadData" />
<el-button type="primary" size="small" icon="el-icon-search" @click="loadData">查询</el-button>
<el-button size="small" icon="el-icon-refresh" @click="resetSearch">重置</el-button>
<div class="filter-hint">
<span class="hint-dot" style="background:#67c23a"></span> 提前完成
<span class="hint-dot" style="background:#409eff"></span> 准时完成
<span class="hint-dot" style="background:#e6a23c"></span> 临期完成
<span class="hint-dot" style="background:#f56c6c"></span> 逾期完成
<span class="hint-dot" style="background:#909399"></span> 进行中
</div>
</div>
<!-- 甘特图 -->
<div class="chart-wrap">
<div v-if="loading" class="loading-box"><i class="el-icon-loading"></i> 加载中</div>
<div v-else-if="!orders.length" class="no-data">
{{ query.type === 'supplier' ? '暂无供应商履约订单数据' : query.type === 'client' ? '暂无甲方履约订单数据' : '暂无订单数据' }}
</div>
<div v-else ref="ganttChart" style="width:100%;height:600px"></div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getTimeline } from "@/api/bid/timeline"
export default {
name: "ClientDeliveryTimeline",
data() {
return {
loading: false,
orders: [],
stats: {},
// query.type 为空时查全部,可选 supplier/client
query: { type: "", status: "", dateFrom: "", dateTo: "" },
chart: null,
statCards: [
{ key: "onTime", label: "按期完成", color: "#67c23a" },
{ key: "delayed", label: "逾期完成", color: "#f56c6c" },
{ key: "pending", label: "待签收/待结单", color: "#e6a23c" },
{ key: "total", label: "订单总数", color: "#409eff" }
]
}
},
mounted() {
this.loadData()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chart) { this.chart.dispose(); this.chart = null }
},
methods: {
handleResize() {
if (this.chart) this.chart.resize()
},
loadData() {
this.loading = true
getTimeline(this.query).then(r => {
const data = r.data || {}
this.orders = data.orders || []
this.stats = data.stats || {}
this.stats.total = this.orders.length
this.loading = false
this.$nextTick(() => this.renderChart())
}).catch(() => { this.loading = false })
},
resetSearch() {
this.query = { type: "", status: "", dateFrom: "", dateTo: "" }
this.loadData()
},
// ═══ 计算订单履约状态 ═══
calcStatus(o) {
const base = o.delayDate || o.deliveryDate
if (o.actualCloseDate && base) {
const diff = this.daysBetween(base, o.actualCloseDate)
if (diff < 0) return 'early'
if (diff === 0) return 'onTime'
if (diff <= 3) return 'nearlyLate'
return 'overdue'
}
if (o.actualCloseDate && !base) return 'onTime'
if (base) {
const diff = this.daysBetween(base, this.todayStr())
if (diff < 0) return 'overdue'
if (diff <= 3) return 'nearlyLate'
}
return 'pending'
},
daysBetween(d1, d2) {
if (!d1 || !d2) return 0
return Math.round((new Date(d2) - new Date(d1)) / 86400000)
},
todayStr() {
const d = new Date()
return d.toISOString().slice(0, 10)
},
renderChart() {
if (!this.$refs.ganttChart || !this.orders.length) return
if (this.chart) this.chart.dispose()
this.chart = echarts.init(this.$refs.ganttChart, 'macarons')
const raw = this.orders
const barColors = {
early: '#67c23a',
onTime: '#409eff',
nearlyLate: '#e6a23c',
overdue: '#f56c6c',
pending: '#909399'
}
const statusLabels = {
early: '提前完成',
onTime: '准时完成',
nearlyLate: '临期完成',
overdue: '逾期完成',
pending: '进行中'
}
let minDate = Infinity, maxDate = -Infinity
raw.forEach(o => {
if (o.createTime) {
const t = new Date(o.createTime).getTime()
if (t < minDate) minDate = t
}
if (o.actualCloseDate) {
const t = new Date(o.actualCloseDate).getTime()
if (t > maxDate) maxDate = t
} else if (o.deliveryDate) {
const t = new Date(o.deliveryDate).getTime() + 86400000 * 7
if (t > maxDate) maxDate = t
}
})
if (!isFinite(minDate)) minDate = Date.now() - 86400000 * 30
if (maxDate < 0) maxDate = Date.now() + 86400000 * 7
minDate -= 86400000 * 2
maxDate += 86400000 * 3
const yNames = raw.map(o => o.doNo)
const ganttItems = raw.map((o, idx) => {
const startDate = o.createTime ? new Date(o.createTime).getTime() : minDate
const endDate = o.actualCloseDate
? new Date(o.actualCloseDate).getTime()
: Math.min(maxDate, Date.now())
const st = this.calcStatus(o)
return {
value: [startDate, endDate, idx],
itemStyle: { color: barColors[st] },
status: st,
order: o
}
})
const milestoneData = []
raw.forEach((o, idx) => {
if (o.deliveryDate) {
milestoneData.push({
coord: [new Date(o.deliveryDate).getTime(), idx],
symbol: 'diamond',
color: '#333',
label: '约定',
order: o
})
}
if (o.delayDate) {
milestoneData.push({
coord: [new Date(o.delayDate).getTime(), idx],
symbol: 'triangle',
color: '#e6a23c',
label: '延期',
order: o
})
}
if (o.actualCloseDate) {
milestoneData.push({
coord: [new Date(o.actualCloseDate).getTime(), idx],
symbol: 'circle',
color: '#f56c6c',
label: '签收',
order: o
})
}
})
const self = this
const option = {
tooltip: {
trigger: 'item',
formatter: function(params) {
const o = params.data && params.data.order ? params.data.order : raw[params.dataIndex]
if (!o) return ''
const st = self.calcStatus(o)
const base = o.delayDate || o.deliveryDate
let cycleInfo = ''
if (base && o.actualCloseDate) {
const diff = self.daysBetween(base, o.actualCloseDate)
cycleInfo = `<div>履约周期: ${diff}天 (${diff <= 0 ? '提前' + Math.abs(diff) + '天' : '延期' + diff + '天'})</div>`
}
return `
<div style="font-weight:700;font-size:14px;margin-bottom:4px">${o.doNo}</div>
<div>类型: ${o.type === 'client' ? '甲方履约' : '供应商履约'}</div>
<div>${o.type === 'client' ? '甲方客户' : '供应商'}: ${o.partyName || '-'}</div>
<div>金额: ¥${o.totalAmount || 0}</div>
<div>状态: ${o.deliveryStatus} (${statusLabels[st]})</div>
<hr style="margin:4px 0;border:none;border-top:1px solid #eee"/>
<div>创建: ${(o.createTime || '-').substring(0, 16)}</div>
<div>约定交货: ${o.deliveryDate || '-'}</div>
<div>延期至: ${o.delayDate || '-'}</div>
<div>签收/结单: ${o.actualCloseDate || '-'}</div>
${cycleInfo}
<div>物料数: ${o.itemCount || 0}</div>
`
}
},
legend: {
show: true,
top: 0,
right: 20,
data: [
{ name: '提前完成', itemStyle: { color: barColors.early } },
{ name: '准时完成', itemStyle: { color: barColors.onTime } },
{ name: '临期完成', itemStyle: { color: barColors.nearlyLate } },
{ name: '逾期完成', itemStyle: { color: barColors.overdue } },
{ name: '进行中', itemStyle: { color: barColors.pending } }
]
},
grid: {
left: 140,
right: 60,
top: 40,
bottom: 50
},
xAxis: {
type: 'time',
min: minDate,
max: maxDate,
axisLabel: {
formatter: '{MM}/{dd}',
fontSize: 11
},
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#eee' } }
},
yAxis: {
type: 'category',
data: yNames,
axisLabel: {
fontSize: 11,
width: 130,
overflow: 'truncate'
},
axisTick: { show: false }
},
series: [
{
name: '履约周期',
type: 'custom',
renderItem: function(params, api) {
const start = api.coord([api.value(0), api.value(2)])
const end = api.coord([api.value(1), api.value(2)])
const height = api.size([0, 1])[1] * 0.5
const status = ganttItems[api.value(2)].status
return {
type: 'rect',
shape: {
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 2),
height: height
},
style: {
fill: barColors[status],
opacity: 0.75,
stroke: '#fff',
lineWidth: 1
}
}
},
encode: { x: [0, 1], y: 2 },
data: ganttItems
},
{
name: '关键节点',
type: 'scatter',
symbolSize: 10,
data: milestoneData.map(m => ({
value: m.coord,
itemStyle: { color: m.color, borderColor: '#fff', borderWidth: 1.5 },
symbol: m.symbol,
order: m.order
})),
z: 10
}
]
}
this.chart.setOption(option)
// 点击跳转到对应订单详情页
this.chart.on('click', params => {
const o = params.data && params.data.order
if (!o) return
if (o.type === 'client') {
const path = o.deliveryStatus === 'pending' ? '/bid/clientDelivery/pending'
: o.deliveryStatus === 'transit' ? '/bid/clientDelivery/transit'
: '/bid/clientDelivery/signed'
this.$router.push({ path, query: { doNo: o.doNo } })
} else {
const path = o.deliveryStatus === 'pending' ? '/bid/order/pending'
: o.deliveryStatus === 'transit' ? '/bid/order/transit'
: '/bid/order/history'
this.$router.push({ path, query: { doNo: o.doNo } })
}
})
}
}
}
</script>
<style scoped>
.timeline-page { padding: 12px; min-height: calc(100vh - 84px); }
.stat-row { margin-bottom: 12px !important; }
.stat-card {
background: #fff; border-radius: 6px; padding: 16px 20px;
border-left: 4px solid #e4393c; box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.stat-num { font-size: 28px; font-weight: 700; line-height: 1.2; }
.stat-lbl { font-size: 12px; color: #909399; margin-top: 4px; }
.filter-bar {
background: #fff; padding: 10px 16px; border-radius: 2px; margin-bottom: 12px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.filter-hint { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: #909399; }
.hint-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-left: 4px; }
.chart-wrap {
background: #fff; border-radius: 2px; border: 1px solid #e5e5e5; padding: 16px;
}
.loading-box, .no-data { text-align: center; padding: 60px; color: #909399; font-size: 14px; }
.loading-box i { font-size: 20px; margin-right: 6px; }
</style>

View File

@@ -60,6 +60,12 @@
<el-col :span="1.5">
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="info" plain icon="el-icon-upload2" size="mini" @click="handleImport" v-hasPermi="['bid:material:import']">导入</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['bid:material:export']">导出</el-button>
</el-col>
<el-col :span="1.5" v-if="currentCategoryName">
<el-tag size="medium" closable @close="clearCategoryFilter" type="warning">
当前分类: {{ currentCategoryName }}
@@ -303,6 +309,16 @@
<el-button type="primary" @click="submitCategoryForm">确定</el-button>
</div>
</el-dialog>
<!-- Excel 导入对话框 -->
<excel-import-dialog
ref="importRef"
title="物料导入"
action="/bid/material/importData"
template-action="/bid/material/importTemplate"
template-file-name="material_template"
update-support-label="是否更新已经存在的物料数据"
@success="getList" />
</div>
</template>
@@ -310,9 +326,11 @@
import { listMaterial, getMaterial, addMaterial, updateMaterial, delMaterial, listManufacturer } from "@/api/bid/material";
import { getCategoryList, addCategory, updateCategory, delCategory } from "@/api/bid/category";
import request from '@/utils/request'
import ExcelImportDialog from "@/components/ExcelImportDialog"
export default {
name: "Material",
components: { ExcelImportDialog },
data() {
return {
loading: false, multiple: true, total: 0, materialList: [],
@@ -409,6 +427,10 @@ export default {
handleAdd() {
this.reset();
this.perfParams = [];
// 自动带入当前选中的分类
if (this.queryParams.categoryId) {
this.form.categoryId = this.queryParams.categoryId;
}
this.open = true;
this.title = "新增物料";
},
@@ -428,6 +450,13 @@ export default {
const ids = row.materialId || (this.ids || []).join(",");
this.$modal.confirm("确认删除?").then(() => delMaterial(ids)).then(() => { this.getList(); this.$modal.msgSuccess("删除成功"); });
},
// ═══ Excel 导入导出 ═══
handleImport() {
this.$refs.importRef.open();
},
handleExport() {
this.download('/bid/material/export', { ...this.queryParams }, `material_${new Date().getTime()}.xlsx`);
},
handleStatusChange(row) { updateMaterial(row); },
// 性能参数
addPerfRow() {

View File

@@ -15,9 +15,8 @@
<div class="filter-left">
<el-input v-model="q.doNo" placeholder="搜索单号" clearable size="small" class="filter-input" @keyup.enter.native="handleSearch" />
<el-select v-model="q.deliveryStatus" placeholder="状态" clearable size="small" style="width:100px" @change="getList">
<el-option label="待发" value="pending" />
<el-option label="在途" value="transit" />
<el-option label="历史" value="history" />
<el-option label="已签收(待结单)" value="history" />
<el-option label="已结单" value="closed" />
</el-select>
<el-button type="primary" size="small" @click="handleSearch">搜索</el-button>
<el-button size="small" @click="resetSearch">重置</el-button>
@@ -31,7 +30,7 @@
<!-- 左侧列表 -->
<div class="cd-left">
<div class="left-header">
<span class="left-title">订单列表</span>
<span class="left-title">已签收待结单</span>
</div>
<el-table ref="table" v-loading="loading" :data="list" border stripe size="small"
@selection-change="onSelectionChange" class="jd-table"
@@ -101,7 +100,7 @@
</template>
<script>
import { listDelivery, setCloseDate } from "@/api/bid/delivery"
import { listDelivery, setCloseDate, batchSetCloseDate } from "@/api/bid/delivery"
import request from '@/utils/request'
export default {
@@ -111,7 +110,7 @@ export default {
loading: false, list: [], total: 0, stats: {},
selected: [],
batchDate: null,
q: { pageNum: 1, pageSize: 50, doNo: "", deliveryStatus: "" },
q: { pageNum: 1, pageSize: 50, doNo: "", deliveryStatus: "history" },
statCards: [
{ key: "pendingClose", label: "已收货未结单", icon: "el-icon-document", color: "#e4393c" },
{ key: "todayClosed", label: "今日结单", icon: "el-icon-circle-check", color: "#67c23a" },
@@ -150,7 +149,9 @@ export default {
calcRow(row) {
if (!row.deliveryDate || !row._editDate) { row._cycleDays = null; row._diffDays = null; return }
const cd = new Date(row._editDate)
const dd = new Date(row.deliveryDate)
// 履约周期以约定交货日为起点,存在延期日期时以延期日期替代计算差异
const baseDate = row.delayDate || row.deliveryDate
const dd = new Date(baseDate)
row._cycleDays = Math.round((cd - dd) / 86400000)
row._diffDays = row._cycleDays
},
@@ -169,8 +170,8 @@ export default {
if (!this.selected.length) return
if (!this.allHaveDate) { this.$modal.msgError("有订单未设置收货日期"); return }
this.$modal.confirm("确认批量结单 " + this.selected.length + " 条?").then(() => {
const promises = this.selected.map(r => setCloseDate(r.doId, r._editDate))
return Promise.all(promises)
const ids = this.selected.map(r => r.doId)
return batchSetCloseDate(ids, this.selected[0]._editDate)
}).then(() => {
this.$modal.msgSuccess("批量结单成功"); this.getList(); this.getStats(); this.selected = []
}).catch(() => {})

View File

@@ -0,0 +1,387 @@
<template>
<div class="timeline-page">
<!-- 统计卡片 -->
<el-row :gutter="12" class="stat-row">
<el-col :span="8" v-for="c in statCards" :key="c.key">
<div class="stat-card" :style="{borderLeftColor: c.color}">
<div class="stat-num" :style="{color: c.color}">{{ stats[c.key] != null ? stats[c.key] : '-' }}</div>
<div class="stat-lbl">{{ c.label }}</div>
</div>
</el-col>
</el-row>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="query.type" placeholder="全部类型" clearable size="small" style="width:130px" @change="loadData">
<el-option label="全部类型" value="" />
<el-option label="供应商履约" value="supplier" />
<el-option label="甲方履约" value="client" />
</el-select>
<el-select v-model="query.status" placeholder="全部状态" clearable size="small" style="width:120px" @change="loadData">
<el-option label="全部状态" value="" />
<el-option label="待发" value="pending" />
<el-option label="在途" value="transit" />
<el-option label="已签收" value="history" />
<el-option label="已结单" value="closed" />
</el-select>
<el-date-picker v-model="query.dateFrom" type="date" value-format="yyyy-MM-dd" placeholder="开始日期" size="small" style="width:140px" @change="loadData" />
<el-date-picker v-model="query.dateTo" type="date" value-format="yyyy-MM-dd" placeholder="结束日期" size="small" style="width:140px" @change="loadData" />
<el-button type="primary" size="small" icon="el-icon-search" @click="loadData">查询</el-button>
<el-button size="small" icon="el-icon-refresh" @click="resetSearch">重置</el-button>
<div class="filter-hint">
<span class="hint-dot" style="background:#67c23a"></span> 提前完成
<span class="hint-dot" style="background:#409eff"></span> 准时完成
<span class="hint-dot" style="background:#e6a23c"></span> 临期完成
<span class="hint-dot" style="background:#f56c6c"></span> 逾期完成
<span class="hint-dot" style="background:#909399"></span> 进行中
</div>
</div>
<!-- 甘特图 -->
<div class="chart-wrap">
<div v-if="loading" class="loading-box"><i class="el-icon-loading"></i> 加载中</div>
<div v-else-if="!orders.length" class="no-data">暂无订单数据</div>
<div v-else ref="ganttChart" style="width:100%;height:600px"></div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getTimeline } from "@/api/bid/timeline"
export default {
name: "OrderTimeline",
data() {
return {
loading: false,
orders: [],
stats: {},
query: { type: "", status: "", dateFrom: "", dateTo: "" },
chart: null,
statCards: [
{ key: "onTime", label: "按期完成", color: "#67c23a" },
{ key: "delayed", label: "逾期完成", color: "#f56c6c" },
{ key: "pending", label: "进行中/待结单", color: "#e6a23c" }
]
}
},
mounted() {
this.loadData()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chart) { this.chart.dispose(); this.chart = null }
},
methods: {
handleResize() {
if (this.chart) this.chart.resize()
},
loadData() {
this.loading = true
getTimeline(this.query).then(r => {
const data = r.data || {}
this.orders = data.orders || []
this.stats = data.stats || {}
this.loading = false
this.$nextTick(() => this.renderChart())
}).catch(() => { this.loading = false })
},
resetSearch() {
this.query = { type: "", status: "", dateFrom: "", dateTo: "" }
this.loadData()
},
// ═══ 计算订单履约状态 ═══
calcStatus(o) {
const base = o.delayDate || o.deliveryDate
if (o.actualCloseDate && base) {
const diff = this.daysBetween(base, o.actualCloseDate)
if (diff < 0) return 'early' // 提前完成
if (diff === 0) return 'onTime' // 准时完成
if (diff <= 3) return 'nearlyLate' // 临期完成延期1-3天
return 'overdue' // 逾期完成(延期>3天
}
if (o.actualCloseDate && !base) return 'onTime'
// 未结单:看是否已过交货期
if (base) {
const diff = this.daysBetween(base, this.todayStr())
if (diff < 0) return 'overdue' // 已逾期未结单
if (diff <= 3) return 'nearlyLate' // 临期
}
return 'pending'
},
daysBetween(d1, d2) {
if (!d1 || !d2) return 0
return Math.round((new Date(d2) - new Date(d1)) / 86400000)
},
todayStr() {
const d = new Date()
return d.toISOString().slice(0, 10)
},
renderChart() {
if (!this.$refs.ganttChart || !this.orders.length) return
if (this.chart) this.chart.dispose()
this.chart = echarts.init(this.$refs.ganttChart, 'macarons')
const raw = this.orders
const barColors = {
early: '#67c23a',
onTime: '#409eff',
nearlyLate: '#e6a23c',
overdue: '#f56c6c',
pending: '#909399'
}
const statusLabels = {
early: '提前完成',
onTime: '准时完成',
nearlyLate: '临期完成',
overdue: '逾期完成',
pending: '进行中'
}
// 计算全局时间范围
let minDate = Infinity, maxDate = -Infinity
raw.forEach(o => {
if (o.createTime) {
const t = new Date(o.createTime).getTime()
if (t < minDate) minDate = t
}
if (o.actualCloseDate) {
const t = new Date(o.actualCloseDate).getTime()
if (t > maxDate) maxDate = t
} else if (o.deliveryDate) {
// 未结单的以交货期+7天为终点展示
const t = new Date(o.deliveryDate).getTime() + 86400000 * 7
if (t > maxDate) maxDate = t
}
})
if (!isFinite(minDate)) minDate = Date.now() - 86400000 * 30
if (maxDate < 0) maxDate = Date.now() + 86400000 * 7
minDate -= 86400000 * 2
maxDate += 86400000 * 3
const yNames = raw.map(o => o.doNo)
// ═══ 使用 custom series 绘制甘特条 ═══
// 每个订单一个条:从创建时间到结单时间(或当前时间)
const ganttItems = raw.map((o, idx) => {
const startDate = o.createTime ? new Date(o.createTime).getTime() : minDate
const endDate = o.actualCloseDate
? new Date(o.actualCloseDate).getTime()
: Math.min(maxDate, Date.now())
const st = this.calcStatus(o)
return {
value: [startDate, endDate, idx],
itemStyle: { color: barColors[st] },
status: st,
order: o
}
})
// ═══ 关键节点标记数据(约定交货/延期/签收) ═══
const milestoneData = []
raw.forEach((o, idx) => {
if (o.deliveryDate) {
milestoneData.push({
coord: [new Date(o.deliveryDate).getTime(), idx],
symbol: 'diamond',
color: '#333',
label: '约定',
order: o
})
}
if (o.delayDate) {
milestoneData.push({
coord: [new Date(o.delayDate).getTime(), idx],
symbol: 'triangle',
color: '#e6a23c',
label: '延期',
order: o
})
}
if (o.actualCloseDate) {
milestoneData.push({
coord: [new Date(o.actualCloseDate).getTime(), idx],
symbol: 'circle',
color: '#f56c6c',
label: '签收',
order: o
})
}
})
const self = this
const option = {
tooltip: {
trigger: 'item',
formatter: function(params) {
const o = params.data && params.data.order ? params.data.order : raw[params.dataIndex]
if (!o) return ''
const st = self.calcStatus(o)
const base = o.delayDate || o.deliveryDate
let cycleInfo = ''
if (base && o.actualCloseDate) {
const diff = self.daysBetween(base, o.actualCloseDate)
cycleInfo = `<div>履约周期: ${diff}天 (${diff <= 0 ? '提前' + Math.abs(diff) + '天' : '延期' + diff + '天'})</div>`
}
return `
<div style="font-weight:700;font-size:14px;margin-bottom:4px">${o.doNo}</div>
<div>类型: ${o.type === 'client' ? '甲方履约' : '供应商履约'}</div>
<div>对方: ${o.partyName || '-'}</div>
<div>金额: ¥${o.totalAmount || 0}</div>
<div>状态: ${o.deliveryStatus} (${statusLabels[st]})</div>
<hr style="margin:4px 0;border:none;border-top:1px solid #eee"/>
<div>创建: ${(o.createTime || '-').substring(0, 16)}</div>
<div>约定交货: ${o.deliveryDate || '-'}</div>
<div>延期至: ${o.delayDate || '-'}</div>
<div>签收/结单: ${o.actualCloseDate || '-'}</div>
${cycleInfo}
<div>物料数: ${o.itemCount || 0}</div>
`
}
},
legend: {
show: true,
top: 0,
right: 20,
data: [
{ name: '提前完成', itemStyle: { color: barColors.early } },
{ name: '准时完成', itemStyle: { color: barColors.onTime } },
{ name: '临期完成', itemStyle: { color: barColors.nearlyLate } },
{ name: '逾期完成', itemStyle: { color: barColors.overdue } },
{ name: '进行中', itemStyle: { color: barColors.pending } }
]
},
grid: {
left: 140,
right: 60,
top: 40,
bottom: 50
},
xAxis: {
type: 'time',
min: minDate,
max: maxDate,
axisLabel: {
formatter: '{MM}/{dd}',
fontSize: 11
},
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#eee' } }
},
yAxis: {
type: 'category',
data: yNames,
axisLabel: {
fontSize: 11,
width: 130,
overflow: 'truncate'
},
axisTick: { show: false }
},
series: [
// ═══ 甘特条custom series 绘制矩形) ═══
{
name: '履约周期',
type: 'custom',
renderItem: function(params, api) {
const start = api.coord([api.value(0), api.value(2)])
const end = api.coord([api.value(1), api.value(2)])
const height = api.size([0, 1])[1] * 0.5
const status = ganttItems[api.value(2)].status
return {
type: 'rect',
shape: {
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 2),
height: height
},
style: {
fill: barColors[status],
opacity: 0.75,
stroke: '#fff',
lineWidth: 1
}
}
},
encode: { x: [0, 1], y: 2 },
data: ganttItems
},
// ═══ 关键节点标记 ═══
{
name: '关键节点',
type: 'scatter',
symbolSize: 10,
data: milestoneData.map(m => ({
value: m.coord,
itemStyle: { color: m.color, borderColor: '#fff', borderWidth: 1.5 },
symbol: m.symbol,
order: m.order
})),
z: 10
}
]
}
this.chart.setOption(option)
// 点击跳转到对应详情self 已在上方定义)
this.chart.on('click', function(params) {
// custom series: params.value = [startDate, endDate, idx]
// scatter series: params.data.order 或 params.value = [date, idx]
let o = null
if (params.data && params.data.order) {
o = params.data.order
} else if (params.value && params.value.length >= 3) {
const idx = Math.round(params.value[2])
o = self.orders[idx]
} else if (params.value && params.value.length >= 2) {
// scatter的坐标: [date, idx]
const idx = Math.round(params.value[1])
o = self.orders[idx]
}
if (!o) return
// 根据类型和状态跳转
if (o.type === 'client') {
const path = o.deliveryStatus === 'pending' ? '/bid/clientDelivery/pending'
: o.deliveryStatus === 'transit' ? '/bid/clientDelivery/transit'
: '/bid/clientDelivery/signed'
self.$router.push({ path, query: { doNo: o.doNo } })
} else {
const path = o.deliveryStatus === 'pending' ? '/bid/order/pending'
: o.deliveryStatus === 'transit' ? '/bid/order/transit'
: '/bid/order/history'
self.$router.push({ path, query: { doNo: o.doNo } })
}
})
}
}
}
</script>
<style scoped>
.timeline-page { padding: 12px; min-height: calc(100vh - 84px); }
.stat-row { margin-bottom: 12px !important; }
.stat-card {
background: #fff; border-radius: 6px; padding: 16px 20px;
border-left: 4px solid #e4393c; box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.stat-num { font-size: 28px; font-weight: 700; line-height: 1.2; }
.stat-lbl { font-size: 12px; color: #909399; margin-top: 4px; }
.filter-bar {
background: #fff; padding: 10px 16px; border-radius: 2px; margin-bottom: 12px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.filter-hint { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: #909399; }
.hint-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-left: 4px; }
.chart-wrap {
background: #fff; border-radius: 2px; border: 1px solid #e5e5e5; padding: 16px;
}
.loading-box, .no-data { text-align: center; padding: 60px; color: #909399; font-size: 14px; }
.loading-box i { font-size: 20px; margin-right: 6px; }
</style>

View File

@@ -16,6 +16,8 @@
/>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch" />
<el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd" />
<el-button type="info" size="small" icon="el-icon-upload2" @click="handleImport" v-hasPermi="['bid:supplier:import']" />
<el-button type="warning" size="small" icon="el-icon-download" @click="handleExport" v-hasPermi="['bid:supplier:export']" />
</div>
</div>
@@ -297,6 +299,16 @@
<el-button type="primary" @click="submitAdd">确定</el-button>
</div>
</el-dialog>
<!-- Excel 导入对话框 -->
<excel-import-dialog
ref="importRef"
title="供应商导入"
action="/bid/supplier/importData"
template-action="/bid/supplier/importTemplate"
template-file-name="supplier_template"
update-support-label="是否更新已经存在的供应商数据"
@success="getList" />
</div>
</template>
@@ -304,9 +316,11 @@
import { listSupplier, getSupplier, addSupplier, updateSupplier, delSupplier } from "@/api/bid/supplier";
import { listObjection } from "@/api/bid/objection";
import { getSupplierQuoteItems } from "@/api/bid/quotation";
import ExcelImportDialog from "@/components/ExcelImportDialog"
export default {
name: "SupplierManage",
components: { ExcelImportDialog },
data() {
return {
// ---- 左侧列表 ----
@@ -389,6 +403,10 @@ export default {
this.getList();
},
// ═══ Excel 导入导出 ═══
handleImport() { this.$refs.importRef.open(); },
handleExport() { this.download('/bid/supplier/export', { ...this.queryParams }, `supplier_${new Date().getTime()}.xlsx`); },
handleSizeChange(size) {
this.queryParams.pageSize = size;
this.queryParams.pageNum = 1;