跳转至

Verdant Glass 苔光用量台

一个「LLM 网关用量看板」首页:悬浮胶囊侧栏 + 四张请求/Token/输入/输出统计卡 + 60×7 活跃度热力图 + 请求趋势面积图 + 供应商消费榜。浅灰绿底 + 白玻璃卡 + 翡翠绿点缀,轻盈干净的 SaaS 后台气质。

要点

  • 浅灰绿 #f3f5ef 打底,右上 / 左下各垫一坨 500px blur(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>