feat(F006-W3xB): Dashboard 加图表 + 较昨日真值

- DashboardView.vue 加 2 个 echarts 图表:
  * 询价趋势线 (近 7 天折线图, 按 publishDate 毫秒时间戳按天分桶)
  * 询价状态分布 (环图, 4 状态, 已成交大块)
- stat 卡 今日询价 + 较昨日 用 inquiry createDate 按天聚合算
- formatTime 工具: 毫秒时间戳 -> "MM-DD HH:mm" 替代原来直接显示 1782662400000
- dateKey 工具: 毫秒时间戳 -> YYYY-MM-DD
- ECharts 用 var(--chart-1/2/3/4) + 主题 watch 切主题自动重渲染
- 失败策略: 任何 API 失败回退 FALLBACK_TREND/FALLBACK_STATUS
- 验证 (Playwright):
  * 今日询价 0 0% 较昨日 (无今日数据)
  * 已成交 192 (真数据)
  * 趋势 5/24-6/28 真数据折线
  * 4 主题切色正常
This commit is contained in:
Sisyphus 2026-07-04 18:36:49 +08:00
parent 4141a27a0c
commit e791520f5d

View File

@ -2,23 +2,45 @@
/**
* F006 数据看板
* W1 D3: mock 数据
* W1 D4: 接真实 API(/sys/openapi/inquiry/publish 等)
* W2 D3: 接真 API,失败回退 mock(渐进式)
* W3.5: 4 stat 全接 inquiry 真 API
* - 总数 = inquiry.data.length(后端分页参数未生效,实际一次返全部)
* - 状态 = 按 apprStatus 推断:0=待报价,1=报价中,2=已成交
* - 待处理 = 全部 - 已成交
*
* 失败策略:任意一个 API 失败不影响其他,失败回退 mock
*/
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch, nextTick } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
const stats = ref([
{ label: '今日询价', value: '128', delta: '+12.5%', trend: 'up', icon: 'ChatDotRound' },
{ label: '订单总额', value: '¥328K', delta: '+8.3%', trend: 'up', icon: 'Money' },
{ label: '活跃供应商', value: '42', delta: '+3', trend: 'up', icon: 'OfficeBuilding' },
{ label: '待处理事项', value: '7', delta: '-2', trend: 'down', icon: 'Bell' }
])
interface Stat {
label: string
value: string
delta: string
trend: 'up' | 'down'
icon: string
}
interface Inquiry {
id: string
customer: string
product: string
qty: string
status: string
time: string
}
const recentInquiries = ref([
{ id: 'RFQ-20668', customer: '中铁建设集团', product: 'HRB400 螺纹钢', qty: '200 吨', status: 'pending', time: '10:24' },
{ id: 'RFQ-20667', customer: '中建八局', product: '商混 C30', qty: '500 m³', status: 'quoting', time: '09:15' },
{ id: 'RFQ-20666', customer: '上海建工', product: '盘螺 8mm', qty: '80 吨', status: 'done', time: '08:42' },
{ id: 'RFQ-20665', customer: '北京城建', product: '水泥 P.O 42.5', qty: '100 吨', status: 'cancelled', time: '昨天 16:30' }
const stats = ref<Stat[]>([
{ label: '今日询价', value: '--', delta: '', trend: 'up', icon: 'ChatDotRound' },
{ label: '订单总额', value: '--', delta: '', trend: 'up', icon: 'Money' },
{ label: '活跃供应商', value: '--', delta: '', trend: 'up', icon: 'OfficeBuilding' },
{ label: '待处理事项', value: '--', delta: '', trend: 'down', icon: 'Bell' }
])
const recentInquiries = ref<Inquiry[]>([])
const apiStatus = ref<'loading' | 'live' | 'fallback'>('loading')
// W3.x B.3 图表数据
const trendData = ref<{ dates: string[]; counts: number[] }>({ dates: [], counts: [] })
const statusSlices = ref<{ name: string; value: number; color: string }[]>([])
const STATUS_MAP: Record<string, { label: string; type: string }> = {
pending: { label: '待报价', type: 'primary' },
@ -27,18 +49,314 @@ const STATUS_MAP: Record<string, { label: string; type: string }> = {
cancelled: { label: '已取消', type: 'danger' }
}
onMounted(() => {
// W1 D4: 这里替换为真实 API 调用
// const res = await api.get('/sys/openapi/inquiry/publish', { params: { customerCode: 'admin' } })
// stats.value = transformStats(res)
const recentColumns = [
{ prop: 'id', label: '单号', width: 140 },
{ prop: 'customer', label: '客户' },
{ prop: 'product', label: '商品' },
{ prop: 'qty', label: '数量', width: 100 },
{ prop: 'status', label: '状态', width: 110, type: 'status' as const, statusMap: STATUS_MAP },
{ prop: 'time', label: '时间', width: 140 }
]
const FALLBACK_INQUIRIES: Inquiry[] = [
{ id: 'RFQ-20668', customer: '中铁建设集团', product: 'HRB400 螺纹钢', qty: '200 吨', status: 'pending', time: '10:24' },
{ id: 'RFQ-20667', customer: '中建八局', product: '商混 C30', qty: '500 m³', status: 'quoting', time: '09:15' },
{ id: 'RFQ-20666', customer: '上海建工', product: '盘螺 8mm', qty: '80 吨', status: 'done', time: '08:42' },
{ id: 'RFQ-20665', customer: '北京城建', product: '水泥 P.O 42.5', qty: '100 吨', status: 'cancelled', time: '昨天 16:30' }
]
const FALLBACK_STATS: Stat[] = [
{ label: '今日询价', value: '128', delta: '+12.5%', trend: 'up', icon: 'ChatDotRound' },
{ label: '订单总额', value: '¥328K', delta: '+8.3%', trend: 'up', icon: 'Money' },
{ label: '活跃供应商', value: '42', delta: '+3', trend: 'up', icon: 'OfficeBuilding' },
{ label: '待处理事项', value: '7', delta: '-2', trend: 'down', icon: 'Bell' }
]
const FALLBACK_TREND = {
dates: ['7/1', '7/2', '7/3', '7/4', '7/5', '7/6', '7/7'],
counts: [12, 18, 15, 22, 28, 24, 32]
}
const FALLBACK_STATUS = [
{ name: '待报价', value: 28, color: '#3B82F6' },
{ name: '报价中', value: 14, color: '#F59E0B' },
{ name: '已成交', value: 86, color: '#10B981' },
{ name: '已取消', value: 6, color: '#EF4444' }
]
/**
* F002 验过:GET /sys/openapi/inquiry/list?status=&page=&limit=
* 这里用于拉近期询价;若 status 不支持或返结构变,回退 mock
*
* 后端无 total 字段、status 字段,前端按 apprStatus 映射状态
*/
async function loadRecentInquiries(): Promise<Inquiry[]> {
try {
const data = await httpGet<any[]>('/sys/openapi/inquiry/list', { page: 1, limit: 5 })
if (!Array.isArray(data)) throw new Error('inquiry list not array')
return data.slice(0, 5).map((it: any) => {
const s = it.apprStatus === 0 ? 'pending'
: it.apprStatus === 1 ? 'quoting'
: it.apprStatus === 2 ? 'done'
: 'pending'
return {
id: it.orderId || it.id || it.rfqId || it.inquiryId || 'RFQ-?',
customer: it.inquiryEnterpriseName || it.customerName || it.customer || '--',
product: it.productName || it.product || '--',
qty: it.purchaseQuantity ? `${it.purchaseQuantity} ${it.unit || ''}`.trim() : '--',
status: s,
time: formatTime(it.publishDate || it.createDate || it.createTime),
}
})
} catch {
return FALLBACK_INQUIRIES
}
}
/**
* 看板顶部 4 个 stat - 接 inquiry 真 API
* 后端 /sys/openapi/inquiry/list 实际返所有数据(无 total 字段),
* 但有 apprStatus 字段可以推断状态:
* - 0 = 待报价
* - 1 = 报价中
* - 2 = 已成交(已完成)
* Dashboard 4 stat 字段映射:
* - 询价总数 = data.length
* - 待报价 = apprStatus == 0 计数
* - 报价中 = apprStatus == 1 计数
* - 已成交 = apprStatus == 2 计数
*
* 失败回退 mock(FALLBACK_STATS)
*/
async function loadStats(): Promise<Stat[]> {
try {
const data = await httpGet<any[]>('/sys/openapi/inquiry/list', { page: 1, limit: 500 })
if (!Array.isArray(data)) throw new Error('inquiry list not array')
const total = data.length
const pending = data.filter((it: any) => it.apprStatus === 0).length
const quoting = data.filter((it: any) => it.apprStatus === 1).length
const done = data.filter((it: any) => it.apprStatus === 2).length
return [
{ label: '询价总数', value: String(total), delta: '', trend: 'up', icon: 'ChatDotRound' },
{ label: '待报价', value: String(pending), delta: '', trend: 'up', icon: 'Bell' },
{ label: '报价中', value: String(quoting), delta: '', trend: 'up', icon: 'Loading' },
{ label: '已成交', value: String(done), delta: '', trend: 'down', icon: 'CircleCheck' }
]
} catch {
return FALLBACK_STATS
}
}
/**
* W3.x B.2: 较昨日差值
*/
function calcDelta(todayCount: number, yesterdayCount: number): { delta: string; trend: 'up' | 'down' } {
if (yesterdayCount === 0) {
return { delta: todayCount > 0 ? '+100%' : '0%', trend: 'up' }
}
const pct = ((todayCount - yesterdayCount) / yesterdayCount) * 100
const sign = pct >= 0 ? '+' : ''
return { delta: `${sign}${pct.toFixed(1)}%`, trend: pct >= 0 ? 'up' : 'down' }
}
/**
* W3.x B.3: 状态分布环图
*/
interface StatusSlice { name: string; value: number; color: string }
function buildStatusSlices(items: any[]): StatusSlice[] {
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0 }
items.forEach((it: any) => {
const k = typeof it.apprStatus === 'number' ? it.apprStatus : 0
counts[k] = (counts[k] || 0) + 1
})
return [
{ name: '待报价', value: counts[0], color: 'var(--chart-1)' },
{ name: '报价中', value: counts[1], color: 'var(--chart-2)' },
{ name: '已成交', value: counts[2], color: 'var(--chart-3)' },
{ name: '已取消', value: counts[3], color: 'var(--chart-4)' }
]
}
/**
* W3.x B.3: 询价趋势线
*/
/**
* 工具: 后端返的 publishDate 是毫秒时间戳字符串 (e.g. "1782662400000")
* 转成 YYYY-MM-DD 字符串供分桶
*/
function dateKey(ts: string | number | undefined | null): string {
if (!ts) return ''
const n = typeof ts === 'string' ? Number(ts) : ts
if (!Number.isFinite(n) || n <= 0) return ''
return new Date(n).toISOString().slice(0, 10)
}
/**
* 工具: 毫秒时间戳 → "MM-DD HH:mm" 格式
*/
function formatTime(ts: string | number | undefined | null): string {
if (!ts) return '--'
const n = typeof ts === 'string' ? Number(ts) : ts
if (!Number.isFinite(n) || n <= 0) return '--'
const d = new Date(n)
const pad = (x: number) => String(x).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function buildTrend(items: any[]): { dates: string[]; counts: number[] } {
const buckets: Record<string, number> = {}
items.forEach((it: any) => {
const d = dateKey(it.publishDate || it.createDate || it.createTime)
if (!d) return
buckets[d] = (buckets[d] || 0) + 1
})
const sorted = Object.keys(buckets).sort()
const last7 = sorted.slice(-7)
return { dates: last7.map(d => d.slice(5)), counts: last7.map(d => buckets[d]) }
}
onMounted(async () => {
const [s, r] = await Promise.all([loadStats(), loadRecentInquiries()])
stats.value = s
recentInquiries.value = r
// 拉一次全量算图表 + 较昨日
let allInquiries: any[] = []
try {
allInquiries = await httpGet<any[]>('/sys/openapi/inquiry/list', { page: 1, limit: 500 })
if (!Array.isArray(allInquiries)) allInquiries = []
} catch { allInquiries = [] }
const isLive = allInquiries.length > 0
if (isLive) {
// W3.x B.2 较昨日
const buckets: Record<string, number> = {}
allInquiries.forEach((it: any) => {
const d = dateKey(it.publishDate || it.createDate)
if (d) buckets[d] = (buckets[d] || 0) + 1
})
const todayKey = new Date().toISOString().slice(0, 10)
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
const today = buckets[todayKey] || 0
const yest = buckets[yesterday] || 0
const d = calcDelta(today, yest)
stats.value = [
{ label: '今日询价', value: String(today), delta: d.delta, trend: d.trend, icon: 'ChatDotRound' },
...stats.value.slice(1)
]
// W3.x B.3 图表
trendData.value = buildTrend(allInquiries)
statusSlices.value = buildStatusSlices(allInquiries)
} else {
trendData.value = FALLBACK_TREND
statusSlices.value = FALLBACK_STATUS
}
apiStatus.value = isLive ? 'live' : 'fallback'
})
/**
* W3.x B.3 ECharts 渲染
* - 趋势线: 折线图, x 轴 7 天日期, y 轴询价数, 主题 brand 色
* - 环图: 4 状态 + 主题色, 中心显示总数
* - watch 数据 + 主题变化重渲染(切主题时配色变)
*/
import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts'
import { TooltipComponent, GridComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useThemeStore } from '@/stores/theme'
echarts.use([LineChart, PieChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])
const trendChartEl = ref<HTMLDivElement | null>(null)
const statusChartEl = ref<HTMLDivElement | null>(null)
let trendChart: echarts.ECharts | null = null
let statusChart: echarts.ECharts | null = null
const themeStore = useThemeStore()
function getBrandColor(): string { return getComputedStyle(document.body).getPropertyValue('--brand').trim() || '#2563EB' }
function getInkColor(): string { return getComputedStyle(document.body).getPropertyValue('--ink-3').trim() || '#64748B' }
function getBorderColor(): string { return getComputedStyle(document.body).getPropertyValue('--border').trim() || '#E2E8F0' }
function getSurfaceColor(): string { return getComputedStyle(document.body).getPropertyValue('--surface').trim() || '#FFFFFF' }
function renderTrend() {
if (!trendChartEl.value) return
if (!trendChart) trendChart = echarts.init(trendChartEl.value)
trendChart.setOption({
grid: { left: 40, right: 20, top: 30, bottom: 30 },
tooltip: { trigger: 'axis', backgroundColor: getSurfaceColor(), borderColor: getBorderColor(), textStyle: { color: getInkColor() } },
xAxis: { type: 'category', data: trendData.value.dates, axisLine: { lineStyle: { color: getBorderColor() } }, axisLabel: { color: getInkColor() } },
yAxis: { type: 'value', splitLine: { lineStyle: { color: getBorderColor() } }, axisLabel: { color: getInkColor() } },
series: [{
type: 'line', data: trendData.value.counts, smooth: true,
itemStyle: { color: getBrandColor() },
lineStyle: { color: getBrandColor(), width: 2 },
areaStyle: { color: getBrandColor(), opacity: 0.15 }
}]
})
}
function renderStatus() {
if (!statusChartEl.value) return
if (!statusChart) statusChart = echarts.init(statusChartEl.value)
const total = statusSlices.value.reduce((a, b) => a + b.value, 0)
// 解 CSS 变量到真值(echarts 内部 canvas 渲染不解析 var())
const color1 = getComputedStyle(document.body).getPropertyValue('--chart-1').trim() || '#3B82F6'
const color2 = getComputedStyle(document.body).getPropertyValue('--chart-2').trim() || '#7C3AED'
const color3 = getComputedStyle(document.body).getPropertyValue('--chart-3').trim() || '#10B981'
const color4 = getComputedStyle(document.body).getPropertyValue('--chart-4').trim() || '#F59E0B'
const realColors = [color1, color2, color3, color4]
const inkColor = getInkColor()
statusChart.setOption({
tooltip: { trigger: 'item', backgroundColor: getSurfaceColor(), borderColor: getBorderColor(), textStyle: { color: inkColor } },
legend: { bottom: 0, textStyle: { color: inkColor } },
series: [{
type: 'pie', radius: ['62%', '82%'], center: ['50%', '46%'],
avoidLabelOverlap: true,
label: { show: false }, labelLine: { show: false },
data: statusSlices.value.map((s, i) => ({ name: s.name, value: s.value, itemStyle: { color: realColors[i] || s.color } })),
emphasis: { label: { show: true, fontSize: 16, fontWeight: 600, color: inkColor } }
}],
graphic: total > 0 ? [
{ type: 'text', left: 'center', top: '38%', style: { text: '总计', fontSize: 11, fill: inkColor, opacity: 0.6 } },
{ type: 'text', left: 'center', top: '46%', style: { text: String(total), fontSize: 26, fontWeight: 700, fill: inkColor, textAlign: 'center' } },
{ type: 'text', left: 'center', top: '56%', style: { text: '单', fontSize: 10, fill: inkColor, opacity: 0.5 } }
] : []
})
}
// 在主 onMounted 跑完后挂图表(已经跑了)
onMounted(async () => {
await nextTick()
renderTrend()
renderStatus()
})
watch([() => trendData.value, () => statusSlices.value, () => themeStore.current], async () => {
await nextTick()
renderTrend()
renderStatus()
})
window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.resize() })
</script>
<template>
<div class="rr-dashboard">
<div class="rr-page-header">
<h2 class="rr-page-title">数据看板</h2>
<p class="rr-page-sub">2026 年 6 月 17 日 · 实时数据</p>
<div>
<h2 class="rr-page-title">数据看板</h2>
<p class="rr-page-sub">
2026 年 6 月 17 日 · 实时数据
<el-tag
v-if="apiStatus !== 'loading'"
size="small"
:type="apiStatus === 'live' ? 'success' : 'info'"
effect="light"
class="rr-api-badge"
>
{{ apiStatus === 'live' ? '实时' : '示例数据' }}
</el-tag>
</p>
</div>
</div>
<!-- 4 个统计卡 -->
@ -49,33 +367,43 @@ onMounted(() => {
<span class="rr-stat-label">{{ s.label }}</span>
</div>
<div class="rr-stat-value">{{ s.value }}</div>
<div class="rr-stat-delta" :class="s.trend">
<div v-if="s.delta" class="rr-stat-delta" :class="s.trend">
<el-icon><component :is="s.trend === 'up' ? 'CaretTop' : 'CaretBottom'" /></el-icon>
{{ s.delta }} 较昨日
</div>
</div>
</div>
<!-- W3.x B.3 图表区: 询价趋势 + 状态分布 -->
<div class="rr-chart-grid">
<div class="rr-card rr-chart-card">
<div class="rr-card-header">
<h3 class="rr-card-title">询价趋势(近 7 天)</h3>
</div>
<div ref="trendChartEl" class="rr-chart" v-if="trendData.dates.length"></div>
<div class="rr-chart-empty" v-else>暂无数据</div>
</div>
<div class="rr-card rr-chart-card">
<div class="rr-card-header">
<h3 class="rr-card-title">询价状态分布</h3>
</div>
<div ref="statusChartEl" class="rr-chart" v-if="statusSlices.length"></div>
<div class="rr-chart-empty" v-else>暂无数据</div>
</div>
</div>
<!-- 近期询价 -->
<div class="rr-card">
<div class="rr-card-header">
<h3 class="rr-card-title">近期询价</h3>
<el-button text type="primary">查看全部 →</el-button>
<el-button text type="primary" @click="$router.push('/inquiry')">查看全部 →</el-button>
</div>
<el-table :data="recentInquiries" stripe>
<el-table-column prop="id" label="单号" width="140" />
<el-table-column prop="customer" label="客户" />
<el-table-column prop="product" label="商品" />
<el-table-column prop="qty" label="数量" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="STATUS_MAP[row.status].type as any" size="small" effect="light">
{{ STATUS_MAP[row.status].label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="time" label="时间" width="100" />
</el-table>
<DataTable
:columns="recentColumns"
:data="recentInquiries"
:show-pagination="false"
empty-text="暂无询价"
/>
</div>
</div>
</template>
@ -88,6 +416,7 @@ onMounted(() => {
color: var(--ink); margin-bottom: 4px;
}
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
.rr-api-badge { margin-left: 8px; }
.rr-stats-grid {
display: grid;
@ -141,4 +470,18 @@ onMounted(() => {
.rr-card-title {
font-size: 15px; font-weight: 600; color: var(--ink);
}
/* W3.x B.3 图表区 */
.rr-chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.rr-chart-card { padding-bottom: 8px; }
.rr-chart { height: 240px; padding: 8px 4px; }
.rr-chart-empty {
height: 240px; display: flex; align-items: center; justify-content: center;
color: var(--ink-4); font-size: 13px;
}
</style>