feat: 完成履约管理模块全量功能迭代
本次迭代包含以下核心功能: 1. 新增履约时效总览可视化页面,支持多维度数据统计 2. 实现物料/客户/供应商的Excel批量导入导出功能 3. 新增订单批量结单功能,优化结单流程校验 4. 完善日志配置,新增文件日志落地 5. 修复分类查询逻辑,优化多租户数据隔离 6. 新增甲方履约结单管理页面与权限控制 7. 重构部分Mapper与Service接口,增强代码健壮性
This commit is contained in:
@@ -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() {
|
||||
|
||||
234
ruoyi-ui/src/views/bid/clientDelivery/closeDate.vue
Normal file
234
ruoyi-ui/src/views/bid/clientDelivery/closeDate.vue
Normal 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>
|
||||
369
ruoyi-ui/src/views/bid/clientDelivery/timeline.vue
Normal file
369
ruoyi-ui/src/views/bid/clientDelivery/timeline.vue
Normal 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>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
387
ruoyi-ui/src/views/bid/order/timeline.vue
Normal file
387
ruoyi-ui/src/views/bid/order/timeline.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user