Verdant Glass 苔光用量台
一个「LLM 网关用量看板」首页:悬浮胶囊侧栏 + 四张请求/Token/输入/输出统计卡 + 60×7 活跃度热力图 + 请求趋势面积图 + 供应商消费榜。浅灰绿底 + 白玻璃卡 + 翡翠绿点缀,轻盈干净的 SaaS 后台气质。
要点
- 浅灰绿
#f3f5ef打底,右上 / 左下各垫一坨 500pxblur(120px)光晕(emerald / teal),卡片用white/90+ 轻度backdrop-blur让光晕从边缘渗出 - 侧栏做成悬浮胶囊:
sticky吸顶 +border-radius: 999px拉满,当前项用暖米色块 highlight,其余图标浅灰、hover 才转深灰 —— 只留一个焦点 - 统计卡左窄右宽:左侧竖排小图标 +
writing-mode: vertical-rl竖排标题当分隔线,右侧堆两行「图标胶囊 + 标签 + 数值 + 单位」 - 活跃度热力图用 60 列 × 7 行、5 档绿色阶(GitHub 贡献图那一路),两端几列刻意压低取值模拟起量 / 收尾的边缘效应
- 面积图用单色 emerald 线 + 渐变面积水洗,hover 出十字线 + 跟指针的 tooltip(做法与 dashboard 形状库的折线图同源)
源码(折叠)
verdant-glass-demo.html(自包含单页,含内联 style + script)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verdant Glass 苔光用量台 demo</title>
<!--
verdant-glass-demo.html —— 「前端风格收集」的 Verdant Glass 苔光用量台演示真身。
吸纳自一段 AI 生成的 LLM 网关用量看板单页(2026-07-09 收录)。原版是 React +
Tailwind 工具类 + lucide-react 图标 + recharts 面积图,按本 wiki 自包含规矩
(skills/writing/mkdocs-wiki/index.md 第 9 节)改写:
- React 18 UMD + htm 从全站 /vendor/ 引,免 JSX/Babel,不走任何 CDN
- Tailwind 工具类翻成下面的语义化 CSS
- lucide-react 图标换成等价内联 SVG(沿用 lucide 线框风格:24 视口、currentColor 描边、圆头圆角)
- recharts 的 AreaChart 换成手写 SVG 面积图(渐变面积 + hover 十字线 tooltip,做法同
dashboard 形状库的 LineChart)
- 原稿贴出时结尾被截断:Area 的 props 与下一张卡片的开头一起丢了,只剩供应商榜的
`.map` 和一个残留的 className 尾巴 `..._20px_rgb(0,0,0,0.03)] flex flex-col gap-5`。
按残留的 shadow 类和卡片圆角推断,补全为「趋势图 2/3 宽 + 供应商消费榜 1/3 宽」两栏,
供应商卡沿用同一套白玻璃卡片外观。
-->
<style>
* { box-sizing: border-box; }
body {
margin: 0;
background: #f3f5ef;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", Helvetica, Arial, sans-serif;
}
button { font: inherit; cursor: pointer; }
/* 背景光晕:固定层,不参与文档流,不挡交互 */
.glow-layer { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
.glow { position: absolute; width: 500px; height: 500px; border-radius: 50%; filter: blur(120px); }
.glow-emerald { top: -100px; right: -100px; background: rgba(5, 150, 105, .1); }
.glow-teal { bottom: -100px; left: -100px; background: rgba(13, 148, 136, .05); }
.shell { position: relative; z-index: 1; min-height: 100vh; display: flex; justify-content: center; }
.layout { width: 100%; max-width: 1440px; padding: 24px; display: flex; gap: 24px; }
/* 悬浮胶囊侧栏:吸顶、圆角拉满、玻璃底 */
.sidebar { position: sticky; top: 24px; z-index: 10; flex-shrink: 0; height: fit-content; }
.sidebar-pill {
width: 68px; background: rgba(255, 255, 255, .8); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, .4);
border-radius: 999px; box-shadow: 0 8px 30px rgba(0, 0, 0, .04);
display: flex; flex-direction: column; align-items: center; padding: 8px 0 32px; gap: 28px;
}
.nav-item {
color: #9ca3af; background: none; border: none; padding: 0;
display: flex; align-items: center; justify-content: center; transition: color .15s;
}
.nav-item:hover { color: #1f2937; }
.nav-home {
width: 56px; height: 64px; margin-top: 4px; border-radius: 22px;
background: #eae3ce; color: #7c6a4a; box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
}
.nav-home:hover { background: #dfd5b8; color: #7c6a4a; }
.nav-settings { margin-top: 8px; }
@media (max-width: 767px) { .sidebar { display: none; } }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 24px; }
.header { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.header-icon { color: #047857; }
.header h1 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: .02em; }
.card {
background: rgba(255, 255, 255, .9); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
border-radius: 24px; box-shadow: 0 4px 20px rgba(0, 0, 0, .03);
}
/* ── 统计卡:左侧竖排图标+标签,右侧两行指标 ── */
.stats-grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
@media (min-width: 1024px) { .stats-grid { grid-template-columns: 1fr 1fr; } }
@media (min-width: 1280px) { .stats-grid { grid-template-columns: 1fr 1fr 1fr 1fr; } }
.stat-card { display: flex; align-items: stretch; gap: 20px; padding: 20px; min-width: 240px; }
.stat-aside {
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
border-right: 1px solid #f3f4f6; padding-right: 20px;
flex-direction: column; color: #9ca3af;
}
.stat-aside-label {
writing-mode: vertical-rl; text-orientation: mixed;
font-size: 13px; font-weight: 500; letter-spacing: .2em; color: #9ca3af;
}
.stat-rows { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 20px; }
.stat-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.stat-row-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.stat-icon-pill { padding: 6px; border-radius: 8px; background: #ecfdf5; color: #10b981; display: flex; }
.stat-row-label { font-size: 12px; font-weight: 500; color: #6b7280; white-space: nowrap; }
.stat-row-value { display: flex; align-items: baseline; gap: 4px; min-width: 0; overflow: hidden; }
.stat-prefix { color: #10b981; font-weight: 600; font-size: 14px; }
.stat-value { color: #1f2937; font-weight: 600; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stat-unit { color: #6b7280; font-size: 12px; margin-left: 2px; }
/* ── 活跃度热力图:60 列 x 7 行小方块 ── */
.heatmap-card { padding: 24px; }
.heatmap-scroll { overflow-x: auto; padding-bottom: 8px; }
.heatmap-cols { display: flex; gap: 4px; justify-content: flex-start; min-width: max-content; }
.heatmap-col { display: flex; flex-direction: column; gap: 4px; }
.cell { width: 12px; height: 12px; border-radius: 2px; transition: box-shadow .15s; }
.cell:hover { box-shadow: 0 0 0 2px #34d399; }
.lvl-0 { background: #e9eee4; }
.lvl-1 { background: #a3d8a7; }
.lvl-2 { background: #76c97d; }
.lvl-3 { background: #52ad5a; }
.lvl-4 { background: #3b8c42; }
/* ── 趋势图 2/3 + 供应商榜 1/3 ── */
.content-grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
@media (min-width: 900px) { .content-grid { grid-template-columns: 2fr 1fr; } }
.chart-card { padding: 24px; height: 320px; display: flex; flex-direction: column; }
.chart-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding: 0 8px; }
.chart-stats { display: flex; gap: 32px; }
.chart-stat-label { font-size: 12px; font-weight: 500; color: #6b7280; margin-bottom: 4px; }
.chart-stat-value { font-size: 20px; font-weight: 700; display: flex; align-items: baseline; gap: 4px; }
.chart-stat-value span { font-size: 14px; }
.chart-range { text-align: right; }
.chart-range-label { font-size: 12px; color: #9ca3af; margin-bottom: 4px; }
.chart-range-value { font-size: 14px; font-weight: 600; color: #1f2937; }
@media (max-width: 480px) { .chart-range { display: none; } }
.chart-body { flex: 1; min-width: 0; position: relative; }
.chart-body svg { display: block; width: 100%; height: 100%; }
.gridline { stroke: #f0f0f0; stroke-width: 1; }
.axis-label { fill: #9ca3af; font-size: 10px; }
.tip {
position: absolute; pointer-events: none; background: #fff; border-radius: 12px;
padding: 6px 10px; font-size: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, .08); white-space: nowrap;
}
.tip .tip-time { color: #6b7280; font-size: 12px; margin-bottom: 2px; }
.tip .tip-value { color: #10b981; font-size: 14px; font-weight: 700; }
.rank-card { padding: 24px; display: flex; flex-direction: column; gap: 20px; }
.rank-title { font-size: 12px; font-weight: 500; color: #6b7280; }
.rank-rows { display: flex; flex-direction: column; gap: 20px; }
.rank-row { display: flex; justify-content: space-between; align-items: center; }
.rank-left { display: flex; align-items: center; gap: 12px; }
.rank-medal { font-size: 20px; line-height: 1; }
.rank-name { font-size: 14px; font-weight: 500; color: #374151; transition: color .15s; }
.rank-row:hover .rank-name { color: #059669; }
.rank-amount { font-size: 14px; font-weight: 600; color: #1f2937; }
</style>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
</head>
<body>
<div id="root"></div>
<script>
"use strict";
const { useState, useRef } = React;
const html = htm.bind(React.createElement);
function Icon({ path, size = 18, strokeWidth = 2.5, viewBox = "0 0 24 24" }) {
return html`
<svg width=${size} height=${size} viewBox=${viewBox} fill="none" stroke="currentColor"
stroke-width=${strokeWidth} stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
}
const ICONS = {
logo: p => html`<path d="M12 13v8"/><path d="M4 14v4"/><path d="M20 14v4"/><path d="M9 13v6"/><path d="M15 13v6"/><path d="M12 3a6 6 0 0 0-6 6v4h12V9a6 6 0 0 0-6-6z"/>`,
home: () => html`<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`,
wifi: () => html`<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.86a10 10 0 0 1 14 0"/><path d="M8.5 16.43a5 5 0 0 1 7 0"/>`,
sparkles: () => html`<path d="M9.94 15.5A2 2 0 0 0 8.5 14.06l-6.13-1.58a.5.5 0 0 1 0-.96l6.13-1.58A2 2 0 0 0 9.94 8.5l1.58-6.13a.5.5 0 0 1 .96 0L14.06 8.5a2 2 0 0 0 1.44 1.44l6.13 1.58a.5.5 0 0 1 0 .96l-6.13 1.58a2 2 0 0 0-1.44 1.44l-1.58 6.13a.5.5 0 0 1-.96 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>`,
workflow: () => html`<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>`,
settings: () => html`<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>`,
bot: () => html`<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>`,
activity: () => html`<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>`,
messageSquare: () => html`<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>`,
clock: () => html`<path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="10"/>`,
layers: () => html`<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/>`,
dollarSign: () => html`<line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>`,
arrowDownToLine: () => html`<path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/>`,
chevronsRight: () => html`<path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/>`,
arrowUpFromLine: () => html`<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>`,
chevronsLeft: () => html`<path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/>`,
};
const STAT_GROUPS = [
{ title: "请求统计", icon: "activity", rows: [
{ label: "请求次数", icon: "messageSquare", value: "740.03", unit: "K" },
{ label: "消耗时间", icon: "clock", value: "17.74", unit: "m" },
]},
{ title: "全部统计", icon: "layers", rows: [
{ label: "消耗 Token", icon: "bot", value: "83.76", unit: "M" },
{ label: "消耗费用", icon: "dollarSign", value: "13.08", unit: "K$", prefix: "$" },
]},
{ title: "输入统计", icon: "arrowDownToLine", rows: [
{ label: "输入 Tokens", icon: "chevronsRight", value: "38.39", unit: "M" },
{ label: "输入费用", icon: "dollarSign", value: "9.73", unit: "K$", prefix: "$" },
]},
{ title: "输出统计", icon: "arrowUpFromLine", rows: [
{ label: "输出 Tokens", icon: "chevronsLeft", value: "45.37", unit: "M" },
{ label: "输出费用", icon: "dollarSign", value: "3.35", unit: "K$", prefix: "$" },
]},
];
const CHART_DATA = [
{ time: "0:00", value: 0 }, { time: "1:00", value: 4 }, { time: "2:00", value: 10 },
{ time: "3:00", value: 4 }, { time: "4:00", value: 9 }, { time: "5:00", value: 6 },
{ time: "6:00", value: 2 }, { time: "7:00", value: 9 }, { time: "8:00", value: 7 },
{ time: "9:00", value: 5 }, { time: "10:00", value: 4.5 }, { time: "11:00", value: 5 },
{ time: "12:00", value: 11 }, { time: "13:00", value: 3 }, { time: "14:00", value: 9 },
{ time: "15:00", value: 10.5 }, { time: "16:00", value: 8 },
];
const SUPPLIERS = [
{ id: "09", name: "供应商09", amount: "1.37K$", medal: "\u{1F947}" },
{ id: "07", name: "供应商07", amount: "1.22K$", medal: "\u{1F948}" },
{ id: "01", name: "供应商01", amount: "1.19K$", medal: "\u{1F949}" },
];
function Sidebar() {
const items = ["wifi", "sparkles", "workflow"];
return html`
<div class="sidebar">
<div class="sidebar-pill">
<button class="nav-item nav-home" title="主页"><${Icon} path=${ICONS.home()} size=${22} /></button>
${items.map(name => html`
<button key=${name} class="nav-item"><${Icon} path=${ICONS[name]()} size=${22} /></button>`)}
<button class="nav-item nav-settings" title="设置"><${Icon} path=${ICONS.settings()} size=${22} /></button>
</div>
</div>`;
}
function StatCard({ group }) {
return html`
<div class="card stat-card">
<div class="stat-aside">
<${Icon} path=${ICONS[group.icon]()} size=${18} />
<span class="stat-aside-label">${group.title}</span>
</div>
<div class="stat-rows">
${group.rows.map(row => html`
<div key=${row.label} class="stat-row">
<div class="stat-row-left">
<span class="stat-icon-pill"><${Icon} path=${ICONS[row.icon]()} size=${16} /></span>
<span class="stat-row-label">${row.label}</span>
</div>
<div class="stat-row-value">
${row.prefix && html`<span class="stat-prefix">${row.prefix}</span>`}
<span class="stat-value">${row.value}</span>
<span class="stat-unit">${row.unit}</span>
</div>
</div>`)}
</div>
</div>`;
}
// 60 列 x 7 行;两端 2 列人为压低活跃度,模拟"账号刚起量/近期收尾"的边缘效应
function generateGrid() {
const grid = [];
for (let col = 0; col < 60; col++) {
for (let row = 0; row < 7; row++) {
const weight = Math.random();
let value = 0;
if (weight > .85) value = 4;
else if (weight > .6) value = 3;
else if (weight > .3) value = 2;
else if (weight > .1) value = 1;
if ((col < 2 || col > 57) && weight > .5) value = 0;
grid.push({ row, col, value });
}
}
return grid;
}
function Heatmap() {
const [cells] = useState(generateGrid);
const cols = Array.from({ length: 60 }, (_, c) => cells.filter(d => d.col === c));
return html`
<div class="card heatmap-card">
<div class="heatmap-scroll">
<div class="heatmap-cols">
${cols.map((col, i) => html`
<div key=${i} class="heatmap-col">
${col.map((cell, j) => html`
<div key=${j} class=${"cell lvl-" + cell.value} title=${"活跃度: " + cell.value}></div>`)}
</div>`)}
</div>
</div>
</div>`;
}
// 面积图:手写 SVG 代替 recharts —— 渐变面积 + 指针找最近点画十字线 tooltip
function TrendChart({ data }) {
const [hover, setHover] = useState(null);
const ref = useRef(null);
const W = 700, H = 220, L = 30, R = 8, T = 10, B = 22;
const max = Math.max(...data.map(d => d.value));
const yMax = Math.ceil((max * 1.15) / 3) * 3 || 3;
const x = i => L + (i * (W - L - R)) / (data.length - 1);
const y = v => T + (1 - v / yMax) * (H - T - B);
const linePath = data.map((d, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(d.value).toFixed(1)}`).join(" ");
const areaPath = `${linePath} L${x(data.length - 1).toFixed(1)},${y(0)} L${x(0).toFixed(1)},${y(0)} Z`;
const yTicks = [0, yMax / 3, (yMax / 3) * 2, yMax];
const every = Math.ceil(data.length / 8);
const onMove = e => {
const box = ref.current.getBoundingClientRect();
const vx = ((e.clientX - box.left) / box.width) * W;
const i = Math.max(0, Math.min(data.length - 1,
Math.round(((vx - L) / (W - L - R)) * (data.length - 1))));
setHover({ i, px: (x(i) / W) * box.width, py: (y(data[i].value) / H) * box.height });
};
return html`
<div class="chart-body">
<svg ref=${ref} viewBox=${`0 0 ${W} ${H}`} onPointerMove=${onMove} onPointerLeave=${() => setHover(null)}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stop-color="#10b981" stop-opacity="0.3"/>
<stop offset="95%" stop-color="#10b981" stop-opacity="0"/>
</linearGradient>
</defs>
${yTicks.map(t => html`
<line key=${t} class="gridline" x1=${L} x2=${W - R} y1=${y(t)} y2=${y(t)}/>
<text key=${"t" + t} class="axis-label" x=${L - 6} y=${y(t) + 3} text-anchor="end">${Math.round(t)}$</text>`)}
${data.map((d, i) => (i % every === 0 ? html`
<text key=${d.time} class="axis-label" x=${x(i)} y=${H - 6} text-anchor="middle">${d.time}</text>` : null))}
<path d=${areaPath} fill="url(#areaGrad)"/>
<path d=${linePath} fill="none" stroke="#10b981" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
${hover && html`
<line x1=${x(hover.i)} x2=${x(hover.i)} y1=${T} y2=${H - B} stroke="#d1d5db" stroke-width="1"/>
<circle cx=${x(hover.i)} cy=${y(data[hover.i].value)} r="4" fill="#10b981" stroke="#fff" stroke-width="2"/>`}
</svg>
${hover && html`
<div class="tip" style=${{ left: Math.min(hover.px + 12, W - 110) + "px", top: Math.max(hover.py - 34, 0) + "px" }}>
<div class="tip-time">${data[hover.i].time}</div>
<div class="tip-value">${data[hover.i].value.toFixed(2)}$</div>
</div>`}
</div>`;
}
function ChartCard() {
return html`
<div class="card chart-card">
<div class="chart-head">
<div class="chart-stats">
<div>
<div class="chart-stat-label">请求次数</div>
<div class="chart-stat-value">3.72<span>K</span></div>
</div>
<div>
<div class="chart-stat-label">消费金额</div>
<div class="chart-stat-value">113.82<span>$</span></div>
</div>
</div>
<div class="chart-range">
<div class="chart-range-label">时间范围</div>
<div class="chart-range-value">今天</div>
</div>
</div>
<${TrendChart} data=${CHART_DATA} />
</div>`;
}
function RankCard() {
return html`
<div class="card rank-card">
<div class="rank-title">供应商消费 Top 3</div>
<div class="rank-rows">
${SUPPLIERS.map(s => html`
<div key=${s.id} class="rank-row">
<div class="rank-left">
<span class="rank-medal" role="img" aria-label="medal">${s.medal}</span>
<span class="rank-name">${s.name}</span>
</div>
<div class="rank-amount">${s.amount}</div>
</div>`)}
</div>
</div>`;
}
function App() {
return html`
<div class="shell">
<div class="glow-layer"><div class="glow glow-emerald"></div><div class="glow glow-teal"></div></div>
<div class="layout">
<${Sidebar} />
<div class="main">
<header class="header">
<span class="header-icon"><${Icon} path=${ICONS.logo()} size=${28} strokeWidth=${2} /></span>
<h1>主页</h1>
</header>
<div class="stats-grid">
${STAT_GROUPS.map(g => html`<${StatCard} key=${g.title} group=${g} />`)}
</div>
<${Heatmap} />
<div class="content-grid">
<${ChartCard} />
<${RankCard} />
</div>
</div>
</div>
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>