From d4f8bb382685160ffc016ad9d405451971cea3b3 Mon Sep 17 00:00:00 2001 From: zty Date: Fri, 12 Jun 2026 15:36:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=93=E4=B8=9A=E7=9C=8B=E6=9D=BF=20?= =?UTF-8?q?Dashboard(=E8=BF=98=E5=8E=9F=E8=AE=BE=E8=AE=A1=E7=A8=BF?= =?UTF-8?q?=E6=9A=96=E7=BB=BF=E4=B8=BB=E9=A2=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用设计包的 charts.js/dashboard.js/data.js(放 public/),Dashboard.vue 运行时加载并渲染 warm 主题看板:侧边栏+KPI+达标率环图+各房间甲醛柱状图+ 6项污染物仪表盘+污染物雷达+甲醛衰减曲线+材料贡献+超标房间清单。 路由 /dashboard(需登录),落地页"专业看板"+顶栏"总览看板"接入。 注:当前为设计稿静态演示数据,后续可接后端聚合。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/public/dashboard/charts.js | 184 +++++++++++++++++ apps/web/public/dashboard/dashboard.js | 260 +++++++++++++++++++++++++ apps/web/public/dashboard/data.js | 70 +++++++ apps/web/src/layouts/AppLayout.vue | 1 + apps/web/src/pages/Dashboard.vue | 50 +++++ apps/web/src/pages/Landing.vue | 2 +- apps/web/src/router/index.ts | 1 + apps/web/src/styles/dashboard.css | 166 ++++++++++++++++ 8 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 apps/web/public/dashboard/charts.js create mode 100644 apps/web/public/dashboard/dashboard.js create mode 100644 apps/web/public/dashboard/data.js create mode 100644 apps/web/src/pages/Dashboard.vue create mode 100644 apps/web/src/styles/dashboard.css diff --git a/apps/web/public/dashboard/charts.js b/apps/web/public/dashboard/charts.js new file mode 100644 index 0000000..24d487d --- /dev/null +++ b/apps/web/public/dashboard/charts.js @@ -0,0 +1,184 @@ +/* charts.js — hand-built SVG chart library for the pollution dashboard. + Every builder returns an SVG markup string. Colors come from a palette + object `p` so the same chart adapts to each theme. + window.CHARTS = { ... } */ +(function () { + const TAU = Math.PI * 2; + const fmt = (n, d = 2) => Number(n).toFixed(d).replace(/\.?0+$/, m => m.includes('.') ? '' : m); + const pol = (cx, cy, r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)]; + // describe an SVG arc from angle a0 to a1 (radians), radius r, center cx,cy + function arcPath(cx, cy, r, a0, a1) { + const [x0, y0] = pol(cx, cy, r, a0); + const [x1, y1] = pol(cx, cy, r, a1); + const large = (a1 - a0) % TAU > Math.PI ? 1 : 0; + return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; + } + const status = (ratio, p) => ratio >= 1 ? p.bad : ratio >= 0.85 ? p.warn : p.good; + + /* ── ringGauge: circular progress vs the national-standard limit. + 100% of the ring = the GB/T limit. Overshoot (>limit) paints the + full ring in the "bad" colour. Center shows the value. ── */ + function ringGauge({ value, limit, unit, name, en, p, size = 104 }) { + const ratio = value / limit; + const col = status(ratio, p); + const r = size / 2 - 9, cx = size / 2, cy = size / 2, C = TAU * r; + const frac = Math.min(ratio, 1); + const start = -Math.PI / 2; + const prog = arcPath(cx, cy, r, start, start + frac * TAU - 0.0001); + const over = ratio > 1; + return ` + + ${over + ? `` + : ``} + ${fmt(value, value >= 10 ? 0 : 3)} + ${unit} + `; + } + + /* ── donut: multi-segment compliance pie with big centre stat ── */ + function donut({ segments, centerNum, centerLabel, p, size = 210 }) { + const total = segments.reduce((s, x) => s + x.value, 0); + const r = size / 2 - 16, cx = size / 2, cy = size / 2; + let a = -Math.PI / 2, paths = ''; + segments.forEach(s => { + const a1 = a + (s.value / total) * TAU; + paths += ``; + a = a1; + }); + return ` + + ${paths} + ${centerNum} + ${centerLabel} + `; + } + + /* ── columns: vertical bar chart of per-room concentration, bars coloured + by status, with two dashed national-standard limit lines. The hero + "clearly shows exceedance" chart. ── */ + function columns({ items, limit, limit2, unit, p, w = 720, h = 300 }) { + const padL = 46, padR = 18, padT = 26, padB = 46; + const iw = w - padL - padR, ih = h - padT - padB; + const max = Math.max(limit, limit2 || 0, ...items.map(d => d.value)) * 1.22; + const y = v => padT + ih - (v / max) * ih; + const bw = Math.min(54, (iw / items.length) * 0.56); + const step = iw / items.length; + let bars = '', labels = ''; + items.forEach((d, i) => { + const cx = padL + step * i + step / 2; + const ratio = d.value / limit; + const col = status(ratio, p); + const by = y(d.value), bh = padT + ih - by; + bars += ` + ${fmt(d.value, 3)}`; + labels += `${d.name}`; + }); + // gridlines + let grid = ''; + for (let g = 0; g <= 4; g++) { + const gy = padT + (ih / 4) * g; + grid += ` + ${fmt(max - (max / 4) * g, 2)}`; + } + const ly = y(limit), ly2 = limit2 ? y(limit2) : null; + const limLine = ` + + GB/T 18883 限值 ${fmt(limit, 2)}`; + const lim2Line = limit2 ? ` + GB 50325-I 限值 ${fmt(limit2, 2)}` : ''; + return ` + ${grid}${bars}${lim2Line}${limLine}${labels}`; + } + + /* ── hbars: horizontal ranking (material pollution contribution) ── */ + function hbars({ items, p, w = 360, rowH = 38 }) { + const max = Math.max(...items.map(d => d.value)); + const labW = 0, barX = 150, barW = w - barX - 56; + let rows = ''; + items.forEach((d, i) => { + const yy = i * rowH; + const bw = Math.max(4, (d.value / max) * barW); + rows += ` + ${d.name} + + + ${fmt(d.value, 3)} + `; + }); + return `${rows}`; + } + + /* ── radar: 6-axis pollutant chart. Ring at ratio 1.0 = the limit. ── */ + function radar({ axes, p, size = 280 }) { + const cx = size / 2, cy = size / 2 + 4, R = size / 2 - 46; + const n = axes.length; + const ang = i => -Math.PI / 2 + (i / n) * TAU; + // scale: value/limit, ring max 1.6 + const RMAX = 1.6; + const rr = v => (Math.min(v, RMAX) / RMAX) * R; + let grid = ''; + [0.5, 1.0, 1.5].forEach(g => { + const pts = axes.map((_, i) => pol(cx, cy, rr(g), ang(i)).map(x => x.toFixed(1)).join(',')).join(' '); + grid += ``; + }); + let spokes = '', labels = ''; + axes.forEach((a, i) => { + const [x, y] = pol(cx, cy, R, ang(i)); + spokes += ``; + const [lx, ly] = pol(cx, cy, R + 20, ang(i)); + const anchor = Math.abs(lx - cx) < 8 ? 'middle' : lx > cx ? 'start' : 'end'; + labels += `${a.name}`; + }); + const vpts = axes.map((a, i) => pol(cx, cy, rr(a.value / a.limit), ang(i)).map(x => x.toFixed(1)).join(',')).join(' '); + const dots = axes.map((a, i) => { + const [x, y] = pol(cx, cy, rr(a.value / a.limit), ang(i)); + return ``; + }).join(''); + return ` + ${grid}${spokes} + + ${dots}${labels}`; + } + + /* ── decayArea: concentration decay over ventilation days ── */ + function decayArea({ points, limit, unit, p, w = 360, h = 200 }) { + const padL = 38, padR = 14, padT = 16, padB = 28; + const iw = w - padL - padR, ih = h - padT - padB; + const xs = points.map(d => d.day), maxX = Math.max(...xs); + const maxY = Math.max(limit, ...points.map(d => d.v)) * 1.15; + const X = d => padL + (d / maxX) * iw; + const Y = v => padT + ih - (v / maxY) * ih; + let grid = ''; + for (let g = 0; g <= 3; g++) { + const gy = padT + (ih / 3) * g; + grid += ``; + } + const line = points.map((d, i) => `${i ? 'L' : 'M'}${X(d.day).toFixed(1)} ${Y(d.v).toFixed(1)}`).join(' '); + const area = `${line} L${X(maxX).toFixed(1)} ${padT + ih} L${padL} ${padT + ih} Z`; + const dots = points.map(d => ``).join(''); + const xlab = points.filter((_, i) => i % 2 === 0).map(d => `${d.day}天`).join(''); + const ly = Y(limit); + return ` + + + + ${grid} + + 限值 ${fmt(limit, 2)} + + + ${dots}${xlab}`; + } + + /* defs for optional glow filters — inject once per dashboard root */ + function defs(p) { + if (!p.glow) return ''; + return ` + + + `; + } + + window.CHARTS = { ringGauge, donut, columns, hbars, radar, decayArea, defs, status, fmt }; +})(); diff --git a/apps/web/public/dashboard/dashboard.js b/apps/web/public/dashboard/dashboard.js new file mode 100644 index 0000000..d337132 --- /dev/null +++ b/apps/web/public/dashboard/dashboard.js @@ -0,0 +1,260 @@ +/* dashboard.js — themes + renderDashboard(themeKey, opts) -> HTML string. + Pure markup; charts are SVG from window.CHARTS. window.renderDashboard */ +(function () { + const C = window.CHARTS; + const D = window.DASH_DATA; + const SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',system-ui,sans-serif"; + const SERIF = "'Songti SC','STSong',Georgia,'Times New Roman',serif"; + + const ICON = { + grid: '', + predict: '', + flask: '', + source: '', + folder: '', + report: '', + gear: '', + search: '', + bell: '', + alert: '', + bldg: '', + leaf: '', + up: '', + down: '', + }; + + const THEMES = { + dark: { + cls: 'v-dark', + vars: { + '--bg': '#0a101e', '--side-bg': '#070c17', '--side-border': 'rgba(255,255,255,.06)', + '--side-ink': '#e7eef9', '--side-sub': '#7589a6', '--side-hover': 'rgba(255,255,255,.05)', + '--border': 'rgba(255,255,255,.08)', '--panel': '#111b2e', '--panel2': '#0d1626', + '--ink': '#e8eff9', '--sub': '#8ba0bd', '--faint': '#5d6f8c', + '--accent': '#2dd4bf', '--accent2': '#38bdf8', '--accent-soft': 'rgba(45,212,191,.14)', '--accent-on': '#5eead4', + '--good': '#34d399', '--good-soft': 'rgba(52,211,153,.15)', '--warn': '#fbbf24', '--warn-soft': 'rgba(251,191,36,.15)', + '--bad': '#f87171', '--bad-soft': 'rgba(248,113,113,.15)', '--track': 'rgba(255,255,255,.07)', + '--radius': '16px', '--shadow': '0 10px 34px rgba(0,0,0,.45)', '--topbar-bg': 'rgba(7,12,23,.5)', + '--logo-glow': '0 0 18px rgba(45,212,191,.5)', '--font': SANS, '--display': SANS, + }, + chart: { good: '#34d399', warn: '#fbbf24', bad: '#f87171', badSoft: 'rgba(248,113,113,.2)', accent: '#2dd4bf', grid: 'rgba(255,255,255,.08)', ink: '#e8eff9', sub: '#8ba0bd', faint: '#5d6f8c', track: 'rgba(255,255,255,.09)', glow: true, gid: 'dk' }, + }, + light: { + cls: 'v-light', + vars: { + '--bg': '#eef1f7', '--side-bg': '#ffffff', '--side-border': '#e7ebf2', + '--side-ink': '#16212f', '--side-sub': '#6a788b', '--side-hover': 'rgba(20,87,214,.06)', + '--border': '#e7ebf2', '--panel': '#ffffff', '--panel2': '#f4f6fb', + '--ink': '#15212e', '--sub': '#5b6b7d', '--faint': '#98a4b3', + '--accent': '#1457d6', '--accent2': '#3b82f6', '--accent-soft': 'rgba(20,87,214,.10)', '--accent-on': '#1457d6', + '--good': '#16a34a', '--good-soft': 'rgba(22,163,74,.11)', '--warn': '#e08600', '--warn-soft': 'rgba(224,134,0,.13)', + '--bad': '#dc2626', '--bad-soft': 'rgba(220,38,38,.10)', '--track': '#eaeef4', + '--radius': '14px', '--shadow': '0 1px 2px rgba(20,30,50,.05),0 6px 18px rgba(20,30,50,.05)', '--topbar-bg': 'rgba(255,255,255,.7)', + '--logo-glow': 'none', '--font': SANS, '--display': SANS, + }, + chart: { good: '#16a34a', warn: '#e08600', bad: '#dc2626', badSoft: 'rgba(220,38,38,.1)', accent: '#1457d6', grid: '#eaeef4', ink: '#15212e', sub: '#5b6b7d', faint: '#98a4b3', track: '#eaeef4', glow: false, gid: 'lt' }, + }, + warm: { + cls: 'v-warm', + vars: { + '--bg': '#f4f0e7', '--side-bg': '#fffdf8', '--side-border': '#e8e0d0', + '--side-ink': '#241e15', '--side-sub': '#897f6c', '--side-hover': 'rgba(31,122,90,.08)', + '--border': '#e9e1d2', '--panel': '#fffdf8', '--panel2': '#f4efe3', + '--ink': '#221d15', '--sub': '#6c6353', '--faint': '#a89c86', + '--accent': '#1f7a5a', '--accent2': '#2f9e74', '--accent-soft': 'rgba(31,122,90,.12)', '--accent-on': '#1f7a5a', + '--good': '#2f8f5b', '--good-soft': 'rgba(47,143,91,.13)', '--warn': '#ca8326', '--warn-soft': 'rgba(202,131,38,.15)', + '--bad': '#bf4a30', '--bad-soft': 'rgba(191,74,48,.12)', '--track': '#ebe3d4', + '--radius': '16px', '--shadow': '0 1px 2px rgba(60,50,30,.05)', '--topbar-bg': 'rgba(255,253,248,.7)', + '--logo-glow': 'none', '--font': SANS, '--display': SERIF, + }, + chart: { good: '#2f8f5b', warn: '#ca8326', bad: '#bf4a30', badSoft: 'rgba(191,74,48,.14)', accent: '#1f7a5a', grid: '#ece4d5', ink: '#221d15', sub: '#6c6353', faint: '#a89c86', track: '#ebe3d4', glow: false, gid: 'wm' }, + }, + }; + + const pct = (v, t) => Math.round((v / t) * 1000) / 10; + const statusKey = r => r >= 1 ? 'bad' : r >= 0.85 ? 'warn' : 'good'; + const cnLevel = { bad: '超标', warn: '临界', good: '达标' }; + + function nav(active) { + const items = [ + ['grid', '总览看板', 0], ['predict', '污染物预测', 0], ['flask', '材料库', 0], + ['source', '污染源识别', 19], ['folder', '案例库 / 项目', 0], ['report', '检测报告', 0], + ]; + return `
主菜单
` + + items.map((it, i) => `
${ICON[it[0]]}${it[1]}${it[2] ? `${it[2]}` : ''}
`).join('') + + `
`; + } + + function kpiCard(lab, icon, iconBg, iconCol, num, unit, trend) { + return `
+
${ICON[icon]}${lab}
+
+
${num}${unit ? `${unit}` : ''}
+ ${trend || ''} +
`; + } + + // pollutant ring gauges or bars + function pollutantViz(mode, p) { + if (mode === 'bar') { + const w = 470, rowH = 34, padR = 64, barX = 92, barW = w - barX - padR; + let rows = ''; + D.pollutants.forEach((d, i) => { + const ratio = d.value / d.limit, col = C.status(ratio, p); + const yy = i * rowH; + const limX = barX + barW; // 100% = limit + const bw = Math.max(4, Math.min(ratio, 1.45) / 1.45 * barW); + const limMark = barX + (1 / 1.45) * barW; + rows += ` + ${d.name} + + + + ${C.fmt(d.value, d.value >= 10 ? 0 : 3)} + `; + }); + return `
${rows} + 国标限值
`; + } + return `
` + D.pollutants.map(d => { + const ratio = d.value / d.limit, sk = statusKey(ratio); + return `
${C.ringGauge({ value: d.value, limit: d.limit, unit: d.unit, name: d.name, en: d.en, p })} +
${d.name}
${d.en} · 限值 ${C.fmt(d.limit, 2)}
+
${cnLevel[sk]} ${Math.round(ratio * 100)}%
`; + }).join('') + `
`; + } + + // compliance donut or stacked bar + function complianceViz(mode, p, total) { + const segs = D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] })); + const legend = `
` + segs.map(s => + `
${s.label}房间${s.value}${pct(s.value, total)}%
` + ).join('') + `
`; + if (mode === 'bar') { + let x = 0; const w = 100; + const segbar = segs.map(s => { const wpc = s.value / total * w; const r = `
`; x += wpc; return r; }).join(''); + return `
${segbar}
+
${D.kpis.compliance}%
+
总体房间达标率
${legend}`; + } + return `
${C.donut({ segments: segs, centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p })}
${legend}`; + } + + window.renderDashboard = function (themeKey, opts) { + opts = opts || {}; + const T = THEMES[themeKey] || THEMES.dark; + const p = T.chart; + const styleVars = Object.entries(T.vars).map(([k, v]) => `${k}:${v}`).join(';'); + const warm = themeKey === 'warm'; + + // worst pollutant flag for latest prediction (主卧 甲醛) + const hcho = D.pollutants[0]; + const over = Math.round((hcho.value / hcho.limit - 1) * 100); + + const top = `
+
工程污染概览
${D.project.name} · ${D.project.area}㎡ · ${D.project.type}
+
+ +
GB/T 18883GB 50325
+
${ICON.bell}
+
`; + + const side = `
+
+
污染物预测系统
INDOOR · AIR
+ ${nav(0)} +
+
+
陈工 · 环境工程师
★ 专业版
+
`; + + const kpis = `
+ ${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', `${ICON.up}本周 +3`)} + ${kpiCard('房间达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `${ICON.up}${D.kpis.trend.compliance}%`)} + ${kpiCard('当前超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', `${ICON.down}${Math.abs(D.kpis.trend.exceed)}`)} + ${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', `${ICON.up}活跃`)} +
`; + + const totalRooms = D.compliance.reduce((s, x) => s + x.value, 0); + + const colCard = `
+
各房间 · 甲醛预测浓度
柱状图按状态着色 · 红=超标 / 橙=临界 / 绿=达标,对照国标限值
+ 单位 mg/m³
+ ${C.columns({ items: D.rooms, limit: D.roomLimit, limit2: D.roomLimit2, unit: 'mg/m³', p, w: 760, h: 268 })} +
`; + + const donutCard = `
+
项目达标率
${totalRooms} 间房
+ ${complianceViz(opts.compliance || 'donut', p, totalRooms)} +
`; + + const gaugeCard = `
+
最近一次预测 · 主卧
${D.project.updated} · 6 项污染物 vs 国标限值
+
${ICON.alert}甲醛 ${hcho.value} mg/m³ · 超出 GB/T 18883 限值 ${over}%,建议加强通风并核查人造板材料
+ ${pollutantViz(opts.pollutant || 'ring', p)} +
`; + + const radarCard = `
+
污染物雷达
主卧
+
虚线红环 = 国标限值(比值 1.0)
+ ${C.radar({ axes: D.pollutants.map(d => ({ name: d.name, value: d.value, limit: d.limit })), p, size: 250 })} +
`; + + const decayCard = `
+
甲醛衰减
+
随通风天数
+ ${C.decayArea({ points: D.decay, limit: D.roomLimit, unit: 'mg/m³', p, w: 300, h: 176 })} +
`; + + const tableRows = D.exceed.map(r => { + const ratio = r.value / r.limit; + return `
${r.room}
${r.project}
+ ${r.pollutant} + ${C.fmt(r.value, r.value >= 10 ? 0 : 3)} + ${C.fmt(r.limit, 2)} + ${cnLevel[r.level]} ${Math.round(ratio * 100)}%`; + }).join(''); + const tableCard = `
+
超标房间清单
${ICON.alert ? '' : ''}共 ${D.exceed.length} 条 · 按超标率排序
+ + ${tableRows}
房间 / 项目污染物预测浓度限值状态
`; + + const matCard = `
+
污染源识别 · 材料贡献
主卧甲醛 · 公式溯源排行
+ ${C.hbars({ items: D.materials.map((m, i) => ({ ...m, color: i === 0 ? p.bad : i === 1 ? p.warn : p.accent })), p, w: 340, rowH: 40 })} +
`; + + let body; + if (warm) { + const heroComp = `
+ ${C.donut({ segments: D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] })), centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p, size: 150 })} +
在管 ${D.kpis.projects} 个项目 · ${totalRooms} 间房
+
${D.compliance[2].value} 间超标 · ${D.compliance[1].value} 间临界
+
最近更新 ${D.project.updated}
+
+
+ ${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', '')} + ${kpiCard('达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `${ICON.up}${D.kpis.trend.compliance}`)} + ${kpiCard('超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', '')} + ${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', '')} +
`; + body = heroComp + `
+ ${colCard} + ${gaugeCard.replace('sp5','sp4')} + ${radarCard} + ${decayCard.replace('sp3','sp4')} + ${matCard} + ${tableCard.replace('sp8','sp12')} +
`; + } else { + body = kpis + `
+ ${colCard}${donutCard} + ${gaugeCard}${radarCard}${decayCard} + ${tableCard}${matCard} +
`; + } + + return `
${C.defs(p)}${side} +
${top}
${body}
`; + }; +})(); diff --git a/apps/web/public/dashboard/data.js b/apps/web/public/dashboard/data.js new file mode 100644 index 0000000..3d5174c --- /dev/null +++ b/apps/web/public/dashboard/data.js @@ -0,0 +1,70 @@ +/* data.js — shared realistic dataset for the dashboard. + Limits per GB/T 18883-2022 (室内空气质量标准) and GB 50325-2020 + (民用建筑工程室内环境污染控制标准, I类民用建筑). window.DASH_DATA */ +window.DASH_DATA = { + project: { name: '锦绣华庭 · 18栋 2单元 1602', area: 118, type: 'I类民用建筑(住宅)', updated: '2026-06-11 14:20' }, + + kpis: { + projects: 28, // 在管项目 + compliance: 82.1, // 达标率 % + exceedRooms: 19, // 当前超标房间 + weekPredictions: 64, // 本周预测次数 + trend: { compliance: +3.4, exceed: -5 }, + }, + + // 项目达标率 pie — 房间层级统计 + compliance: [ + { label: '达标', value: 213, key: 'good' }, + { label: '临界', value: 34, key: 'warn' }, + { label: '超标', value: 19, key: 'bad' }, + ], + + // 6 监测污染物 — 最近一次预测(主卧),value 为预测浓度 + pollutants: [ + { key: 'HCHO', name: '甲醛', en: 'HCHO', unit: 'mg/m³', value: 0.131, limit: 0.10, limit2: 0.07 }, + { key: 'C6H6', name: '苯', en: 'Benzene', unit: 'mg/m³', value: 0.021, limit: 0.03, limit2: 0.06 }, + { key: 'TVOC', name: 'TVOC', en: 'TVOC', unit: 'mg/m³', value: 0.582, limit: 0.60, limit2: 0.45 }, + { key: 'NH3', name: '氨', en: 'NH₃', unit: 'mg/m³', value: 0.112, limit: 0.20, limit2: 0.15 }, + { key: 'Rn', name: '氡', en: 'Radon', unit: 'Bq/m³', value: 208, limit: 300, limit2: 150 }, + { key: 'VOC', name: '总VOC', en: 'VOC', unit: 'mg/m³', value: 0.486, limit: 0.60, limit2: 0.50 }, + ], + + // 各房间 甲醛预测浓度(hero 柱状图) + rooms: [ + { name: '主卧', value: 0.131 }, + { name: '次卧', value: 0.092 }, + { name: '客厅', value: 0.078 }, + { name: '书房', value: 0.118 }, + { name: '儿童房', value: 0.142 }, + { name: '厨房', value: 0.064 }, + { name: '餐厅', value: 0.071 }, + { name: '卫生间', value: 0.055 }, + ], + roomLimit: 0.10, roomLimit2: 0.07, + + // 各材料 甲醛 污染贡献排行(主卧) + materials: [ + { name: '多层实木复合地板', value: 0.052 }, + { name: '人造板衣柜', value: 0.041 }, + { name: '木器漆 · 饰面', value: 0.018 }, + { name: '壁纸及基膜', value: 0.012 }, + { name: '布艺沙发软装', value: 0.008 }, + ], + + // 甲醛浓度随通风天数衰减(主卧) + decay: [ + { day: 0, v: 0.182 }, { day: 7, v: 0.158 }, { day: 14, v: 0.141 }, + { day: 21, v: 0.131 }, { day: 30, v: 0.117 }, { day: 45, v: 0.101 }, + { day: 60, v: 0.089 }, { day: 90, v: 0.072 }, + ], + + // 超标房间清单 + exceed: [ + { project: '锦绣华庭 18-2-1602', room: '儿童房', pollutant: '甲醛', value: 0.142, limit: 0.10, level: 'bad' }, + { project: '锦绣华庭 18-2-1602', room: '主卧', pollutant: '甲醛', value: 0.131, limit: 0.10, level: 'bad' }, + { project: '翠湖天地 6-1-803', room: '书房', pollutant: '甲醛', value: 0.118, limit: 0.10, level: 'bad' }, + { project: '锦绣华庭 18-2-1602', room: '客厅', pollutant: 'TVOC', value: 0.582, limit: 0.60, level: 'warn' }, + { project: '万科城 9-3-2201', room: '主卧', pollutant: '苯', value: 0.034, limit: 0.03, level: 'bad' }, + { project: '翠湖天地 6-1-803', room: '次卧', pollutant: 'TVOC', value: 0.561, limit: 0.60, level: 'warn' }, + ], +}; diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index e7ace35..dd77261 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -12,6 +12,7 @@ @click="onNav" > 首页 + 总览看板 模板库 材料库 历史记录 diff --git a/apps/web/src/pages/Dashboard.vue b/apps/web/src/pages/Dashboard.vue new file mode 100644 index 0000000..eee3fb5 --- /dev/null +++ b/apps/web/src/pages/Dashboard.vue @@ -0,0 +1,50 @@ + + + + + + diff --git a/apps/web/src/pages/Landing.vue b/apps/web/src/pages/Landing.vue index ffbf8b1..c7aa4ce 100644 --- a/apps/web/src/pages/Landing.vue +++ b/apps/web/src/pages/Landing.vue @@ -198,7 +198,7 @@ onBeforeUnmount(stop); function scrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); } function scrollTo(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); } -function goPro() { router.push(localStorage.getItem('token') ? '/home' : '/login'); } +function goPro() { router.push(localStorage.getItem('token') ? '/dashboard' : '/login'); } // 预测入口 → 手机注册 const authOpen = ref(false); diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index bd6fe1f..3a347ee 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -4,6 +4,7 @@ const routes = [ { path: '/login', component: () => import('../pages/Login.vue') }, { path: '/landing', name: 'landing', component: () => import('../pages/Landing.vue') }, { path: '/source', name: 'source', component: () => import('../pages/SourceTracing.vue') }, + { path: '/dashboard', name: 'dashboard', component: () => import('../pages/Dashboard.vue') }, { path: '/', component: () => import('../layouts/AppLayout.vue'), diff --git a/apps/web/src/styles/dashboard.css b/apps/web/src/styles/dashboard.css new file mode 100644 index 0000000..4ca489a --- /dev/null +++ b/apps/web/src/styles/dashboard.css @@ -0,0 +1,166 @@ +/* dashboard.css — layout + components. All colours come from CSS custom + properties set per-theme on the .dash root (see dashboard.js THEMES). */ + +.dash { + --gap: 16px; + display: flex; + width: 100%; + height: 100%; + background: var(--bg); + color: var(--ink); + font-family: var(--font); + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + letter-spacing: 0.1px; + overflow: hidden; +} +.dash * { box-sizing: border-box; } +.dash svg text { font-family: var(--font); } + +/* ── Sidebar ── */ +.d-side { + width: 236px; + flex: 0 0 236px; + background: var(--side-bg); + border-right: 1px solid var(--side-border, var(--border)); + display: flex; + flex-direction: column; + padding: 24px 18px; + color: var(--side-ink, var(--ink)); +} +.d-brand { display: flex; align-items: center; gap: 12px; padding: 0 6px 22px; } +.d-logo { + width: 40px; height: 40px; border-radius: 11px; flex: 0 0 40px; + display: flex; align-items: center; justify-content: center; + background: var(--accent); color: #fff; + box-shadow: var(--logo-glow, none); +} +.d-logo svg { width: 22px; height: 22px; } +.d-brand-tt { font-size: 14.5px; font-weight: 700; letter-spacing: 0.2px; color: var(--side-ink, var(--ink)); } +.d-brand-sub { font-size: 10.5px; color: var(--side-sub, var(--sub)); letter-spacing: 1.4px; margin-top: 2px; text-transform: uppercase; } + +.d-navgrp { font-size: 10px; letter-spacing: 1.5px; color: var(--side-sub, var(--faint)); text-transform: uppercase; padding: 16px 10px 8px; } +.d-nav { display: flex; flex-direction: column; gap: 2px; } +.d-nav-i { + display: flex; align-items: center; gap: 11px; + padding: 9px 11px; border-radius: 9px; + color: var(--side-sub, var(--sub)); font-size: 13px; font-weight: 500; + cursor: pointer; position: relative; +} +.d-nav-i svg { width: 17px; height: 17px; opacity: 0.85; flex: 0 0 17px; } +.d-nav-i:hover { background: var(--side-hover, rgba(0,0,0,.04)); } +.d-nav-i.on { background: var(--accent-soft); color: var(--accent-on, var(--accent)); font-weight: 600; } +.d-nav-i.on svg { opacity: 1; } +.d-nav-i .d-badge { margin-left: auto; font-size: 10px; font-weight: 700; background: var(--bad); color: #fff; border-radius: 8px; padding: 1px 6px; } + +.d-side-foot { margin-top: auto; } +.d-user { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 11px; background: var(--side-hover, rgba(0,0,0,.03)); } +.d-ava { width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent2, var(--accent))); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex: 0 0 34px; } +.d-user-nm { font-size: 12.5px; font-weight: 600; color: var(--side-ink, var(--ink)); } +.d-pro { display: inline-flex; align-items: center; gap: 3px; font-size: 9.5px; font-weight: 700; letter-spacing: .3px; color: var(--accent-on, var(--accent)); background: var(--accent-soft); border-radius: 6px; padding: 1px 6px; margin-top: 3px; } + +/* ── Main ── */ +.d-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; } +.d-top { + height: 66px; flex: 0 0 66px; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 16px; + padding: 0 28px; background: var(--topbar-bg, transparent); +} +.d-top-tt { font-size: 18px; font-weight: 700; letter-spacing: 0.2px; } +.d-top-crumb { font-size: 12px; color: var(--sub); margin-top: 1px; } +.d-spacer { flex: 1; } +.d-search { + display: flex; align-items: center; gap: 8px; + background: var(--panel2); border: 1px solid var(--border); + border-radius: 9px; padding: 8px 12px; width: 220px; color: var(--faint); font-size: 12.5px; +} +.d-search svg { width: 15px; height: 15px; } +.d-std { display: flex; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; gap: 2px; } +.d-std b { font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 6px; color: var(--sub); cursor: pointer; } +.d-std b.on { background: var(--accent); color: #fff; } +.d-iconbtn { width: 38px; height: 38px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel2); display: flex; align-items: center; justify-content: center; color: var(--sub); position: relative; } +.d-iconbtn svg { width: 17px; height: 17px; } +.d-iconbtn .d-dot { position: absolute; top: 8px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--bad); border: 2px solid var(--panel); } + +.d-scroll { flex: 1; padding: 20px 24px 22px; overflow: hidden; } + +/* ── KPI row ── */ +.d-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap); margin-bottom: var(--gap); } +.d-kpi { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 15px 18px; box-shadow: var(--shadow); position: relative; overflow: hidden; } +.d-kpi-lab { font-size: 12.5px; color: var(--sub); display: flex; align-items: center; gap: 8px; } +.d-kpi-ic { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; } +.d-kpi-ic svg { width: 16px; height: 16px; } +.d-kpi-row { display: flex; align-items: flex-end; justify-content: space-between; margin-top: 12px; } +.d-kpi-num { font-size: 29px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; font-variant-numeric: tabular-nums; font-family: var(--display); } +.d-kpi-unit { font-size: 13px; color: var(--faint); font-weight: 600; margin-left: 3px; } +.d-kpi-tr { font-size: 11.5px; font-weight: 700; display: inline-flex; align-items: center; gap: 3px; padding: 3px 7px; border-radius: 7px; } +.d-up { color: var(--good); background: var(--good-soft); } +.d-down { color: var(--good); background: var(--good-soft); } +.d-up.bad { color: var(--bad); background: var(--bad-soft); } + +/* ── Grid + cards ── */ +.d-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--gap); } +.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 15px 18px; min-width: 0; } +.card-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.card-t { font-size: 14.5px; font-weight: 700; letter-spacing: 0.2px; display: flex; align-items: center; gap: 8px; } +.card-t .d-bar { width: 3px; height: 14px; border-radius: 2px; background: var(--accent); } +.card-sub { font-size: 11px; color: var(--faint); margin-top: 2px; font-weight: 500; } +.card-tag { font-size: 10.5px; font-weight: 700; color: var(--sub); background: var(--panel2); border: 1px solid var(--border); border-radius: 7px; padding: 3px 8px; } + +.sp8 { grid-column: span 8; } .sp4 { grid-column: span 4; } +.sp5 { grid-column: span 5; } .sp3 { grid-column: span 3; } +.sp12 { grid-column: span 12; } + +/* gauges grid */ +.d-gauges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 4px; } +.d-g { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 4px 0; } +.d-g-nm { font-size: 12px; font-weight: 700; margin-top: 4px; } +.d-g-en { font-size: 9px; color: var(--faint); letter-spacing: .5px; } +.d-g-pill { font-size: 9.5px; font-weight: 700; border-radius: 6px; padding: 1px 7px; margin-top: 4px; } +.pill-good { color: var(--good); background: var(--good-soft); } +.pill-warn { color: var(--warn); background: var(--warn-soft); } +.pill-bad { color: var(--bad); background: var(--bad-soft); } + +/* donut legend */ +.d-legend { display: flex; flex-direction: column; gap: 9px; margin-top: 6px; } +.d-leg { display: flex; align-items: center; gap: 9px; font-size: 12.5px; } +.d-leg .dot { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 10px; } +.d-leg .nm { color: var(--sub); } +.d-leg .vl { margin-left: auto; font-weight: 700; font-variant-numeric: tabular-nums; } +.d-leg .pc { color: var(--faint); font-size: 11px; width: 42px; text-align: right; font-variant-numeric: tabular-nums; } + +/* table */ +.d-tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.d-tbl th { text-align: left; font-size: 10.5px; letter-spacing: .5px; color: var(--faint); font-weight: 600; padding: 0 10px 10px; text-transform: uppercase; } +.d-tbl td { padding: 9px 10px; border-top: 1px solid var(--border); } +.d-tbl td:first-child, .d-tbl th:first-child { padding-left: 4px; } +.d-tbl .rm { font-weight: 700; } +.d-tbl .pj { color: var(--sub); font-size: 11.5px; } +.d-tbl .vn { font-variant-numeric: tabular-nums; font-weight: 700; } +.d-tbl .lm { color: var(--faint); font-variant-numeric: tabular-nums; } +.d-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; border-radius: 7px; padding: 3px 9px; } +.d-chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.chip-bad { color: var(--bad); background: var(--bad-soft); } +.chip-warn { color: var(--warn); background: var(--warn-soft); } +.chip-good { color: var(--good); background: var(--good-soft); } +.d-pol { font-weight: 700; } + +/* alert strip on the latest-prediction card */ +.d-alert { display: flex; align-items: center; gap: 9px; font-size: 12px; font-weight: 600; color: var(--bad); background: var(--bad-soft); border-radius: 9px; padding: 9px 12px; margin-bottom: 14px; } +.d-alert svg { width: 16px; height: 16px; flex: 0 0 16px; } + +.d-flex { display: flex; gap: var(--gap); } + +/* ── Warm variant: hero band + serif display ── */ +.dash.v-warm .d-top-tt { font-family: var(--display); font-size: 19px; } +.dash.v-warm .card { box-shadow: none; } +.dash.v-warm .d-kpi { box-shadow: none; } +.d-hero { display: grid; grid-template-columns: auto 1fr; gap: 26px; align-items: center; + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 22px 26px; margin-bottom: var(--gap); } +.d-hero-l { display: flex; align-items: center; gap: 22px; } +.d-hero-big { font-family: var(--display); font-size: 30px; font-weight: 800; letter-spacing: -.5px; } +.d-hero-txt .t { font-size: 13px; color: var(--sub); } +.d-hero-txt .n { font-family: var(--display); font-size: 15px; font-weight: 700; margin-top: 2px; }