feat: 专业看板 Dashboard(还原设计稿暖绿主题)
复用设计包的 charts.js/dashboard.js/data.js(放 public/),Dashboard.vue 运行时加载并渲染 warm 主题看板:侧边栏+KPI+达标率环图+各房间甲醛柱状图+ 6项污染物仪表盘+污染物雷达+甲醛衰减曲线+材料贡献+超标房间清单。 路由 /dashboard(需登录),落地页"专业看板"+顶栏"总览看板"接入。 注:当前为设计稿静态演示数据,后续可接后端聚合。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
325269c2fe
commit
d4f8bb3826
|
|
@ -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 `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" class="ch-ring">
|
||||||
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${p.track}" stroke-width="8"/>
|
||||||
|
${over
|
||||||
|
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"/>`
|
||||||
|
: `<path d="${prog}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>`}
|
||||||
|
<text x="${cx}" y="${cy - 2}" text-anchor="middle" font-size="20" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(value, value >= 10 ? 0 : 3)}</text>
|
||||||
|
<text x="${cx}" y="${cy + 14}" text-anchor="middle" font-size="9" fill="${p.sub}">${unit}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 += `<path d="${arcPath(cx, cy, r, a + 0.012, a1 - 0.012)}" fill="none" stroke="${s.color}" stroke-width="22" stroke-linecap="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>`;
|
||||||
|
a = a1;
|
||||||
|
});
|
||||||
|
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
||||||
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${p.track}" stroke-width="22"/>
|
||||||
|
${paths}
|
||||||
|
<text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="40" font-weight="800" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${centerNum}</text>
|
||||||
|
<text x="${cx}" y="${cy + 20}" text-anchor="middle" font-size="13" fill="${p.sub}" letter-spacing="1">${centerLabel}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 += `<rect x="${(cx - bw / 2).toFixed(1)}" y="${by.toFixed(1)}" width="${bw}" height="${bh.toFixed(1)}" rx="4" fill="${col}"${p.glow ? ` filter="url(#chGlowSoft)"` : ''}/>
|
||||||
|
<text x="${cx.toFixed(1)}" y="${(by - 7).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(d.value, 3)}</text>`;
|
||||||
|
labels += `<text x="${cx.toFixed(1)}" y="${h - padB + 18}" text-anchor="middle" font-size="11" fill="${p.sub}">${d.name}</text>`;
|
||||||
|
});
|
||||||
|
// gridlines
|
||||||
|
let grid = '';
|
||||||
|
for (let g = 0; g <= 4; g++) {
|
||||||
|
const gy = padT + (ih / 4) * g;
|
||||||
|
grid += `<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${w - padR}" y2="${gy.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>
|
||||||
|
<text x="${padL - 8}" y="${(gy + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="${p.faint}" style="font-variant-numeric:tabular-nums">${fmt(max - (max / 4) * g, 2)}</text>`;
|
||||||
|
}
|
||||||
|
const ly = y(limit), ly2 = limit2 ? y(limit2) : null;
|
||||||
|
const limLine = `<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${w - padR}" y2="${ly.toFixed(1)}" stroke="${p.bad}" stroke-width="1.5" stroke-dasharray="6 4"/>
|
||||||
|
<rect x="${w - padR - 150}" y="${(ly - 17).toFixed(1)}" width="150" height="15" rx="3" fill="${p.badSoft}"/>
|
||||||
|
<text x="${w - padR - 6}" y="${(ly - 6).toFixed(1)}" text-anchor="end" font-size="9.5" font-weight="600" fill="${p.bad}">GB/T 18883 限值 ${fmt(limit, 2)}</text>`;
|
||||||
|
const lim2Line = limit2 ? `<line x1="${padL}" y1="${ly2.toFixed(1)}" x2="${w - padR}" y2="${ly2.toFixed(1)}" stroke="${p.warn}" stroke-width="1.2" stroke-dasharray="3 4"/>
|
||||||
|
<text x="${w - padR - 6}" y="${(ly2 + 12).toFixed(1)}" text-anchor="end" font-size="9.5" font-weight="600" fill="${p.warn}">GB 50325-I 限值 ${fmt(limit2, 2)}</text>` : '';
|
||||||
|
return `<svg viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="xMidYMid meet">
|
||||||
|
${grid}${bars}${lim2Line}${limLine}${labels}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 += `<g transform="translate(0 ${yy})">
|
||||||
|
<text x="0" y="${rowH / 2 + 4}" font-size="12" fill="${p.ink}">${d.name}</text>
|
||||||
|
<rect x="${barX}" y="${rowH / 2 - 8}" width="${barW}" height="16" rx="5" fill="${p.track}"/>
|
||||||
|
<rect x="${barX}" y="${rowH / 2 - 8}" width="${bw.toFixed(1)}" height="16" rx="5" fill="${d.color || p.accent}"${p.glow ? ` filter="url(#chGlowSoft)"` : ''}/>
|
||||||
|
<text x="${w}" y="${rowH / 2 + 4}" text-anchor="end" font-size="11" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(d.value, 3)}</text>
|
||||||
|
</g>`;
|
||||||
|
});
|
||||||
|
return `<svg viewBox="0 0 ${w} ${items.length * rowH}" width="100%">${rows}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 += `<polygon points="${pts}" fill="none" stroke="${g === 1 ? p.bad : p.grid}" stroke-width="${g === 1 ? 1.3 : 1}" ${g === 1 ? 'stroke-dasharray="5 4"' : ''}/>`;
|
||||||
|
});
|
||||||
|
let spokes = '', labels = '';
|
||||||
|
axes.forEach((a, i) => {
|
||||||
|
const [x, y] = pol(cx, cy, R, ang(i));
|
||||||
|
spokes += `<line x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>`;
|
||||||
|
const [lx, ly] = pol(cx, cy, R + 20, ang(i));
|
||||||
|
const anchor = Math.abs(lx - cx) < 8 ? 'middle' : lx > cx ? 'start' : 'end';
|
||||||
|
labels += `<text x="${lx.toFixed(1)}" y="${(ly + 4).toFixed(1)}" text-anchor="${anchor}" font-size="11" font-weight="600" fill="${p.sub}">${a.name}</text>`;
|
||||||
|
});
|
||||||
|
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 `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${p.accent}"/>`;
|
||||||
|
}).join('');
|
||||||
|
return `<svg viewBox="0 0 ${size} ${size}" width="100%">
|
||||||
|
${grid}${spokes}
|
||||||
|
<polygon points="${vpts}" fill="${p.accent}" fill-opacity="0.18" stroke="${p.accent}" stroke-width="2"${p.glow ? ` filter="url(#chGlow)"` : ''}/>
|
||||||
|
${dots}${labels}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 += `<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${w - padR}" y2="${gy.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>`;
|
||||||
|
}
|
||||||
|
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 => `<circle cx="${X(d.day).toFixed(1)}" cy="${Y(d.v).toFixed(1)}" r="2.6" fill="${p.accent}"/>`).join('');
|
||||||
|
const xlab = points.filter((_, i) => i % 2 === 0).map(d => `<text x="${X(d.day).toFixed(1)}" y="${h - 8}" text-anchor="middle" font-size="9" fill="${p.faint}">${d.day}天</text>`).join('');
|
||||||
|
const ly = Y(limit);
|
||||||
|
return `<svg viewBox="0 0 ${w} ${h}" width="100%">
|
||||||
|
<defs><linearGradient id="${p.gid}_dk" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="${p.accent}" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="${p.accent}" stop-opacity="0.02"/></linearGradient></defs>
|
||||||
|
${grid}
|
||||||
|
<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${w - padR}" y2="${ly.toFixed(1)}" stroke="${p.bad}" stroke-width="1.2" stroke-dasharray="5 4"/>
|
||||||
|
<text x="${w - padR}" y="${(ly - 5).toFixed(1)}" text-anchor="end" font-size="9" fill="${p.bad}">限值 ${fmt(limit, 2)}</text>
|
||||||
|
<path d="${area}" fill="url(#${p.gid}_dk)"/>
|
||||||
|
<path d="${line}" fill="none" stroke="${p.accent}" stroke-width="2.4" stroke-linejoin="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>
|
||||||
|
${dots}${xlab}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* defs for optional glow filters — inject once per dashboard root */
|
||||||
|
function defs(p) {
|
||||||
|
if (!p.glow) return '';
|
||||||
|
return `<svg width="0" height="0" style="position:absolute"><defs>
|
||||||
|
<filter id="chGlow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||||
|
<filter id="chGlowSoft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||||
|
</defs></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.CHARTS = { ringGauge, donut, columns, hbars, radar, decayArea, defs, status, fmt };
|
||||||
|
})();
|
||||||
|
|
@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
|
||||||
|
predict: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l5-6 4 3 6-8"/><path d="M3 21h18"/></svg>',
|
||||||
|
flask: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8a2 2 0 0 0 1.7 3h10.6A2 2 0 0 0 19 17l-5-8V3"/><path d="M7 14h10"/></svg>',
|
||||||
|
source: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></svg>',
|
||||||
|
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
||||||
|
report: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
||||||
|
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7 7 0 0 0-1.7 1l-2.3-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.6c.1-.3.1-.7.1-1z" stroke-linejoin="round"/></svg>',
|
||||||
|
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
|
||||||
|
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>',
|
||||||
|
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 16H3z"/><path d="M12 9v5M12 17.5v.01"/></svg>',
|
||||||
|
bldg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 21V5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v16M14 21V9h5a1 1 0 0 1 1 1v11M7 8h2M7 12h2M7 16h2"/></svg>',
|
||||||
|
leaf: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20c10 2 16-4 16-14 0 0-8-2-12 2-3 3-3 7-1 9 3-4 6-6 9-7"/></svg>',
|
||||||
|
up: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14l6-6 6 6"/></svg>',
|
||||||
|
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 10l6 6 6-6"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 `<div class="d-navgrp">主菜单</div><div class="d-nav">` +
|
||||||
|
items.map((it, i) => `<div class="d-nav-i${i === active ? ' on' : ''}">${ICON[it[0]]}<span>${it[1]}</span>${it[2] ? `<span class="d-badge">${it[2]}</span>` : ''}</div>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiCard(lab, icon, iconBg, iconCol, num, unit, trend) {
|
||||||
|
return `<div class="d-kpi">
|
||||||
|
<div class="d-kpi-lab"><span class="d-kpi-ic" style="background:${iconBg};color:${iconCol}">${ICON[icon]}</span>${lab}</div>
|
||||||
|
<div class="d-kpi-row">
|
||||||
|
<div><span class="d-kpi-num">${num}</span>${unit ? `<span class="d-kpi-unit">${unit}</span>` : ''}</div>
|
||||||
|
${trend || ''}
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 += `<g transform="translate(0 ${yy})">
|
||||||
|
<text x="0" y="${rowH / 2 + 4}" font-size="12" font-weight="700" fill="${p.ink}">${d.name}</text>
|
||||||
|
<rect x="${barX}" y="${rowH / 2 - 8}" width="${barW}" height="16" rx="5" fill="${p.track}"/>
|
||||||
|
<rect x="${barX}" y="${rowH / 2 - 8}" width="${bw.toFixed(1)}" height="16" rx="5" fill="${col}"/>
|
||||||
|
<line x1="${limMark.toFixed(1)}" y1="${rowH / 2 - 12}" x2="${limMark.toFixed(1)}" y2="${rowH / 2 + 12}" stroke="${p.bad}" stroke-width="1.4" stroke-dasharray="3 3"/>
|
||||||
|
<text x="${w}" y="${rowH / 2 + 4}" text-anchor="end" font-size="11" font-weight="700" fill="${col}" style="font-variant-numeric:tabular-nums">${C.fmt(d.value, d.value >= 10 ? 0 : 3)}</text>
|
||||||
|
</g>`;
|
||||||
|
});
|
||||||
|
return `<div style="padding-top:4px"><svg viewBox="0 0 ${w} ${D.pollutants.length * rowH + 6}" width="100%">${rows}
|
||||||
|
<text x="${barX + (1 / 1.45) * barW}" y="${D.pollutants.length * rowH + 2}" text-anchor="middle" font-size="9" fill="${p.bad}">国标限值</text></svg></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="d-gauges">` + D.pollutants.map(d => {
|
||||||
|
const ratio = d.value / d.limit, sk = statusKey(ratio);
|
||||||
|
return `<div class="d-g">${C.ringGauge({ value: d.value, limit: d.limit, unit: d.unit, name: d.name, en: d.en, p })}
|
||||||
|
<div class="d-g-nm">${d.name}</div><div class="d-g-en">${d.en} · 限值 ${C.fmt(d.limit, 2)}</div>
|
||||||
|
<div class="d-g-pill pill-${sk}">${cnLevel[sk]} ${Math.round(ratio * 100)}%</div></div>`;
|
||||||
|
}).join('') + `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = `<div class="d-legend">` + segs.map(s =>
|
||||||
|
`<div class="d-leg"><span class="dot" style="background:${s.color}"></span><span class="nm">${s.label}房间</span><span class="vl">${s.value}</span><span class="pc">${pct(s.value, total)}%</span></div>`
|
||||||
|
).join('') + `</div>`;
|
||||||
|
if (mode === 'bar') {
|
||||||
|
let x = 0; const w = 100;
|
||||||
|
const segbar = segs.map(s => { const wpc = s.value / total * w; const r = `<div style="width:${wpc}%;background:${s.color}"></div>`; x += wpc; return r; }).join('');
|
||||||
|
return `<div style="display:flex;height:18px;border-radius:6px;overflow:hidden;gap:2px;margin:6px 0 16px">${segbar}</div>
|
||||||
|
<div style="font-family:var(--display);font-size:34px;font-weight:800;letter-spacing:-.5px">${D.kpis.compliance}<span style="font-size:16px;color:var(--faint)">%</span></div>
|
||||||
|
<div style="font-size:12px;color:var(--sub);margin:2px 0 4px">总体房间达标率</div>${legend}`;
|
||||||
|
}
|
||||||
|
return `<div style="display:flex;justify-content:center;margin:4px 0 10px">${C.donut({ segments: segs, centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p })}</div>${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 = `<div class="d-top">
|
||||||
|
<div><div class="d-top-tt">工程污染概览</div><div class="d-top-crumb">${D.project.name} · ${D.project.area}㎡ · ${D.project.type}</div></div>
|
||||||
|
<div class="d-spacer"></div>
|
||||||
|
<div class="d-search">${ICON.search}<span>搜索项目 / 房间 / 材料</span></div>
|
||||||
|
<div class="d-std"><b class="on">GB/T 18883</b><b>GB 50325</b></div>
|
||||||
|
<div class="d-iconbtn">${ICON.bell}<span class="d-dot"></span></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const side = `<div class="d-side">
|
||||||
|
<div class="d-brand"><div class="d-logo">${warm ? ICON.leaf : ICON.bldg}</div>
|
||||||
|
<div><div class="d-brand-tt">污染物预测系统</div><div class="d-brand-sub">INDOOR · AIR</div></div></div>
|
||||||
|
${nav(0)}
|
||||||
|
<div class="d-side-foot">
|
||||||
|
<div class="d-user"><div class="d-ava">陈</div>
|
||||||
|
<div><div class="d-user-nm">陈工 · 环境工程师</div><span class="d-pro">★ 专业版</span></div></div>
|
||||||
|
</div></div>`;
|
||||||
|
|
||||||
|
const kpis = `<div class="d-kpis">
|
||||||
|
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', `<span class="d-kpi-tr d-up">${ICON.up}本周 +3</span>`)}
|
||||||
|
${kpiCard('房间达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}%</span>`)}
|
||||||
|
${kpiCard('当前超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', `<span class="d-kpi-tr d-up">${ICON.down}${Math.abs(D.kpis.trend.exceed)}</span>`)}
|
||||||
|
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', `<span class="d-kpi-tr" style="color:var(--accent-on);background:var(--accent-soft)">${ICON.up}活跃</span>`)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const totalRooms = D.compliance.reduce((s, x) => s + x.value, 0);
|
||||||
|
|
||||||
|
const colCard = `<div class="card sp8">
|
||||||
|
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>各房间 · 甲醛预测浓度</div><div class="card-sub">柱状图按状态着色 · 红=超标 / 橙=临界 / 绿=达标,对照国标限值</div></div>
|
||||||
|
<span class="card-tag">单位 mg/m³</span></div>
|
||||||
|
${C.columns({ items: D.rooms, limit: D.roomLimit, limit2: D.roomLimit2, unit: 'mg/m³', p, w: 760, h: 268 })}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const donutCard = `<div class="card sp4">
|
||||||
|
<div class="card-h"><div class="card-t"><span class="d-bar"></span>项目达标率</div><span class="card-tag">${totalRooms} 间房</span></div>
|
||||||
|
${complianceViz(opts.compliance || 'donut', p, totalRooms)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const gaugeCard = `<div class="card sp5">
|
||||||
|
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>最近一次预测 · 主卧</div><div class="card-sub">${D.project.updated} · 6 项污染物 vs 国标限值</div></div></div>
|
||||||
|
<div class="d-alert">${ICON.alert}<span>甲醛 ${hcho.value} mg/m³ · 超出 GB/T 18883 限值 ${over}%,建议加强通风并核查人造板材料</span></div>
|
||||||
|
${pollutantViz(opts.pollutant || 'ring', p)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const radarCard = `<div class="card sp4">
|
||||||
|
<div class="card-h"><div class="card-t"><span class="d-bar"></span>污染物雷达</div><span class="card-tag">主卧</span></div>
|
||||||
|
<div class="card-sub" style="margin:-8px 0 2px">虚线红环 = 国标限值(比值 1.0)</div>
|
||||||
|
${C.radar({ axes: D.pollutants.map(d => ({ name: d.name, value: d.value, limit: d.limit })), p, size: 250 })}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const decayCard = `<div class="card sp3">
|
||||||
|
<div class="card-h"><div class="card-t"><span class="d-bar"></span>甲醛衰减</div></div>
|
||||||
|
<div class="card-sub" style="margin:-8px 0 8px">随通风天数</div>
|
||||||
|
${C.decayArea({ points: D.decay, limit: D.roomLimit, unit: 'mg/m³', p, w: 300, h: 176 })}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const tableRows = D.exceed.map(r => {
|
||||||
|
const ratio = r.value / r.limit;
|
||||||
|
return `<tr><td><div class="rm">${r.room}</div><div class="pj">${r.project}</div></td>
|
||||||
|
<td><span class="d-pol">${r.pollutant}</span></td>
|
||||||
|
<td class="vn" style="color:${C.status(ratio, p)}">${C.fmt(r.value, r.value >= 10 ? 0 : 3)}</td>
|
||||||
|
<td class="lm">${C.fmt(r.limit, 2)}</td>
|
||||||
|
<td><span class="d-chip chip-${r.level}">${cnLevel[r.level]} ${Math.round(ratio * 100)}%</span></td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
const tableCard = `<div class="card sp8">
|
||||||
|
<div class="card-h"><div class="card-t"><span class="d-bar"></span>超标房间清单</div><span class="card-tag">${ICON.alert ? '' : ''}共 ${D.exceed.length} 条 · 按超标率排序</span></div>
|
||||||
|
<table class="d-tbl"><thead><tr><th>房间 / 项目</th><th>污染物</th><th>预测浓度</th><th>限值</th><th>状态</th></tr></thead>
|
||||||
|
<tbody>${tableRows}</tbody></table></div>`;
|
||||||
|
|
||||||
|
const matCard = `<div class="card sp4">
|
||||||
|
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>污染源识别 · 材料贡献</div><div class="card-sub">主卧甲醛 · 公式溯源排行</div></div></div>
|
||||||
|
${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 })}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (warm) {
|
||||||
|
const heroComp = `<div class="d-hero"><div class="d-hero-l">
|
||||||
|
${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 })}
|
||||||
|
<div class="d-hero-txt"><div class="t">在管 ${D.kpis.projects} 个项目 · ${totalRooms} 间房</div>
|
||||||
|
<div class="n">${D.compliance[2].value} 间超标 · ${D.compliance[1].value} 间临界</div>
|
||||||
|
<div class="t" style="margin-top:8px">最近更新 ${D.project.updated}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-kpis" style="margin:0">
|
||||||
|
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', '')}
|
||||||
|
${kpiCard('达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}</span>`)}
|
||||||
|
${kpiCard('超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', '')}
|
||||||
|
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', '')}
|
||||||
|
</div></div>`;
|
||||||
|
body = heroComp + `<div class="d-grid">
|
||||||
|
${colCard}
|
||||||
|
${gaugeCard.replace('sp5','sp4')}
|
||||||
|
${radarCard}
|
||||||
|
${decayCard.replace('sp3','sp4')}
|
||||||
|
${matCard}
|
||||||
|
${tableCard.replace('sp8','sp12')}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
body = kpis + `<div class="d-grid">
|
||||||
|
${colCard}${donutCard}
|
||||||
|
${gaugeCard}${radarCard}${decayCard}
|
||||||
|
${tableCard}${matCard}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="dash ${T.cls}" style="${styleVars}">${C.defs(p)}${side}
|
||||||
|
<div class="d-main">${top}<div class="d-scroll">${body}</div></div></div>`;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
@click="onNav"
|
@click="onNav"
|
||||||
>
|
>
|
||||||
<a-menu-item key="home">首页</a-menu-item>
|
<a-menu-item key="home">首页</a-menu-item>
|
||||||
|
<a-menu-item key="dashboard">总览看板</a-menu-item>
|
||||||
<a-menu-item key="template">模板库</a-menu-item>
|
<a-menu-item key="template">模板库</a-menu-item>
|
||||||
<a-menu-item key="material">材料库</a-menu-item>
|
<a-menu-item key="material">材料库</a-menu-item>
|
||||||
<a-menu-item key="history">历史记录</a-menu-item>
|
<a-menu-item key="history">历史记录</a-menu-item>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
<div class="dash-page">
|
||||||
|
<div class="dash-bar">
|
||||||
|
<a @click="router.push('/home')">← 返回工作台</a>
|
||||||
|
<span class="dash-bar-tt">专业看板 · 工程污染概览</span>
|
||||||
|
<span class="dash-bar-note">演示数据(静态),接入后端聚合后实时</span>
|
||||||
|
</div>
|
||||||
|
<div class="dash-host"><div ref="host" style="width:100%;height:100%"></div></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const host = ref<HTMLElement>();
|
||||||
|
|
||||||
|
function loadScript(src: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => reject(new Error('load fail ' + src));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const w = window as any;
|
||||||
|
if (!w.renderDashboard) {
|
||||||
|
await loadScript('/dashboard/data.js');
|
||||||
|
await loadScript('/dashboard/charts.js');
|
||||||
|
await loadScript('/dashboard/dashboard.js');
|
||||||
|
}
|
||||||
|
if (host.value) host.value.innerHTML = w.renderDashboard('warm', { compliance: 'donut', pollutant: 'ring' });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style src="../styles/dashboard.css"></style>
|
||||||
|
<style scoped>
|
||||||
|
.dash-page { height: 100vh; display: flex; flex-direction: column; background: #f4f0e7; }
|
||||||
|
.dash-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 16px; padding: 10px 20px; background: #fffdf8; border-bottom: 1px solid #e8e0d0; }
|
||||||
|
.dash-bar a { color: #1f7a5a; cursor: pointer; font-weight: 600; }
|
||||||
|
.dash-bar-tt { font-weight: 700; }
|
||||||
|
.dash-bar-note { color: #a89c86; font-size: 12px; margin-left: auto; }
|
||||||
|
.dash-host { flex: 1; min-height: 0; overflow: auto; }
|
||||||
|
.dash-host :deep(.dash) { min-width: 1280px; }
|
||||||
|
</style>
|
||||||
|
|
@ -198,7 +198,7 @@ onBeforeUnmount(stop);
|
||||||
|
|
||||||
function scrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
|
function scrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
|
||||||
function scrollTo(id: string) { document.getElementById(id)?.scrollIntoView({ 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);
|
const authOpen = ref(false);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const routes = [
|
||||||
{ path: '/login', component: () => import('../pages/Login.vue') },
|
{ path: '/login', component: () => import('../pages/Login.vue') },
|
||||||
{ path: '/landing', name: 'landing', component: () => import('../pages/Landing.vue') },
|
{ path: '/landing', name: 'landing', component: () => import('../pages/Landing.vue') },
|
||||||
{ path: '/source', name: 'source', component: () => import('../pages/SourceTracing.vue') },
|
{ path: '/source', name: 'source', component: () => import('../pages/SourceTracing.vue') },
|
||||||
|
{ path: '/dashboard', name: 'dashboard', component: () => import('../pages/Dashboard.vue') },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('../layouts/AppLayout.vue'),
|
component: () => import('../layouts/AppLayout.vue'),
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
Loading…
Reference in New Issue