feat(bid): 新增投标报表统计分析模块
本次提交新增了完整的投标报表统计分析功能,包括: 添加用于数据检查与菜单初始化的 SQL 脚本 实现采购概览仪表板、采购成本分析及供应商绩效报告的后端服务、Mapper、Controller 及 VO 类 添加前端 API、路由配置以及使用 ECharts 可视化图表的页面组件 为仪表板添加通用的 KPI 卡片组件
This commit is contained in:
19
ruoyi-ui/src/api/bid/report.js
Normal file
19
ruoyi-ui/src/api/bid/report.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
const baseUrl = '/bid/report'
|
||||
|
||||
/** 采购总览看板 */
|
||||
export const getDashboard = () => request({ url: baseUrl + '/dashboard', method: 'get' })
|
||||
|
||||
/** 采购成本分析 */
|
||||
export const getCostAnalysis = (params) => request({ url: baseUrl + '/cost', method: 'get', params })
|
||||
|
||||
/** 供应商绩效 */
|
||||
export const getSupplierPerformance = () => request({ url: baseUrl + '/supplier', method: 'get' })
|
||||
|
||||
/** 导出报表 */
|
||||
export const exportReport = (type, data) => request({
|
||||
url: baseUrl + '/export/' + type,
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob'
|
||||
})
|
||||
@@ -125,6 +125,43 @@ export const dynamicRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
// ── 统计分析 路由 ──
|
||||
{
|
||||
path: '/bid/report/dashboard',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:report:dashboard'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/report/dashboard'),
|
||||
name: 'ReportDashboard',
|
||||
meta: { title: '采购总览看板', activeMenu: '/bid/report' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/bid/report/cost',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:report:cost'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/report/cost'),
|
||||
name: 'ReportCost',
|
||||
meta: { title: '采购成本分析', activeMenu: '/bid/report' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/bid/report/supplier',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:report:supplier'],
|
||||
children: [{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/report/supplier'),
|
||||
name: 'ReportSupplier',
|
||||
meta: { title: '供应商绩效', activeMenu: '/bid/report' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/system/user-auth',
|
||||
component: Layout,
|
||||
|
||||
104
ruoyi-ui/src/views/bid/report/components/KpiCard.vue
Normal file
104
ruoyi-ui/src/views/bid/report/components/KpiCard.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<el-card shadow="hover" class="kpi-card" :style="{ '--kpi-accent': accentColor }">
|
||||
<div class="kpi-label">{{ label }}</div>
|
||||
<div class="kpi-value">
|
||||
<span class="kpi-number">{{ displayValue }}</span>
|
||||
<span class="kpi-unit" v-if="unit">{{ unit }}</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>
|
||||
<i class="el-icon-minus"></i> 持平
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
|
||||
const CARD_NAMES = ['采购总额', 'RFQ总数', '采购单数', '活跃供应商']
|
||||
|
||||
export default {
|
||||
name: 'KpiCard',
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: [Number, String], default: 0 },
|
||||
unit: { type: String, default: '' },
|
||||
changeRate: { type: Number, default: 0 },
|
||||
trend: { type: String, default: 'up' }
|
||||
},
|
||||
computed: {
|
||||
safeValue() {
|
||||
const v = Number(this.value)
|
||||
return isNaN(v) ? 0 : v
|
||||
},
|
||||
displayValue() {
|
||||
const v = this.safeValue
|
||||
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() {
|
||||
const rate = Number(this.changeRate) || 0
|
||||
return rate.toFixed(1) + '%'
|
||||
},
|
||||
accentColor() {
|
||||
const idx = CARD_NAMES.indexOf(this.label)
|
||||
return COLORS[idx >= 0 ? idx : 4]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card {
|
||||
border-left: 4px solid var(--kpi-accent, #409EFF);
|
||||
border-radius: 6px;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.kpi-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.kpi-value {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
.kpi-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.kpi-unit {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.kpi-trend {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.trend-up { color: #67C23A; }
|
||||
.trend-down { color: #F56C6C; }
|
||||
.no-change { color: #C0C4CC; }
|
||||
.trend-label {
|
||||
color: #C0C4CC;
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
278
ruoyi-ui/src/views/bid/report/cost.vue
Normal file
278
ruoyi-ui/src/views/bid/report/cost.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="app-container cost-page">
|
||||
<div class="page-header">
|
||||
<span class="page-title">💰 采购成本分析</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
<i class="el-icon-loading"></i> 加载中…
|
||||
</div>
|
||||
|
||||
<div v-show="!loading && data" class="cost-content">
|
||||
<el-row :gutter="20" class="summary-row">
|
||||
<el-col :xs="8" :sm="6" v-for="s in summaryCards" :key="s.label" style="margin-bottom:16px">
|
||||
<el-card shadow="hover" class="summary-card" :style="{ '--s-accent': s.color }">
|
||||
<div class="s-label">{{ s.label }}</div>
|
||||
<div class="s-value" :style="{ color: s.color }">{{ s.value }}</div>
|
||||
<div class="s-sub" v-if="s.sub">{{ s.sub }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover" style="margin-bottom:16px">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-data-board" style="color:#409EFF"></i> 月度预算 vs 实际成本</span>
|
||||
</div>
|
||||
<div ref="costTrendChart" style="height:350px;width:100%"></div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-s-data" style="color:#E6A23C"></i> 品类采购分布</span>
|
||||
</div>
|
||||
<div ref="categoryChart" style="height:300px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-success" style="color:#67C23A"></i> 节省分析</span>
|
||||
</div>
|
||||
<div ref="savedChart" style="height:300px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<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-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">
|
||||
<template slot-scope="s"><span class="cell-expected">¥{{ formatMoney(s.row.expectedTotal) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最低报价" width="130" align="right">
|
||||
<template slot-scope="s"><span class="cell-lowest">¥{{ formatMoney(s.row.lowestQuote) }}</span></template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<template slot-scope="s">
|
||||
<span :class="Number(s.row.savedAmount) > 0 ? 'cell-saved' : 'cell-none'">
|
||||
¥{{ formatMoney(s.row.savedAmount) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参与供应商" width="100" align="center" prop="supplierCount" />
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template slot-scope="s">
|
||||
<el-button type="text" size="mini" icon="el-icon-data-analysis"
|
||||
@click="goCompare(s.row.rfqId)">比价</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!data.rfqDetails || !data.rfqDetails.length" class="no-data" style="padding:20px">暂无数据</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
require('echarts/theme/macarons') // echarts theme
|
||||
import { debounce } from '@/utils'
|
||||
import { getCostAnalysis } from '@/api/bid/report'
|
||||
|
||||
export default {
|
||||
name: 'ReportCost',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: null,
|
||||
trendChart: null,
|
||||
categoryChart: null,
|
||||
savedChart: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
summaryCards() {
|
||||
if (!this.data || !this.data.summary) return []
|
||||
const s = this.data.summary
|
||||
return [
|
||||
{ label: '预算总额', value: '¥' + this.formatMoney(s.totalExpected), color: '#409EFF' },
|
||||
{ label: '实际采购', value: '¥' + this.formatMoney(s.totalActual), color: '#67C23A' },
|
||||
{ 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: '相比预算' }
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadData()
|
||||
this.__resizeHandler = debounce(() => {
|
||||
this.trendChart && this.trendChart.resize()
|
||||
this.categoryChart && this.categoryChart.resize()
|
||||
this.savedChart && this.savedChart.resize()
|
||||
}, 100)
|
||||
window.addEventListener('resize', this.__resizeHandler)
|
||||
this.$watch('$store.state.sidebar.opened', () => {
|
||||
setTimeout(() => {
|
||||
this.trendChart && this.trendChart.resize()
|
||||
this.categoryChart && this.categoryChart.resize()
|
||||
this.savedChart && this.savedChart.resize()
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.__resizeHandler)
|
||||
this.trendChart && this.trendChart.dispose()
|
||||
this.categoryChart && this.categoryChart.dispose()
|
||||
this.savedChart && this.savedChart.dispose()
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
getCostAnalysis().then(r => {
|
||||
this.data = r.data
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.initTrendChart()
|
||||
this.initCategoryChart()
|
||||
this.initSavedChart()
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
this.loading = false
|
||||
this.$message.error('加载成本数据失败')
|
||||
})
|
||||
},
|
||||
initTrendChart() {
|
||||
const el = this.$refs.costTrendChart
|
||||
if (!el) return
|
||||
if (this.trendChart) this.trendChart.dispose()
|
||||
this.trendChart = echarts.init(el, 'macarons')
|
||||
const items = this.data.costTrend || []
|
||||
this.trendChart.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).toLocaleString() + '<br/>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
},
|
||||
legend: { data: ['预算金额', '实际金额'], left: 0 },
|
||||
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 } },
|
||||
series: [
|
||||
{
|
||||
name: '预算金额', type: 'bar',
|
||||
data: items.map(d => Number(d.expectedAmount) || 0),
|
||||
itemStyle: { color: '#C0C4CC', borderRadius: [4,4,0,0] },
|
||||
barWidth: '35%'
|
||||
},
|
||||
{
|
||||
name: '实际金额', type: 'bar',
|
||||
data: items.map(d => Number(d.actualAmount) || 0),
|
||||
itemStyle: { color: '#409EFF', borderRadius: [4,4,0,0] },
|
||||
barWidth: '35%',
|
||||
label: { show: true, position: 'top', formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
|
||||
}
|
||||
]
|
||||
})
|
||||
},
|
||||
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 => ({
|
||||
name: d.categoryName || '未分类',
|
||||
value: Number(d.amount) || 0
|
||||
}))
|
||||
this.categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['50%', '55%'],
|
||||
label: { show: true, formatter: '{b}\n{d}%', fontSize: 11 },
|
||||
data: list.length ? list : [{ name: '暂无', value: 1 }],
|
||||
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#22a4ff']
|
||||
}]
|
||||
})
|
||||
},
|
||||
initSavedChart() {
|
||||
const el = this.$refs.savedChart
|
||||
if (!el) return
|
||||
if (this.savedChart) this.savedChart.dispose()
|
||||
this.savedChart = echarts.init(el, 'macarons')
|
||||
const items = this.data.costTrend || []
|
||||
this.savedChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
return '<strong>' + params[0].axisValue + '</strong><br/>'
|
||||
+ '节省:¥' + Number(params[0].value).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 } },
|
||||
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)' }
|
||||
])},
|
||||
lineStyle: { width: 3 },
|
||||
symbol: 'diamond', symbolSize: 10,
|
||||
label: { show: true, formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
|
||||
}]
|
||||
})
|
||||
},
|
||||
goCompare(rfqId) {
|
||||
this.$router.push({ path: '/bid/comparison/detail', query: { rfqId } })
|
||||
},
|
||||
formatMoney(v) {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? '0.00' : n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cost-page { padding-bottom: 30px; }
|
||||
.page-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
|
||||
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
|
||||
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
|
||||
.cost-content { min-height: 400px; }
|
||||
.summary-card {
|
||||
border-left: 4px solid var(--s-accent, #409EFF);
|
||||
border-radius: 6px;
|
||||
.s-label { font-size: 14px; color: #909399; margin-bottom: 8px; }
|
||||
.s-value { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
|
||||
.s-sub { font-size: 12px; color: #C0C4CC; }
|
||||
}
|
||||
.cell-expected { color: #C0C4CC; }
|
||||
.cell-lowest { color: #E6A23C; font-weight: 600; }
|
||||
.cell-actual { color: #409EFF; font-weight: 600; }
|
||||
.cell-saved { color: #67C23A; font-weight: 700; }
|
||||
.cell-none { color: #C0C4CC; }
|
||||
.no-data { text-align: center; color: #C0C4CC; }
|
||||
</style>
|
||||
296
ruoyi-ui/src/views/bid/report/dashboard.vue
Normal file
296
ruoyi-ui/src/views/bid/report/dashboard.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="app-container dashboard-page">
|
||||
<div class="page-header">
|
||||
<span class="page-title">📊 采购总览看板</span>
|
||||
<span class="page-tip">实时数据 · 自动更新</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
<i class="el-icon-loading"></i> 加载中…
|
||||
</div>
|
||||
|
||||
<div v-show="!loading && data" class="dashboard-content">
|
||||
<el-row :gutter="20" class="kpi-row">
|
||||
<el-col :xs="12" :sm="6" v-for="card in kpiCards" :key="card.label" style="margin-bottom:16px">
|
||||
<KpiCard v-bind="card" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="14" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-data-line" style="color:#409EFF"></i> 月度采购趋势</span>
|
||||
</div>
|
||||
<div ref="trendChart" style="height:320px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="10" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-pie-chart" style="color:#67C23A"></i> RFQ 状态分布</span>
|
||||
</div>
|
||||
<div ref="pieChart" style="height:320px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-s-order" style="color:#E6A23C"></i> 供应商采购排名 Top5</span>
|
||||
</div>
|
||||
<div ref="rankChart" style="height:280px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-timer" style="color:#909399"></i> 最新动态</span>
|
||||
</div>
|
||||
<div class="activity-list" v-if="data.recentActivities && data.recentActivities.length">
|
||||
<div v-for="(act, i) in data.recentActivities" :key="i" class="activity-item">
|
||||
<span class="act-time">{{ act.time }}</span>
|
||||
<el-tag :type="actTag(act.type)" size="mini">{{ actType(act.type) }}</el-tag>
|
||||
<span class="act-desc">{{ act.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">暂无动态</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
require('echarts/theme/macarons') // echarts theme
|
||||
import { debounce } from '@/utils'
|
||||
import { getDashboard } from '@/api/bid/report'
|
||||
import KpiCard from './components/KpiCard'
|
||||
|
||||
export default {
|
||||
name: 'ReportDashboard',
|
||||
components: { KpiCard },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: null,
|
||||
trendChart: null,
|
||||
pieChart: null,
|
||||
rankChart: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
kpiCards() {
|
||||
if (!this.data) return []
|
||||
const map = {
|
||||
'采购总额': 'totalPurchaseAmount',
|
||||
'RFQ总数': 'totalRfqCount',
|
||||
'采购单数': 'totalPoCount',
|
||||
'活跃供应商': 'activeSupplierCount'
|
||||
}
|
||||
return Object.entries(map).map(([label, key]) => {
|
||||
const item = this.data[key]
|
||||
return {
|
||||
label,
|
||||
value: item && item.value !== undefined && item.value !== null ? item.value : 0,
|
||||
unit: item ? item.unit : '',
|
||||
changeRate: item ? item.changeRate : 0,
|
||||
trend: item ? item.trend : 'up'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadData()
|
||||
this.__resizeHandler = debounce(() => {
|
||||
this.trendChart && this.trendChart.resize()
|
||||
this.pieChart && this.pieChart.resize()
|
||||
this.rankChart && this.rankChart.resize()
|
||||
}, 100)
|
||||
window.addEventListener('resize', this.__resizeHandler)
|
||||
// 监听侧边栏变化
|
||||
this.$watch('$store.state.sidebar.opened', () => {
|
||||
setTimeout(() => {
|
||||
this.trendChart && this.trendChart.resize()
|
||||
this.pieChart && this.pieChart.resize()
|
||||
this.rankChart && this.rankChart.resize()
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.__resizeHandler)
|
||||
this.trendChart && this.trendChart.dispose()
|
||||
this.pieChart && this.pieChart.dispose()
|
||||
this.rankChart && this.rankChart.dispose()
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
getDashboard().then(r => {
|
||||
this.data = r.data
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.initTrendChart()
|
||||
this.initPieChart()
|
||||
this.initRankChart()
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
this.loading = false
|
||||
this.$message.error('加载看板数据失败')
|
||||
})
|
||||
},
|
||||
initTrendChart() {
|
||||
const el = this.$refs.trendChart
|
||||
if (!el) return
|
||||
if (this.trendChart) this.trendChart.dispose()
|
||||
this.trendChart = echarts.init(el, 'macarons')
|
||||
const list = this.data.monthlyTrend || []
|
||||
this.trendChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
let s = '<strong>' + params[0].axisValue + '</strong><br/>'
|
||||
params.forEach(p => {
|
||||
if (p.seriesIndex === 0) s += p.marker + ' 金额:¥' + Number(p.value).toLocaleString() + '<br/>'
|
||||
else s += p.marker + ' 单数:' + p.value + ' 单<br/>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
},
|
||||
legend: { data: ['采购金额', '采购单数'], left: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: list.map(d => d.month || ''), axisLabel: { fontSize: 11 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
|
||||
{ type: 'value', name: '单数' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '采购金额', type: 'bar', data: list.map(d => Number(d.amount) || 0),
|
||||
itemStyle: { color: '#409EFF', borderRadius: [4,4,0,0] },
|
||||
barWidth: '40%',
|
||||
label: { show: true, position: 'top', formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
|
||||
},
|
||||
{
|
||||
name: '采购单数', type: 'line', yAxisIndex: 1, data: list.map(d => Number(d.count) || 0),
|
||||
itemStyle: { color: '#67C23A' },
|
||||
lineStyle: { width: 3 },
|
||||
symbol: 'circle', symbolSize: 8
|
||||
}
|
||||
]
|
||||
})
|
||||
},
|
||||
initPieChart() {
|
||||
const el = this.$refs.pieChart
|
||||
if (!el) return
|
||||
if (this.pieChart) this.pieChart.dispose()
|
||||
this.pieChart = echarts.init(el, 'macarons')
|
||||
const list = (this.data.rfqStatusDist || []).map(d => ({
|
||||
name: d.statusLabel || d.status || '未知',
|
||||
value: Number(d.count) || 0
|
||||
}))
|
||||
this.pieChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} 单 ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '55%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{d}%', fontSize: 11 },
|
||||
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
||||
data: list.length ? list : [{ name: '暂无数据', value: 1 }],
|
||||
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
|
||||
}]
|
||||
})
|
||||
},
|
||||
initRankChart() {
|
||||
const el = this.$refs.rankChart
|
||||
if (!el) return
|
||||
if (this.rankChart) this.rankChart.dispose()
|
||||
this.rankChart = echarts.init(el, 'macarons')
|
||||
const items = this.data.topSuppliers || []
|
||||
if (!items.length) {
|
||||
this.rankChart.setOption({
|
||||
title: { text: '暂无采购数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
|
||||
})
|
||||
return
|
||||
}
|
||||
const names = items.map(d => d.supplierName || '未知')
|
||||
const amounts = items.map(d => Number(d.totalAmount) || 0)
|
||||
this.rankChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
const idx = params[0].dataIndex
|
||||
const item = items[idx]
|
||||
if (!item) return ''
|
||||
return `<strong>${item.supplierName}</strong><br/>
|
||||
采购金额:¥${Number(item.totalAmount).toLocaleString()}<br/>
|
||||
采购次数:${item.poCount || 0} 次<br/>
|
||||
综合评分:${(Number(item.avgScore) || 0).toFixed(1)} 分`
|
||||
}
|
||||
},
|
||||
grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'value', name: '金额(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
|
||||
yAxis: { type: 'category', data: names.slice().reverse(), axisLabel: { fontSize: 12 } },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: amounts.slice().reverse().map(v => ({
|
||||
value: v,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{ offset: 0, color: '#409EFF' },
|
||||
{ offset: 1, color: '#22a4ff' }
|
||||
]),
|
||||
borderRadius: [0, 6, 6, 0]
|
||||
}
|
||||
})),
|
||||
barWidth: '55%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: p => '¥' + Number(p.value).toLocaleString(),
|
||||
fontSize: 11
|
||||
}
|
||||
}]
|
||||
})
|
||||
},
|
||||
actTag(type) {
|
||||
return { PO: 'primary', QUOTE: 'success', EVAL: 'warning', OBJECTION: 'danger' }[type] || 'info'
|
||||
},
|
||||
actType(type) {
|
||||
return { PO: '采购单', QUOTE: '报价', EVAL: '评价', OBJECTION: '异议' }[type] || type
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard-page { padding-bottom: 30px; }
|
||||
.page-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
|
||||
.page-tip { font-size: 13px; color: #C0C4CC; }
|
||||
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
|
||||
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
|
||||
.kpi-row { margin-bottom: 4px; }
|
||||
.dashboard-content { min-height: 400px; }
|
||||
.activity-list { max-height: 280px; overflow-y: auto; }
|
||||
.activity-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.act-time { font-size: 12px; color: #C0C4CC; white-space: nowrap; min-width: 120px; }
|
||||
.act-desc { font-size: 13px; color: #606266; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.no-data { padding: 40px 0; text-align: center; color: #C0C4CC; }
|
||||
</style>
|
||||
307
ruoyi-ui/src/views/bid/report/supplier.vue
Normal file
307
ruoyi-ui/src/views/bid/report/supplier.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="app-container supplier-page">
|
||||
<div class="page-header">
|
||||
<span class="page-title">🏆 供应商绩效</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-box">
|
||||
<i class="el-icon-loading"></i> 加载中…
|
||||
</div>
|
||||
|
||||
<div v-show="!loading && data" class="supplier-content">
|
||||
<el-card shadow="hover" style="margin-bottom:16px">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-s-custom" style="color:#409EFF"></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" />
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="采购次数" prop="poCount" width="80" align="center" />
|
||||
<el-table-column label="采购金额" width="130" align="right">
|
||||
<template slot-scope="s">¥{{ formatMoney(s.row.poAmount) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!data.rankings || !data.rankings.length" class="no-data">暂无数据</div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-s-marketing" style="color:#409EFF"></i> 供应商评价雷达图</span>
|
||||
<span style="float:right;font-size:12px;color:#C0C4CC">点击表格行切换</span>
|
||||
</div>
|
||||
<div ref="radarChart" style="height:320px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-data-board" style="color:#67C23A"></i> 中标率分析</span>
|
||||
</div>
|
||||
<div ref="winRateChart" style="height:320px;width:100%"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<div slot="header" class="card-header">
|
||||
<span><i class="el-icon-warning-outline" style="color:#F56C6C"></i> 订单异议统计</span>
|
||||
</div>
|
||||
<el-table :data="data.objectionStats || []" border size="small">
|
||||
<el-table-column label="供应商" prop="supplierName" min-width="160" />
|
||||
<el-table-column label="异议数" prop="objectionCount" width="90" align="center" />
|
||||
<el-table-column label="已解决" prop="resolvedCount" width="80" align="center" />
|
||||
<el-table-column label="解决率" width="100" align="center">
|
||||
<template slot-scope="s">
|
||||
<el-progress :percentage="resolveRate(s.row)" :stroke-width="12" :show-text="true" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主要问题" prop="topReason" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div v-if="!data.objectionStats || !data.objectionStats.length" class="no-data" style="padding:20px">
|
||||
暂无订单异议记录
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
require('echarts/theme/macarons') // echarts theme
|
||||
import { debounce } from '@/utils'
|
||||
import { getSupplierPerformance } from '@/api/bid/report'
|
||||
|
||||
export default {
|
||||
name: 'ReportSupplier',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: null,
|
||||
radarChart: null,
|
||||
winRateChart: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadData()
|
||||
this.__resizeHandler = debounce(() => {
|
||||
this.radarChart && this.radarChart.resize()
|
||||
this.winRateChart && this.winRateChart.resize()
|
||||
}, 100)
|
||||
window.addEventListener('resize', this.__resizeHandler)
|
||||
this.$watch('$store.state.sidebar.opened', () => {
|
||||
setTimeout(() => {
|
||||
this.radarChart && this.radarChart.resize()
|
||||
this.winRateChart && this.winRateChart.resize()
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.__resizeHandler)
|
||||
this.radarChart && this.radarChart.dispose()
|
||||
this.winRateChart && this.winRateChart.dispose()
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
getSupplierPerformance().then(r => {
|
||||
this.data = r.data
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.initRadarChart()
|
||||
this.initWinRateChart()
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
this.loading = false
|
||||
this.$message.error('加载供应商数据失败')
|
||||
})
|
||||
},
|
||||
// ── 雷达图 ──
|
||||
initRadarChart(supplierName) {
|
||||
const el = this.$refs.radarChart
|
||||
if (!el) return
|
||||
if (this.radarChart) {
|
||||
this.radarChart.dispose()
|
||||
this.radarChart = null
|
||||
}
|
||||
this.radarChart = echarts.init(el, 'macarons')
|
||||
|
||||
let radarRows = this.data.radarData || []
|
||||
if (supplierName) {
|
||||
radarRows = radarRows.filter(r => r.supplierName === supplierName)
|
||||
}
|
||||
|
||||
if (!radarRows.length) {
|
||||
this.radarChart.setOption({
|
||||
title: { text: '暂无评价数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const indicator = [
|
||||
{ name: '质量', max: 5 },
|
||||
{ name: '交期', max: 5 },
|
||||
{ name: '服务', max: 5 },
|
||||
{ name: '价格', max: 5 }
|
||||
]
|
||||
const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#22a4ff']
|
||||
|
||||
this.radarChart.setOption({
|
||||
legend: {
|
||||
data: radarRows.map(r => r.supplierName),
|
||||
top: 0,
|
||||
textStyle: { fontSize: 11 }
|
||||
},
|
||||
radar: {
|
||||
indicator,
|
||||
center: ['50%', '58%'],
|
||||
radius: '60%',
|
||||
shape: 'circle',
|
||||
name: { textStyle: { fontSize: 13, fontWeight: 600 } },
|
||||
splitArea: { areaStyle: { color: ['rgba(64,158,255,0.02)', 'rgba(64,158,255,0.05)'] } }
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: radarRows.map((r, i) => ({
|
||||
name: r.supplierName,
|
||||
value: [Number(r.quality) || 0, Number(r.delivery) || 0, Number(r.service) || 0, Number(r.price) || 0],
|
||||
lineStyle: { color: colors[i % colors.length], width: 2 },
|
||||
areaStyle: { color: colors[i % colors.length], opacity: 0.1 },
|
||||
itemStyle: { color: colors[i % colors.length] }
|
||||
}))
|
||||
}]
|
||||
})
|
||||
},
|
||||
// ── 中标率柱状图 ──
|
||||
initWinRateChart() {
|
||||
const el = this.$refs.winRateChart
|
||||
if (!el) return
|
||||
if (this.winRateChart) {
|
||||
this.winRateChart.dispose()
|
||||
this.winRateChart = null
|
||||
}
|
||||
this.winRateChart = echarts.init(el, 'macarons')
|
||||
|
||||
const items = this.data.winRateData || []
|
||||
if (!items.length) {
|
||||
this.winRateChart.setOption({
|
||||
title: { text: '暂无报价数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const names = items.map(d => d.supplierName || '')
|
||||
const total = items.map(d => Number(d.totalQuotes) || 0)
|
||||
const win = items.map(d => Number(d.winCount) || 0)
|
||||
|
||||
this.winRateChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
const idx = params[0].dataIndex
|
||||
const item = items[idx]
|
||||
if (!item) return ''
|
||||
return `<strong>${item.supplierName}</strong><br/>
|
||||
报价次数:${Number(item.totalQuotes) || 0} 次<br/>
|
||||
中标次数:${Number(item.winCount) || 0} 次<br/>
|
||||
中标率:${item.winRate || 0}%`
|
||||
}
|
||||
},
|
||||
legend: { data: ['报价次数', '中标次数'], left: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: names, axisLabel: { fontSize: 11, rotate: 15 } },
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{
|
||||
name: '报价次数', type: 'bar',
|
||||
data: total,
|
||||
itemStyle: { color: '#C0C4CC', borderRadius: [4,4,0,0] },
|
||||
barWidth: '30%'
|
||||
},
|
||||
{
|
||||
name: '中标次数', type: 'bar',
|
||||
data: win,
|
||||
itemStyle: { color: '#67C23A', borderRadius: [4,4,0,0] },
|
||||
barWidth: '30%',
|
||||
label: { show: true, position: 'top', formatter: p => items[p.dataIndex] ? (items[p.dataIndex].winRate || 0) + '%' : '' }
|
||||
}
|
||||
]
|
||||
})
|
||||
},
|
||||
// ── 点击表格行切换雷达图 ──
|
||||
onRowClick(row) {
|
||||
if (row && row.supplierName) {
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => this.initRadarChart(row.supplierName))
|
||||
})
|
||||
}
|
||||
},
|
||||
resolveRate(row) {
|
||||
if (!row || !row.objectionCount) return 0
|
||||
return Math.round((Number(row.resolvedCount) || 0) / Number(row.objectionCount) * 100)
|
||||
},
|
||||
// ── 安全工具函数 ──
|
||||
safeFixed(v, digits) {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? '0.0' : n.toFixed(digits)
|
||||
},
|
||||
scoreTag(v) {
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return 'info'
|
||||
if (n >= 4.5) return 'success'
|
||||
if (n >= 3.5) return 'primary'
|
||||
if (n >= 2.5) return 'warning'
|
||||
return 'danger'
|
||||
},
|
||||
scoreClass(v) {
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return 'low'
|
||||
if (n >= 4.0) return 'high'
|
||||
if (n >= 3.0) return 'mid'
|
||||
return 'low'
|
||||
},
|
||||
formatMoney(v) {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? '0.00' : n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.supplier-page { padding-bottom: 30px; }
|
||||
.page-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
|
||||
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
|
||||
.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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user