diff --git a/src/modules/dashboard/DashboardView.vue b/src/modules/dashboard/DashboardView.vue index ac37792..eb9bbfa 100644 --- a/src/modules/dashboard/DashboardView.vue +++ b/src/modules/dashboard/DashboardView.vue @@ -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([ + { 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([]) +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 = { pending: { label: '待报价', type: 'primary' }, @@ -27,18 +49,314 @@ const STATUS_MAP: Record = { 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 { + try { + const data = await httpGet('/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 { + try { + const data = await httpGet('/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 = { 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 = {} + 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('/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 = {} + 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(null) +const statusChartEl = ref(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() }) @@ -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; +}