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 ``;
+ }
+
+ /* ── 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 ``;
+ }
+
+ /* ── 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 ``;
+ }
+
+ /* ── 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 ``;
+ }
+
+ /* ── 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 ``;
+ }
+
+ /* ── 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 ``;
+ }
+
+ /* 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 ``;
+ }
+ 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}
+
+
${ICON.search}搜索项目 / 房间 / 材料
+
GB/T 18883GB 50325
+
${ICON.bell}
+
`;
+
+ const side = `
+
${warm ? ICON.leaf : ICON.bldg}
+
+ ${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 = `
+
+ ${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 ``;
+ };
+})();
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; }