feat: 完成采购看板与异议管理模块功能升级
1. 新增报表下钻跳转支持,为供应商、RFQ、采购单、物料等页面添加路由参数解析 2. 优化异议管理模块:新增发货单关联、详情弹窗、审批流程优化 3. 完善采购看板功能:支持累计数据展示、图表导出、数据补全与趋势优化 4. 新增供应商评分历史趋势统计与品类分布聚合逻辑 5. 修复异议API路径与通知跳转路径问题,新增模拟测试数据
This commit is contained in:
@@ -3,5 +3,5 @@ const baseUrl = '/bid/objection'
|
||||
export const listObjection = (params) => request({ url: baseUrl + '/list', method: 'get', params })
|
||||
export const getObjection = (id) => request({ url: baseUrl + '/' + id, method: 'get' })
|
||||
export const addObjection = (data) => request({ url: baseUrl, method: 'post', data })
|
||||
export const updateObjection = (data) => request({ url: baseUrl, method: 'put', data })
|
||||
export const updateObjection = (data) => request({ url: baseUrl + '/resolve', method: 'put', data })
|
||||
export const delObjection = (ids) => request({ url: baseUrl + '/' + ids, method: 'delete' })
|
||||
|
||||
@@ -127,6 +127,67 @@ export const dynamicRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
// ── 列表页路由(供报表下钻跳转) ──
|
||||
{
|
||||
path: '/bid/purchaseorder',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:purchaseorder:list'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/purchaseorder/index'),
|
||||
name: 'PurchaseOrderList',
|
||||
meta: { title: '采购单', activeMenu: '/quote/purchaseorder' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/bid/rfq',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:rfq:list'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/rfq/index'),
|
||||
name: 'RfqList',
|
||||
meta: { title: '报价请求', activeMenu: '/quote/rfq' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/bid/supplier',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:supplier:list'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/supplier/index'),
|
||||
name: 'SupplierList',
|
||||
meta: { title: '供应商管理', activeMenu: '/basedata/bidSupplier' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/bid/material',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:material:list'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/material/index'),
|
||||
name: 'MaterialList',
|
||||
meta: { title: '物料管理', activeMenu: '/basedata/material' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/bid/comparison/detail',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:comparison:list'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/comparison/detail'),
|
||||
name: 'ComparisonDetail',
|
||||
meta: { title: '比价详情', activeMenu: '/quote/comparison' }
|
||||
}]
|
||||
},
|
||||
// ── 统计分析 路由 ──
|
||||
{
|
||||
path: '/bid/report/dashboard',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
size="mini"
|
||||
placeholder="搜索分类"
|
||||
clearable
|
||||
style="width:140px" />
|
||||
class="tree-filter-input" />
|
||||
</div>
|
||||
<el-tree
|
||||
ref="categoryTree"
|
||||
@@ -369,6 +369,10 @@ export default {
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 支持从报表下钻跳转,读取路由query中的categoryId
|
||||
if (this.$route.query.categoryId) {
|
||||
this.queryParams.categoryId = Number(this.$route.query.categoryId);
|
||||
}
|
||||
this.getList();
|
||||
this.loadCategories();
|
||||
this.loadBrands();
|
||||
@@ -576,12 +580,22 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-filter-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-tree {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="采购单号" prop="poNo" width="150" />
|
||||
<el-table-column label="发货单号" prop="doNo" width="180" />
|
||||
<el-table-column label="供应商" prop="supplierName" width="150" />
|
||||
<el-table-column label="异议原因" prop="reason" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="状态" width="100">
|
||||
@@ -32,10 +32,10 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="处理结果" prop="resolution" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="提交时间" prop="createTime" width="160" />
|
||||
<el-table-column label="操作" class-name="col-ops" align="center">
|
||||
<el-table-column label="操作" class-name="col-ops" align="center" width="260">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="handleResolve(scope.row)" v-if="scope.row.status==='pending'||scope.row.status==='processing'" style="color:#67C23A">处理</el-button>
|
||||
<el-button size="mini" type="text" @click="handleSubmitApproval(scope.row)" v-if="scope.row.status==='pending'" style="color:#E6A23C">提交审批</el-button>
|
||||
<el-button size="mini" type="text" @click="loadDetail(scope.row.objectionId)">详情</el-button>
|
||||
<el-button size="mini" type="text" @click="handleResolve(scope.row)" v-if="scope.row.status==='pending'" style="color:#67C23A">处理</el-button>
|
||||
<el-button size="mini" type="text" @click="handleApprove(scope.row)" v-if="scope.row.status==='10'" style="color:#67C23A">通过</el-button>
|
||||
<el-button size="mini" type="text" @click="handleReject(scope.row)" v-if="scope.row.status==='10'" style="color:#F56C6C">驳回</el-button>
|
||||
</template>
|
||||
@@ -45,13 +45,15 @@
|
||||
|
||||
<el-dialog title="提交异议" :visible.sync="addOpen" width="500px" append-to-body>
|
||||
<el-form ref="addForm" :model="addForm" label-width="100px">
|
||||
<el-form-item label="采购单号"><el-input v-model="addForm.poId" placeholder="输入采购单ID" /></el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-select v-model="addForm.supplierId" placeholder="选择供应商" style="width:100%">
|
||||
<el-option v-for="s in supplierOptions" :key="s.supplierId" :label="s.supplierName" :value="s.supplierId" />
|
||||
<el-form-item label="发货单号" required>
|
||||
<el-select v-model="addForm.doId" placeholder="选择已签收的发货单" filterable style="width:100%" @change="onDeliveryChange">
|
||||
<el-option v-for="d in deliveryOptions" :key="d.doId" :label="d.doNo + (d.supplierName ? ' - ' + d.supplierName : '')" :value="d.doId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="异议原因"><el-input v-model="addForm.reason" type="textarea" rows="4" /></el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="addForm.supplierName" disabled placeholder="选择发货单后自动填充" />
|
||||
</el-form-item>
|
||||
<el-form-item label="异议原因" required><el-input v-model="addForm.reason" type="textarea" rows="4" /></el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="addOpen=false">取消</el-button>
|
||||
@@ -61,53 +63,126 @@
|
||||
|
||||
<el-dialog title="处理异议" :visible.sync="resolveOpen" width="460px" append-to-body>
|
||||
<el-form :model="resolveForm" label-width="90px">
|
||||
<el-form-item label="处理结果"><el-input v-model="resolveForm.resolution" type="textarea" rows="4" /></el-form-item>
|
||||
<el-form-item label="处理状态">
|
||||
<el-radio-group v-model="resolveForm.status">
|
||||
<el-radio label="resolved">已解决</el-radio>
|
||||
<el-radio label="rejected">拒绝</el-radio>
|
||||
</el-radio-group>
|
||||
<el-form-item label="异议原因">
|
||||
<span style="color:#909399;font-size:13px">{{ resolveForm.reason || '—' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理结果" required>
|
||||
<el-input v-model="resolveForm.resolution" type="textarea" rows="4" placeholder="请填写处理结果,提交后将进入审批流程" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="resolveOpen=false">取消</el-button>
|
||||
<el-button type="primary" @click="submitResolve">确认</el-button>
|
||||
<el-button type="primary" @click="submitResolve" :loading="resolveSubmitting">提交审批</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 异议详情弹窗(从通知中心跳转 / 列表页点击详情) -->
|
||||
<el-dialog title="异议详情" :visible.sync="detailOpen" width="580px" append-to-body>
|
||||
<div v-loading="detailLoading" style="padding:12px 0">
|
||||
<el-form label-width="110px" size="small" v-if="detailData">
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="发货单号">{{ detailData.doNo || '-' }}</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="采购单号">{{ detailData.poNo || '-' }}</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="供应商">{{ detailData.supplierName || '-' }}</el-form-item>
|
||||
<el-form-item label="异议原因">{{ detailData.reason || '-' }}</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-tag :type="{ pending:'warning', '10':'warning', resolved:'success', rejected:'danger' }[detailData.status]">
|
||||
{{ { pending:'待处理', '10':'审批中', resolved:'已解决', rejected:'已拒绝' }[detailData.status] || detailData.status }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理结果" v-if="detailData.resolution">{{ detailData.resolution }}</el-form-item>
|
||||
<el-form-item label="提交人">{{ detailData.createBy || '-' }}</el-form-item>
|
||||
<el-form-item label="提交时间">{{ parseTime(detailData.createTime) }}</el-form-item>
|
||||
<el-form-item label="处理时间" v-if="detailData.resolveTime">{{ parseTime(detailData.resolveTime) }}</el-form-item>
|
||||
</el-form>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { listObjection, addObjection, updateObjection } from "@/api/bid/objection";
|
||||
import { listObjection, getObjection, addObjection, updateObjection } from "@/api/bid/objection";
|
||||
import { listSupplier } from "@/api/bid/supplier";
|
||||
import { listDelivery } from "@/api/bid/delivery";
|
||||
import { submitApproval, approveBiz, rejectBiz } from "@/api/bid/approvalAction";
|
||||
export default {
|
||||
name: "Objection",
|
||||
data() {
|
||||
return {
|
||||
loading: false, total: 0, list: [],
|
||||
addOpen: false, resolveOpen: false,
|
||||
supplierOptions: [],
|
||||
addOpen: false, resolveOpen: false, resolveSubmitting: false,
|
||||
detailOpen: false, detailLoading: false, detailData: null,
|
||||
supplierOptions: [], deliveryOptions: [],
|
||||
queryParams: { pageNum: 1, pageSize: 10, status: null },
|
||||
addForm: {}, resolveForm: {}
|
||||
};
|
||||
},
|
||||
created() { this.getList(); listSupplier({ pageSize: 200 }).then(r => { this.supplierOptions = r.rows || []; }); },
|
||||
created() {
|
||||
this.getList();
|
||||
listSupplier({ pageSize: 200 }).then(r => { this.supplierOptions = r.rows || []; });
|
||||
listDelivery({ pageSize: 200, deliveryStatus: 'history' }).then(r => { this.deliveryOptions = r.rows || []; });
|
||||
// 从通知中心跳转过来时,根据id参数显示详情
|
||||
const detailId = this.$route.query.id;
|
||||
if (detailId) {
|
||||
this.loadDetail(detailId);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.query.id'(newId) {
|
||||
if (newId) this.loadDetail(newId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true;
|
||||
listObjection(this.queryParams).then(r => { this.list = r.rows; this.total = r.total; this.loading = false; });
|
||||
},
|
||||
loadDetail(id) {
|
||||
this.detailLoading = true;
|
||||
this.detailData = null;
|
||||
this.detailOpen = true;
|
||||
getObjection(id).then(r => { this.detailData = r.data; }).catch(() => {
|
||||
this.$modal.msgError("加载异议详情失败");
|
||||
}).finally(() => { this.detailLoading = false; });
|
||||
},
|
||||
handleAdd() { this.addForm = {}; this.addOpen = true; },
|
||||
onDeliveryChange(doId) {
|
||||
const d = this.deliveryOptions.find(x => x.doId === doId);
|
||||
if (d) {
|
||||
this.$set(this.addForm, 'supplierId', d.supplierId);
|
||||
this.$set(this.addForm, 'supplierName', d.supplierName || '');
|
||||
}
|
||||
},
|
||||
submitAdd() {
|
||||
if (!this.addForm.doId) { this.$modal.msgWarning("请选择发货单"); return; }
|
||||
if (!this.addForm.reason || !this.addForm.reason.trim()) { this.$modal.msgWarning("请填写异议原因"); return; }
|
||||
addObjection(this.addForm).then(() => { this.$modal.msgSuccess("提交成功"); this.addOpen = false; this.getList(); });
|
||||
},
|
||||
handleResolve(row) { this.resolveForm = { objectionId: row.objectionId, status: "resolved" }; this.resolveOpen = true; },
|
||||
handleResolve(row) { this.resolveForm = { objectionId: row.objectionId, reason: row.reason, resolution: '' }; this.resolveOpen = true; },
|
||||
submitResolve() {
|
||||
updateObjection(this.resolveForm).then(() => { this.$modal.msgSuccess("处理成功"); this.resolveOpen = false; this.getList(); });
|
||||
},
|
||||
handleSubmitApproval(row) {
|
||||
this.$modal.confirm("确认提交审批?").then(() => submitApproval("ORDER_OBJECTION", row.objectionId))
|
||||
.then(() => { this.$modal.msgSuccess("已提交审批"); this.getList(); });
|
||||
if (!this.resolveForm.resolution || !this.resolveForm.resolution.trim()) {
|
||||
this.$modal.msgWarning("请填写处理结果");
|
||||
return;
|
||||
}
|
||||
this.resolveSubmitting = true;
|
||||
// 第一步:保存处理结果
|
||||
updateObjection(this.resolveForm).then(() => {
|
||||
// 第二步:提交审批
|
||||
return submitApproval("ORDER_OBJECTION", this.resolveForm.objectionId);
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess("已提交审批,等待管理员审核");
|
||||
this.resolveOpen = false;
|
||||
this.getList();
|
||||
}).catch(() => {
|
||||
this.$modal.msgError("操作失败");
|
||||
}).finally(() => {
|
||||
this.resolveSubmitting = false;
|
||||
});
|
||||
},
|
||||
handleApprove(row) {
|
||||
this.$modal.confirm("确认通过该异议?").then(() => approveBiz("ORDER_OBJECTION", row.objectionId))
|
||||
|
||||
@@ -199,7 +199,16 @@ export default {
|
||||
rules: { supplierId: [{ required: true, message: "请选择供应商", trigger: "change" }] }
|
||||
};
|
||||
},
|
||||
created() { this.getList(); listSupplier({ pageSize: 200 }).then(r => { this.supplierOptions = r.rows || []; }); },
|
||||
created() {
|
||||
// 支持从报表下钻传入month参数(如2026-01)
|
||||
if (this.$route.query.month) {
|
||||
const month = this.$route.query.month;
|
||||
this.queryParams.beginTime = month + '-01';
|
||||
this.queryParams.endTime = month + '-31';
|
||||
}
|
||||
this.getList();
|
||||
listSupplier({ pageSize: 200 }).then(r => { this.supplierOptions = r.rows || []; });
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true;
|
||||
|
||||
@@ -5,14 +5,22 @@
|
||||
<span class="kpi-number">{{ displayValue }}</span>
|
||||
<span class="kpi-unit" v-if="unit">{{ unit }}</span>
|
||||
</div>
|
||||
<!-- 当月为0但有累计值时,显示累计数据 -->
|
||||
<div class="kpi-total" v-if="showTotal">
|
||||
<span class="kpi-total-label">累计</span>
|
||||
<span class="kpi-total-value">{{ displayTotalValue }}</span>
|
||||
</div>
|
||||
<div class="kpi-trend" :class="trendClass" v-if="changeRate > 0">
|
||||
<i :class="trendIcon"></i>
|
||||
{{ changeRateText }}
|
||||
<span class="trend-label">环比上月</span>
|
||||
</div>
|
||||
<div class="kpi-trend no-change" v-else>
|
||||
<div class="kpi-trend no-change" v-else-if="!showTotal">
|
||||
<i class="el-icon-minus"></i> 持平
|
||||
</div>
|
||||
<div class="kpi-trend no-change" v-else>
|
||||
<i class="el-icon-info"></i> 本月暂无数据
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +33,7 @@ export default {
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: [Number, String], default: 0 },
|
||||
totalValue: { type: [Number, String], default: 0 },
|
||||
unit: { type: String, default: '' },
|
||||
changeRate: { type: Number, default: 0 },
|
||||
trend: { type: String, default: 'up' }
|
||||
@@ -34,6 +43,14 @@ export default {
|
||||
const v = Number(this.value)
|
||||
return isNaN(v) ? 0 : v
|
||||
},
|
||||
safeTotalValue() {
|
||||
const v = Number(this.totalValue)
|
||||
return isNaN(v) ? 0 : v
|
||||
},
|
||||
// 当月值为0且累计值>0时,显示累计数据
|
||||
showTotal() {
|
||||
return this.safeValue === 0 && this.safeTotalValue > 0
|
||||
},
|
||||
displayValue() {
|
||||
const v = this.safeValue
|
||||
if (this.label === '采购总额') {
|
||||
@@ -43,6 +60,15 @@ export default {
|
||||
}
|
||||
return v.toLocaleString()
|
||||
},
|
||||
displayTotalValue() {
|
||||
const v = this.safeTotalValue
|
||||
if (this.label === '采购总额') {
|
||||
if (v >= 100000000) return '¥' + (v / 100000000).toFixed(2) + '亿'
|
||||
if (v >= 10000) return '¥' + (v / 10000).toFixed(2) + '万'
|
||||
return '¥' + v.toLocaleString()
|
||||
}
|
||||
return v.toLocaleString()
|
||||
},
|
||||
trendClass() { return this.trend === 'up' ? 'trend-up' : 'trend-down' },
|
||||
trendIcon() { return this.trend === 'up' ? 'el-icon-top' : 'el-icon-bottom' },
|
||||
changeRateText() {
|
||||
@@ -87,6 +113,22 @@ export default {
|
||||
color: #909399;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.kpi-total {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.kpi-total-label {
|
||||
color: #C0C4CC;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kpi-total-value {
|
||||
font-weight: 600;
|
||||
color: #E6A23C;
|
||||
}
|
||||
.kpi-trend {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<div class="app-container cost-page">
|
||||
<div class="page-header">
|
||||
<span class="page-title">💰 采购成本分析</span>
|
||||
<el-date-picker v-model="dateRange" type="monthrange" size="mini" start-placeholder="开始月份" end-placeholder="结束月份" value-format="yyyy-MM" style="margin-left:auto;width:260px" @change="onDateChange" />
|
||||
<el-button type="success" size="mini" icon="el-icon-download" @click="handleExport">导出Excel</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
@@ -22,6 +24,7 @@
|
||||
<el-card shadow="hover" style="margin-bottom:16px">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-data-board" style="color:#e4393c"></i> 月度预算 vs 实际成本</span>
|
||||
<el-button v-if="selectedMonth" type="text" size="mini" @click="selectedMonth=null" style="float:right;color:#F56C6C">清除月份筛选: {{selectedMonth}}</el-button>
|
||||
</div>
|
||||
<div ref="costTrendChart" style="height:350px;width:100%"></div>
|
||||
</el-card>
|
||||
@@ -49,7 +52,7 @@
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-document" style="color:#909399"></i> RFQ 比价明细</span>
|
||||
</div>
|
||||
<el-table :data="data.rfqDetails || []" border size="small">
|
||||
<el-table :data="filteredRfqDetails" border size="small" :row-class-name="tableRowClassName">
|
||||
<el-table-column label="询价单号" prop="rfqNo" width="150" />
|
||||
<el-table-column label="询价标题" prop="rfqTitle" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="预算价" width="130" align="right">
|
||||
@@ -61,11 +64,24 @@
|
||||
<el-table-column label="采纳价格" width="130" align="right">
|
||||
<template slot-scope="s"><span class="cell-actual">¥{{ formatMoney(s.row.acceptedQuote) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节省金额" width="130" align="right">
|
||||
<el-table-column label="节省金额" width="140" align="right">
|
||||
<template slot-scope="s">
|
||||
<span :class="Number(s.row.savedAmount) > 0 ? 'cell-saved' : 'cell-none'">
|
||||
<el-tooltip v-if="s.row.overBudget" :content="'实际超支 ¥' + formatMoney(Math.abs(Number(s.row.savedAmount)))" placement="top">
|
||||
<span class="cell-overspend">
|
||||
<i class="el-icon-warning-outline"></i> ¥{{ formatMoney(s.row.savedAmount) }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else-if="Number(s.row.savedAmount) > 0" class="cell-saved">
|
||||
¥{{ formatMoney(s.row.savedAmount) }}
|
||||
</span>
|
||||
<span v-else class="cell-none">¥{{ formatMoney(s.row.savedAmount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异说明" width="130" align="center">
|
||||
<template slot-scope="s">
|
||||
<span v-if="s.row.overBudget" class="diff-overspend">超预算 {{ getDiffPercent(s.row) }}%</span>
|
||||
<span v-else-if="Number(s.row.savedAmount) > 0" class="diff-saved">节约 {{ getDiffPercent(s.row) }}%</span>
|
||||
<span v-else class="diff-none">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参与供应商" width="100" align="center" prop="supplierCount" />
|
||||
@@ -76,7 +92,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!data.rfqDetails || !data.rfqDetails.length" class="no-data" style="padding:20px">暂无数据</div>
|
||||
<div v-if="!filteredRfqDetails.length" class="no-data" style="padding:20px">暂无数据</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +102,7 @@
|
||||
import * as echarts from 'echarts'
|
||||
require('echarts/theme/macarons') // echarts theme
|
||||
import { debounce } from '@/utils'
|
||||
import { getCostAnalysis } from '@/api/bid/report'
|
||||
import { getCostAnalysis, exportReport } from '@/api/bid/report'
|
||||
|
||||
export default {
|
||||
name: 'ReportCost',
|
||||
@@ -96,7 +112,9 @@ export default {
|
||||
data: null,
|
||||
trendChart: null,
|
||||
categoryChart: null,
|
||||
savedChart: null
|
||||
savedChart: null,
|
||||
selectedMonth: null,
|
||||
dateRange: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -109,6 +127,11 @@ export default {
|
||||
{ label: '节省金额', value: '¥' + this.formatMoney(s.savedAmount), color: Number(s.savedAmount) > 0 ? '#E6A23C' : '#C0C4CC', sub: Number(s.savedAmount) > 0 ? '为您节省了开支' : '' },
|
||||
{ label: '节省比例', value: (Number(s.savedRate) || 0) + '%', color: '#F56C6C', sub: '相比预算' }
|
||||
]
|
||||
},
|
||||
filteredRfqDetails() {
|
||||
if (!this.data || !this.data.rfqDetails) return []
|
||||
if (!this.selectedMonth) return this.data.rfqDetails
|
||||
return this.data.rfqDetails.filter(d => d.month === this.selectedMonth)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -136,7 +159,12 @@ export default {
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
getCostAnalysis().then(r => {
|
||||
const params = {}
|
||||
if (this.dateRange && this.dateRange.length === 2) {
|
||||
params.startMonth = this.dateRange[0]
|
||||
params.endMonth = this.dateRange[1]
|
||||
}
|
||||
getCostAnalysis(params).then(r => {
|
||||
this.data = r.data
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
@@ -151,6 +179,23 @@ export default {
|
||||
this.$message.error('加载成本数据失败')
|
||||
})
|
||||
},
|
||||
onDateChange() {
|
||||
this.selectedMonth = null
|
||||
this.loadData()
|
||||
},
|
||||
handleExport() {
|
||||
exportReport('cost', this.data).then(res => {
|
||||
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '采购成本分析.xlsx'
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}).catch(() => {
|
||||
this.$message.error('导出失败')
|
||||
})
|
||||
},
|
||||
initTrendChart() {
|
||||
const el = this.$refs.costTrendChart
|
||||
if (!el) return
|
||||
@@ -188,18 +233,34 @@ export default {
|
||||
}
|
||||
]
|
||||
})
|
||||
this.trendChart.off('click')
|
||||
this.trendChart.on('click', (params) => {
|
||||
const items = this.data.costTrend || []
|
||||
const item = items[params.dataIndex]
|
||||
if (item && item.month) {
|
||||
this.selectedMonth = item.month
|
||||
} else {
|
||||
this.selectedMonth = null
|
||||
}
|
||||
})
|
||||
},
|
||||
initCategoryChart() {
|
||||
const el = this.$refs.categoryChart
|
||||
if (!el) return
|
||||
if (this.categoryChart) this.categoryChart.dispose()
|
||||
this.categoryChart = echarts.init(el, 'macarons')
|
||||
const list = (this.data.categoryDist || []).map(d => ({
|
||||
const rawList = this.data.categoryDist || []
|
||||
const list = rawList.map(d => ({
|
||||
name: d.categoryName || '未分类',
|
||||
value: Number(d.amount) || 0
|
||||
value: Number(d.amount) || 0,
|
||||
categoryId: d.categoryId || 0
|
||||
}))
|
||||
this.categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: ¥{c} ({d}%)',
|
||||
extraCssText: 'cursor: pointer;'
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
@@ -209,6 +270,14 @@ export default {
|
||||
color: ['#e4393c', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#e4393c']
|
||||
}]
|
||||
})
|
||||
// 点击饼图扇区下钻到物料管理页
|
||||
this.categoryChart.off('click')
|
||||
this.categoryChart.on('click', (params) => {
|
||||
const item = list[params.dataIndex]
|
||||
if (item && item.categoryId && item.categoryId !== 0) {
|
||||
this.$router.push({ path: '/bid/material', query: { categoryId: item.categoryId } })
|
||||
}
|
||||
})
|
||||
},
|
||||
initSavedChart() {
|
||||
const el = this.$refs.savedChart
|
||||
@@ -216,33 +285,70 @@ export default {
|
||||
if (this.savedChart) this.savedChart.dispose()
|
||||
this.savedChart = echarts.init(el, 'macarons')
|
||||
const items = this.data.costTrend || []
|
||||
const savedData = items.map(d => Number(d.savedAmount) || 0)
|
||||
this.savedChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
const v = Number(params[0].value)
|
||||
const label = v < 0 ? '超支' : '节省'
|
||||
return '<strong>' + params[0].axisValue + '</strong><br/>'
|
||||
+ '节省:¥' + Number(params[0].value).toLocaleString()
|
||||
+ label + ':¥' + Math.abs(v).toLocaleString()
|
||||
}
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: items.map(d => d.month || '') },
|
||||
yAxis: { type: 'value', name: '节省(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
|
||||
visualMap: {
|
||||
show: false,
|
||||
type: 'piecewise',
|
||||
dimension: 1,
|
||||
pieces: [
|
||||
{ gt: 0, color: '#67C23A' },
|
||||
{ lte: 0, color: '#F56C6C' }
|
||||
]
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: items.map(d => Number(d.savedAmount) || 0),
|
||||
itemStyle: { color: '#67C23A' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(103,194,58,0.4)' },
|
||||
{ offset: 1, color: 'rgba(103,194,58,0.05)' }
|
||||
])},
|
||||
data: savedData,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(103,194,58,0.35)' },
|
||||
{ offset: 1, color: 'rgba(103,194,58,0.02)' }
|
||||
])
|
||||
},
|
||||
lineStyle: { width: 3 },
|
||||
symbol: 'diamond', symbolSize: 10,
|
||||
label: { show: true, formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
|
||||
label: {
|
||||
show: true,
|
||||
formatter: p => {
|
||||
const v = Number(p.value)
|
||||
return (v < 0 ? '-' : '') + '¥' + Math.abs(v).toLocaleString()
|
||||
},
|
||||
fontSize: 10
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#E4E7ED', type: 'dashed' },
|
||||
data: [{ yAxis: 0 }]
|
||||
}
|
||||
}]
|
||||
})
|
||||
},
|
||||
goCompare(rfqId) {
|
||||
this.$router.push({ path: '/comparison/detail', query: { rfqId } })
|
||||
this.$router.push({ path: '/bid/comparison/detail', query: { rfqId } })
|
||||
},
|
||||
tableRowClassName({ row }) {
|
||||
if (row.overBudget) return 'row-overspend'
|
||||
return ''
|
||||
},
|
||||
getDiffPercent(row) {
|
||||
const expected = Number(row.expectedTotal)
|
||||
if (!expected || expected === 0) return '0.0'
|
||||
const saved = Number(row.savedAmount)
|
||||
const pct = Math.abs(saved) / expected * 100
|
||||
return pct.toFixed(1)
|
||||
},
|
||||
formatMoney(v) {
|
||||
const n = Number(v)
|
||||
@@ -274,5 +380,13 @@ export default {
|
||||
.cell-actual { color: #e4393c; font-weight: 600; }
|
||||
.cell-saved { color: #67C23A; font-weight: 700; }
|
||||
.cell-none { color: #C0C4CC; }
|
||||
.cell-overspend { color: #F56C6C; font-weight: 700; cursor: help; i { margin-right: 2px; } }
|
||||
.diff-overspend { color: #F56C6C; font-size: 12px; font-weight: 600; }
|
||||
.diff-saved { color: #67C23A; font-size: 12px; }
|
||||
.diff-none { color: #C0C4CC; }
|
||||
.no-data { text-align: center; color: #C0C4CC; }
|
||||
::v-deep .el-table .row-overspend {
|
||||
background-color: #FEF0F0;
|
||||
td { background-color: #FEF0F0 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="page-header">
|
||||
<span class="page-title">📊 采购总览看板</span>
|
||||
<span class="page-tip">实时数据 · 自动更新</span>
|
||||
<el-button type="success" size="mini" icon="el-icon-download" @click="handleExport" style="margin-left:auto">导出Excel</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
@@ -68,7 +69,7 @@
|
||||
import * as echarts from 'echarts'
|
||||
require('echarts/theme/macarons') // echarts theme
|
||||
import { debounce } from '@/utils'
|
||||
import { getDashboard } from '@/api/bid/report'
|
||||
import { getDashboard, exportReport } from '@/api/bid/report'
|
||||
import KpiCard from './components/KpiCard'
|
||||
|
||||
export default {
|
||||
@@ -97,6 +98,7 @@ export default {
|
||||
return {
|
||||
label,
|
||||
value: item && item.value !== undefined && item.value !== null ? item.value : 0,
|
||||
totalValue: item && item.totalValue !== undefined && item.totalValue !== null ? item.totalValue : 0,
|
||||
unit: item ? item.unit : '',
|
||||
changeRate: item ? item.changeRate : 0,
|
||||
trend: item ? item.trend : 'up'
|
||||
@@ -185,6 +187,14 @@ export default {
|
||||
}
|
||||
]
|
||||
})
|
||||
this.trendChart.off('click')
|
||||
this.trendChart.on('click', (params) => {
|
||||
const list = this.data.monthlyTrend || []
|
||||
const item = list[params.dataIndex]
|
||||
if (item && item.month) {
|
||||
this.$router.push({ path: '/bid/purchaseorder', query: { month: item.month } })
|
||||
}
|
||||
})
|
||||
},
|
||||
initPieChart() {
|
||||
const el = this.$refs.pieChart
|
||||
@@ -209,6 +219,14 @@ export default {
|
||||
color: ['#e4393c', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
|
||||
}]
|
||||
})
|
||||
this.pieChart.off('click')
|
||||
this.pieChart.on('click', (params) => {
|
||||
const list = this.data.rfqStatusDist || []
|
||||
const item = list[params.dataIndex]
|
||||
if (item && item.status) {
|
||||
this.$router.push({ path: '/bid/rfq', query: { status: item.status } })
|
||||
}
|
||||
})
|
||||
},
|
||||
initRankChart() {
|
||||
const el = this.$refs.rankChart
|
||||
@@ -261,12 +279,33 @@ export default {
|
||||
}
|
||||
}]
|
||||
})
|
||||
this.rankChart.off('click')
|
||||
this.rankChart.on('click', (params) => {
|
||||
const items = this.data.topSuppliers || []
|
||||
const item = items[params.dataIndex !== undefined ? (items.length - 1 - params.dataIndex) : -1]
|
||||
if (item && item.supplierId) {
|
||||
this.$router.push({ path: '/bid/supplier', query: { supplierId: item.supplierId } })
|
||||
}
|
||||
})
|
||||
},
|
||||
actTag(type) {
|
||||
return { PO: 'primary', QUOTE: 'success', EVAL: 'warning', OBJECTION: 'danger' }[type] || 'info'
|
||||
},
|
||||
actType(type) {
|
||||
return { PO: '采购单', QUOTE: '报价', EVAL: '评价', OBJECTION: '异议' }[type] || type
|
||||
},
|
||||
handleExport() {
|
||||
exportReport('dashboard', this.data).then(res => {
|
||||
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '采购总览看板.xlsx'
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}).catch(() => {
|
||||
this.$message.error('导出失败')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="app-container supplier-page">
|
||||
<div class="page-header">
|
||||
<span class="page-title">🏆 供应商绩效</span>
|
||||
<el-button type="success" size="mini" icon="el-icon-download" @click="handleExport" style="margin-left:auto">导出Excel</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
@@ -14,25 +15,43 @@
|
||||
<span><i class="el-icon-s-custom" style="color:#e4393c"></i> 供应商综合评分排名</span>
|
||||
</div>
|
||||
<el-table :data="data.rankings || []" border size="small"
|
||||
highlight-current-row @current-change="onRowClick" style="cursor:pointer">
|
||||
<el-table-column label="排名" type="index" width="55" align="center" />
|
||||
highlight-current-row @current-change="onRowClick" style="cursor:pointer"
|
||||
:row-class-name="tableRowClassName">
|
||||
<el-table-column label="排名" width="55" align="center">
|
||||
<template slot-scope="s">{{ getRank(s.$index, s.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="供应商名称" prop="supplierName" min-width="160" />
|
||||
<el-table-column label="评价次数" prop="evalCount" width="80" align="center" />
|
||||
<el-table-column label="质量评分" width="90" align="center">
|
||||
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.qualityAvg))" size="mini">{{ safeFixed(s.row.qualityAvg, 1) }}</el-tag></template>
|
||||
<template slot-scope="s">
|
||||
<el-tag v-if="s.row.evalStatus === 'evaluated'" :type="scoreTag(Number(s.row.qualityAvg))" size="mini">{{ safeFixed(s.row.qualityAvg, 1) }}</el-tag>
|
||||
<span v-else class="score-na">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交期评分" width="90" align="center">
|
||||
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.deliveryAvg))" size="mini">{{ safeFixed(s.row.deliveryAvg, 1) }}</el-tag></template>
|
||||
<template slot-scope="s">
|
||||
<el-tag v-if="s.row.evalStatus === 'evaluated'" :type="scoreTag(Number(s.row.deliveryAvg))" size="mini">{{ safeFixed(s.row.deliveryAvg, 1) }}</el-tag>
|
||||
<span v-else class="score-na">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="服务评分" width="90" align="center">
|
||||
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.serviceAvg))" size="mini">{{ safeFixed(s.row.serviceAvg, 1) }}</el-tag></template>
|
||||
<template slot-scope="s">
|
||||
<el-tag v-if="s.row.evalStatus === 'evaluated'" :type="scoreTag(Number(s.row.serviceAvg))" size="mini">{{ safeFixed(s.row.serviceAvg, 1) }}</el-tag>
|
||||
<span v-else class="score-na">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格评分" width="90" align="center">
|
||||
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.priceAvg))" size="mini">{{ safeFixed(s.row.priceAvg, 1) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="综合评分" width="100" align="center">
|
||||
<template slot-scope="s">
|
||||
<span :class="'score-badge-' + scoreClass(s.row.totalAvg)">{{ safeFixed(s.row.totalAvg, 1) }}</span>
|
||||
<el-tag v-if="s.row.evalStatus === 'evaluated'" :type="scoreTag(Number(s.row.priceAvg))" size="mini">{{ safeFixed(s.row.priceAvg, 1) }}</el-tag>
|
||||
<span v-else class="score-na">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="综合评分" width="130" align="center">
|
||||
<template slot-scope="s">
|
||||
<div v-if="s.row.evalStatus === 'evaluated'" class="score-bar-wrap">
|
||||
<el-progress :percentage="Number(s.row.totalAvg) / 5 * 100" :color="scoreColor(s.row.totalAvg)" :stroke-width="14" :text-inside="true" :format="() => safeFixed(s.row.totalAvg, 1)"></el-progress>
|
||||
</div>
|
||||
<span v-else class="score-na">暂无评价</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="采购次数" prop="poCount" width="80" align="center" />
|
||||
@@ -48,9 +67,13 @@
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-s-marketing" style="color:#e4393c"></i> 供应商评价雷达图</span>
|
||||
<span style="float:right;font-size:12px;color:#C0C4CC">点击表格行切换</span>
|
||||
</div>
|
||||
<div ref="radarChart" style="height:320px;width:100%"></div>
|
||||
<div style="margin-bottom:10px">
|
||||
<el-select v-model="radarSelected" multiple filterable placeholder="选择供应商" size="mini" style="width:100%" @change="refreshRadarChart">
|
||||
<el-option v-for="r in evaluatedSuppliers" :key="r.supplierName" :label="r.supplierName" :value="r.supplierName" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div ref="radarChart" style="height:280px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
@@ -63,6 +86,16 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover" style="margin-bottom:16px">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-trend-charts" style="color:#409EFF"></i> 评分历史趋势</span>
|
||||
<el-select v-model="historySupplier" filterable placeholder="选择供应商" size="mini" style="float:right;width:200px" @change="refreshHistoryChart">
|
||||
<el-option v-for="r in evaluatedSuppliers" :key="r.supplierName" :label="r.supplierName" :value="r.supplierName" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div ref="historyChart" style="height:320px;width:100%"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-warning-outline" style="color:#F56C6C"></i> 订单异议统计</span>
|
||||
@@ -90,7 +123,7 @@
|
||||
import * as echarts from 'echarts'
|
||||
require('echarts/theme/macarons') // echarts theme
|
||||
import { debounce } from '@/utils'
|
||||
import { getSupplierPerformance } from '@/api/bid/report'
|
||||
import { getSupplierPerformance, exportReport } from '@/api/bid/report'
|
||||
|
||||
export default {
|
||||
name: 'ReportSupplier',
|
||||
@@ -99,7 +132,16 @@ export default {
|
||||
loading: false,
|
||||
data: null,
|
||||
radarChart: null,
|
||||
winRateChart: null
|
||||
winRateChart: null,
|
||||
historyChart: null,
|
||||
radarSelected: [],
|
||||
historySupplier: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
evaluatedSuppliers() {
|
||||
if (!this.data || !this.data.rankings) return []
|
||||
return this.data.rankings.filter(r => r.evalStatus === 'evaluated')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -107,12 +149,14 @@ export default {
|
||||
this.__resizeHandler = debounce(() => {
|
||||
this.radarChart && this.radarChart.resize()
|
||||
this.winRateChart && this.winRateChart.resize()
|
||||
this.historyChart && this.historyChart.resize()
|
||||
}, 100)
|
||||
window.addEventListener('resize', this.__resizeHandler)
|
||||
this.$watch('$store.state.sidebar.opened', () => {
|
||||
setTimeout(() => {
|
||||
this.radarChart && this.radarChart.resize()
|
||||
this.winRateChart && this.winRateChart.resize()
|
||||
this.historyChart && this.historyChart.resize()
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
@@ -120,17 +164,39 @@ export default {
|
||||
window.removeEventListener('resize', this.__resizeHandler)
|
||||
this.radarChart && this.radarChart.dispose()
|
||||
this.winRateChart && this.winRateChart.dispose()
|
||||
this.historyChart && this.historyChart.dispose()
|
||||
},
|
||||
methods: {
|
||||
handleExport() {
|
||||
exportReport('supplier', this.data).then(res => {
|
||||
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '供应商绩效评估.xlsx'
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}).catch(() => {
|
||||
this.$message.error('导出失败')
|
||||
})
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true
|
||||
getSupplierPerformance().then(r => {
|
||||
this.data = r.data
|
||||
this.loading = false
|
||||
// 默认选中前3名已评价供应商
|
||||
const evalSuppliers = (this.data.rankings || []).filter(r => r.evalStatus === 'evaluated')
|
||||
this.radarSelected = evalSuppliers.slice(0, 3).map(r => r.supplierName)
|
||||
// 默认选择第一个已评价供应商的历史趋势
|
||||
if (evalSuppliers.length > 0) {
|
||||
this.historySupplier = evalSuppliers[0].supplierName
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.initRadarChart()
|
||||
this.initWinRateChart()
|
||||
this.initHistoryChart()
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
@@ -139,7 +205,7 @@ export default {
|
||||
})
|
||||
},
|
||||
// ── 雷达图 ──
|
||||
initRadarChart(supplierName) {
|
||||
initRadarChart() {
|
||||
const el = this.$refs.radarChart
|
||||
if (!el) return
|
||||
if (this.radarChart) {
|
||||
@@ -149,8 +215,9 @@ export default {
|
||||
this.radarChart = echarts.init(el, 'macarons')
|
||||
|
||||
let radarRows = this.data.radarData || []
|
||||
if (supplierName) {
|
||||
radarRows = radarRows.filter(r => r.supplierName === supplierName)
|
||||
// 按选中的供应商筛选
|
||||
if (this.radarSelected && this.radarSelected.length > 0) {
|
||||
radarRows = radarRows.filter(r => this.radarSelected.includes(r.supplierName))
|
||||
}
|
||||
|
||||
if (!radarRows.length) {
|
||||
@@ -166,7 +233,7 @@ export default {
|
||||
{ name: '服务', max: 5 },
|
||||
{ name: '价格', max: 5 }
|
||||
]
|
||||
const colors = ['#e4393c', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#e4393c']
|
||||
const colors = ['#e4393c', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#409EFF']
|
||||
|
||||
this.radarChart.setOption({
|
||||
legend: {
|
||||
@@ -193,6 +260,21 @@ export default {
|
||||
}))
|
||||
}]
|
||||
})
|
||||
|
||||
this.radarChart.off('click')
|
||||
this.radarChart.on('click', (params) => {
|
||||
if (params.name) {
|
||||
const ranking = (this.data.rankings || []).find(r => r.supplierName === params.name)
|
||||
if (ranking && ranking.supplierId) {
|
||||
this.$router.push({ path: '/bid/supplier', query: { supplierId: ranking.supplierId } })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
refreshRadarChart() {
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => this.initRadarChart())
|
||||
})
|
||||
},
|
||||
// ── 中标率柱状图 ──
|
||||
initWinRateChart() {
|
||||
@@ -249,15 +331,96 @@ export default {
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
this.winRateChart.off('click')
|
||||
this.winRateChart.on('click', (params) => {
|
||||
const items = this.data.winRateData || []
|
||||
const item = items[params.dataIndex]
|
||||
if (item && item.supplierId) {
|
||||
this.$router.push({ path: '/bid/supplier', query: { supplierId: item.supplierId } })
|
||||
}
|
||||
})
|
||||
},
|
||||
// ── 点击表格行切换雷达图 ──
|
||||
onRowClick(row) {
|
||||
if (row && row.supplierName) {
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => this.initRadarChart(row.supplierName))
|
||||
})
|
||||
if (row && row.evalStatus === 'evaluated' && row.supplierName) {
|
||||
// 点击已评价供应商行时,将其加入雷达图选中
|
||||
if (!this.radarSelected.includes(row.supplierName)) {
|
||||
this.radarSelected.push(row.supplierName)
|
||||
this.refreshRadarChart()
|
||||
}
|
||||
}
|
||||
},
|
||||
// ── 评分历史趋势图 ──
|
||||
initHistoryChart() {
|
||||
const el = this.$refs.historyChart
|
||||
if (!el) return
|
||||
if (this.historyChart) {
|
||||
this.historyChart.dispose()
|
||||
this.historyChart = null
|
||||
}
|
||||
this.historyChart = echarts.init(el, 'macarons')
|
||||
|
||||
const allHistory = this.data.scoreHistory || []
|
||||
const supplierHistory = allHistory.filter(h => h.supplierName === this.historySupplier)
|
||||
|
||||
if (!supplierHistory.length) {
|
||||
this.historyChart.setOption({
|
||||
title: { text: '暂无评分历史数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const months = supplierHistory.map(h => h.month)
|
||||
const colors = { quality: '#e4393c', delivery: '#67C23A', service: '#E6A23C', price: '#409EFF' }
|
||||
|
||||
this.historyChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
let s = '<strong>' + params[0].axisValue + '</strong><br/>'
|
||||
params.forEach(p => {
|
||||
s += p.marker + ' ' + p.seriesName + ':' + Number(p.value).toFixed(1) + '<br/>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
},
|
||||
legend: { data: ['质量', '交期', '服务', '价格'], top: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: months },
|
||||
yAxis: { type: 'value', min: 0, max: 5, name: '评分' },
|
||||
series: [
|
||||
{ name: '质量', type: 'line', data: supplierHistory.map(h => Number(h.qualityAvg) || 0), itemStyle: { color: colors.quality }, smooth: true },
|
||||
{ name: '交期', type: 'line', data: supplierHistory.map(h => Number(h.deliveryAvg) || 0), itemStyle: { color: colors.delivery }, smooth: true },
|
||||
{ name: '服务', type: 'line', data: supplierHistory.map(h => Number(h.serviceAvg) || 0), itemStyle: { color: colors.service }, smooth: true },
|
||||
{ name: '价格', type: 'line', data: supplierHistory.map(h => Number(h.priceAvg) || 0), itemStyle: { color: colors.price }, smooth: true }
|
||||
]
|
||||
})
|
||||
},
|
||||
refreshHistoryChart() {
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => this.initHistoryChart())
|
||||
})
|
||||
},
|
||||
// ── 表格辅助方法 ──
|
||||
tableRowClassName({ row }) {
|
||||
if (row.evalStatus === 'not_evaluated') return 'row-not-evaluated'
|
||||
return ''
|
||||
},
|
||||
getRank(index, row) {
|
||||
// 已评价供应商按totalAvg降序排名,未评价排末尾不编号
|
||||
if (row.evalStatus === 'not_evaluated') return '—'
|
||||
// 计算在已评价供应商中的排名
|
||||
const evaluated = (this.data.rankings || []).filter(r => r.evalStatus === 'evaluated')
|
||||
return evaluated.findIndex(r => r.supplierId === row.supplierId) + 1
|
||||
},
|
||||
scoreColor(v) {
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return '#C0C4CC'
|
||||
if (n >= 4.0) return '#67C23A'
|
||||
if (n >= 3.0) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
},
|
||||
resolveRate(row) {
|
||||
if (!row || !row.objectionCount) return 0
|
||||
return Math.round((Number(row.resolvedCount) || 0) / Number(row.objectionCount) * 100)
|
||||
@@ -301,7 +464,10 @@ export default {
|
||||
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
|
||||
.supplier-content { min-height: 400px; }
|
||||
.no-data { text-align: center; color: #C0C4CC; padding: 40px 0; }
|
||||
.score-badge-high { color: #67C23A; font-weight: 700; font-size: 16px; }
|
||||
.score-badge-mid { color: #E6A23C; font-weight: 600; font-size: 15px; }
|
||||
.score-badge-low { color: #F56C6C; font-weight: 600; }
|
||||
.score-na { color: #C0C4CC; font-size: 13px; }
|
||||
.score-bar-wrap { padding: 0 4px; }
|
||||
::v-deep .el-table .row-not-evaluated {
|
||||
background-color: #FAFAFA;
|
||||
td { background-color: #FAFAFA !important; color: #909399; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -302,6 +302,10 @@ export default {
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 支持从报表下钻传入status参数
|
||||
if (this.$route.query.status) {
|
||||
this.queryParams.status = this.$route.query.status;
|
||||
}
|
||||
this.getList();
|
||||
// 供应商用户不需要加载供应商列表和甲方报价选项
|
||||
if (!this.isSupplier) {
|
||||
|
||||
@@ -365,6 +365,10 @@ export default {
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 支持从报表下钻传入supplierId参数
|
||||
if (this.$route.query.supplierId) {
|
||||
this.queryParams.supplierId = this.$route.query.supplierId;
|
||||
}
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
|
||||
Reference in New Issue
Block a user