跳转至

数据展示件(Chip / 元数据 / 进度 / 统计瓦片)

一张页面里最底层的原子零件:状态/标签用 chip 表意,字段/键值用 metadata list 平铺,配额/百分比走 progress,指标 + 涨跌 + sparkline 上 stat 瓦片。真实版这些原子会被表格单元格、详情面板、图表页大量复用;demo 把它们摆到同一面墙上,方便一次看清所有语义色和亮暗表现。

试这几下:

  • 点右上角 切到暗色 / 亮色 —— 主题瞬时切换,所有原子跟着 CSS 变量走,色差与对比保持一致
  • 点标签上的 × 移除单个标签;输入框回车/添加新增;空到没有时不塌陷,替换成虚线空态占位
  • 清空 —— 破坏性动作先弹确认;成功后 toast + 出现空态
  • 进度区 重新采样 —— 随机改数值,颜色按阈值切换:<80% 绿 / 80–95% 橙 / ≥95% 红
  • 看 Stat 瓦片:涨跌决定 delta 文本颜色与 sparkline 描边色(涨绿跌红)

规矩

  • 纯展示、无数据获取:所有原子只吃 props / 子节点,不发请求、不管状态。demo 用内存数据陈列,真实版由表格单元格 / 详情面板 / 图表页把 fetch 好的字段传进来
  • 颜色全走 CSS 变量:Chip / Progress / Stat 的语义色(good / warn / danger / muted)都绑到令牌,同一份 JSX 在亮暗主题里直接跟随;不硬编码颜色。demo 用 <html data-theme> 覆盖 demo.css 的 prefers-color-scheme 演示切换
  • 语义色 status → 颜色映射统一:活跃 / <80% / 涨 → --chip-on-*;警告 / 80–95% → --chip-warn-*;停用 / ≥95% / 跌 → --danger
  • 空槽给占位:TagList 空数组渲染 (真实版)或虚线空态块(demo),从不塌陷;所有数字用 tabular-nums 对齐
  • 每次变更 toast,破坏性操作(清空标签)先过确认弹窗;这些原子本身无副作用,但 demo 沿用同一交互规矩

蓝本:add-data-display.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-data-display.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:数据展示件</title>
<!--
  demo-data-display.html —— 「数据展示件」形状的最小可玩实现
  (蓝本:open-dashboard 的 add-data-display —— TagList / MetadataList / ProgressTile / UserCell)。
  复现的不变量:
  - 纯展示,无数据获取:所有原子只吃 props / 子节点;demo 内存数据陈列,真实版由上层页面喂
  - 颜色全走 CSS 变量:Chip / Progress / Stat 的语义色(good / warn / danger / muted)绑令牌,
    切主题时同一份 JSX 直接跟随,不硬编码颜色
  - 语义色 status → 颜色映射统一:
    活跃 / <80% / 涨 → --chip-on-*;警告 / 80–95% → --chip-warn-*;停用 / ≥95% / 跌 → --danger
  - 空槽给占位:TagList 空数组渲染空态块,从不塌陷;数字用 tabular-nums 对齐
  额外:demo 提供亮/暗主题按钮,通过 <html data-theme="…"> 覆盖 demo.css 的系统跟随,
  演示所有展示件在两种主题下都干净。
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  /* data-theme 覆盖 demo.css 的 prefers-color-scheme,供本页主题按钮切换 */
  :root[data-theme="light"] {
    --bg: #f6f7f9; --card: #ffffff; --fg: #1f2328; --muted: #6a737d;
    --border: #e4e7eb; --primary: #3564d7; --primary-fg: #ffffff; --danger: #c4372c;
    --chip-on-bg: #e3f4e8; --chip-on-fg: #176e34;
    --chip-off-bg: #f0f1f3; --chip-off-fg: #6a737d;
    --chip-warn-bg: #fdf1d8; --chip-warn-fg: #8a5a00;
  }
  :root[data-theme="dark"] {
    --bg: #15181c; --card: #1e2227; --fg: #e6e9ec; --muted: #8b949e;
    --border: #333a42; --primary: #5b8bf0; --primary-fg: #ffffff; --danger: #e5695f;
    --chip-on-bg: #12351f; --chip-on-fg: #5fca86;
    --chip-off-bg: #2a2f36; --chip-off-fg: #8b949e;
    --chip-warn-bg: #3a2e10; --chip-warn-fg: #e0b45c;
  }

  .wall { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  @media (max-width: 640px) { .wall { grid-template-columns: 1fr; } }
  .block { padding: 12px 14px; }
  .block h3 { margin: 0 0 4px; font-size: 13px; }
  .block .hint { margin: 0 0 10px; color: var(--muted); font-size: 12px; }

  /* Chip 陈列 —— 变体一览 + 交互式 TagList */
  .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
  .chip.chip-primary { background: color-mix(in srgb, var(--primary) 16%, transparent); color: var(--primary); }
  .chip.chip-danger { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
  .chip.removable { padding-right: 4px; display: inline-flex; align-items: center; gap: 3px; }
  .chip .x { border: 0; background: transparent; color: inherit; cursor: pointer;
             padding: 0 3px; font-size: 12px; line-height: 1; opacity: .7; }
  .chip .x:hover { opacity: 1; }
  .tag-add { display: flex; gap: 6px; margin-top: 8px; }
  .tag-add input { flex: 1; }
  .empty-slot { padding: 14px; text-align: center; color: var(--muted);
                border: 1px dashed var(--border); border-radius: 10px; font-size: 12px; }

  /* MetadataList —— 键值网格 */
  dl.meta { display: grid; grid-template-columns: minmax(72px, auto) 1fr;
            gap: 6px 14px; margin: 0; font-size: 13px; }
  dl.meta dt { color: var(--muted); }
  dl.meta dd { margin: 0; color: var(--fg); font-weight: 550; font-variant-numeric: tabular-nums; }

  /* Progress bar */
  .prog-item { margin-bottom: 10px; }
  .prog-item:last-child { margin-bottom: 0; }
  .prog-head { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
  .prog-head .l { color: var(--fg); }
  .prog-head .r { color: var(--muted); font-variant-numeric: tabular-nums; }
  .bar { height: 6px; border-radius: 999px; background: var(--chip-off-bg); overflow: hidden; }
  .bar > i { display: block; height: 100%; border-radius: inherit; transition: width .35s ease; }
  .bar > i.tone-on { background: var(--chip-on-fg); }
  .bar > i.tone-warn { background: var(--chip-warn-fg); }
  .bar > i.tone-danger { background: var(--danger); }

  /* Stat 瓦片 + sparkline */
  .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .stat-tile { padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; }
  .stat-tile .row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
  .stat-tile .v { margin: 2px 0 0; font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; }
  .delta-up { color: var(--chip-on-fg); font-size: 12px; }
  .delta-down { color: var(--danger); font-size: 12px; }
  svg.spark { display: block; }

  /* 分隔线 */
  .divider { height: 1px; background: var(--border); margin: 12px 0; border: 0; }

  /* 主题按钮 */
  .theme-btn { display: inline-flex; align-items: center; gap: 6px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useEffect, useMemo } = React;
const html = htm.bind(React.createElement);

const INITIAL_TAGS = [
  { text: "设计",     tone: "on" },
  { text: "前端",     tone: "on" },
  { text: "紧急",     tone: "warn" },
  { text: "Q3 目标",  tone: "primary" },
  { text: "计费",     tone: "off" },
  { text: "内部",     tone: "off" },
];

const META = [
  { label: "状态",    valueChip: { text: "运行中", tone: "on" } },
  { label: "套餐",    value: "企业版" },
  { label: "席位",    value: "48 / 50" },
  { label: "区域",    value: "cn-east-1" },
  { label: "创建于",  value: "2024-11-02" },
  { label: "责任人",  value: "陈禾" },
];

const INITIAL_PROGRESS = [
  { label: "存储用量",       value: 320,  total: 512 },
  { label: "API 配额 (月)",  value: 7300, total: 10000 },
  { label: "任务完成",       value: 18,   total: 24 },
  { label: "带宽用量",       value: 96,   total: 100 },
];

const STATS = [
  { label: "本月新增用户", display: "1,284",     delta: +12.4, spark: [10, 18, 14, 22, 26, 24, 30] },
  { label: "本周收入",     display: "¥ 128,940", delta: +4.7,  spark: [40, 42, 38, 45, 50, 48, 54] },
  { label: "退款率",       display: "1.8%",      delta: -0.3,  spark: [3.2, 2.8, 2.5, 2.1, 2.0, 1.9, 1.8] },
  { label: "平均响应",     display: "182 ms",    delta: -8.1,  spark: [240, 226, 218, 210, 202, 190, 182] },
];

// 阈值 → 语义色:<80% 绿 / 80–95% 橙 / ≥95% 红
function progressTone(pct) {
  if (pct >= 95) return "danger";
  if (pct >= 80) return "warn";
  return "on";
}

// sparkline:折线 + 尾点,按涨跌上色
function Spark({ points, up }) {
  const w = 64, h = 18;
  const min = Math.min.apply(null, points);
  const max = Math.max.apply(null, points);
  const range = max - min || 1;
  const step = w / (points.length - 1);
  const coords = points.map((v, i) => [
    (i * step),
    h - ((v - min) / range) * (h - 2) - 1,
  ]);
  const d = coords.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" ");
  const last = coords[coords.length - 1];
  const color = up ? "var(--chip-on-fg)" : "var(--danger)";
  return html`
    <svg class="spark" viewBox=${"0 0 " + w + " " + h} width=${w} height=${h} aria-hidden="true">
      <path d=${d} fill="none" stroke=${color} stroke-width="1.5"
            stroke-linejoin="round" stroke-linecap="round" />
      <circle cx=${last[0].toFixed(1)} cy=${last[1].toFixed(1)} r="1.6" fill=${color} />
    </svg>`;
}

function Confirm({ text, onOk, onCancel }) {
  return html`
    <div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onCancel(); }}>
      <div class="modal">
        <h2>确认操作</h2>
        <p style=${{ margin: 0 }}>${text}</p>
        <div class="actions">
          <button class="btn" onClick=${onCancel}>取消</button>
          <button class="btn primary" onClick=${onOk}>确认</button>
        </div>
      </div>
    </div>`;
}

function initialTheme() {
  if (typeof window === "undefined" || !window.matchMedia) return "light";
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

function App() {
  const [theme, setTheme] = useState(initialTheme);
  const [tags, setTags] = useState(INITIAL_TAGS);
  const [tagInput, setTagInput] = useState("");
  const [progress, setProgress] = useState(INITIAL_PROGRESS);
  const [confirm, setConfirm] = useState(null);
  const [toast, setToast] = useState("");

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
  }, [theme]);

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  const removeTag = (idx) => {
    const removed = tags[idx];
    setTags(ts => ts.filter((_, i) => i !== idx));
    setToast("已移除标签「" + removed.text + "」");
  };

  const addTag = () => {
    const v = tagInput.trim();
    if (!v) return;
    if (tags.some(t => t.text === v)) {
      setToast("标签「" + v + "」已存在");
      return;
    }
    setTags(ts => ts.concat([{ text: v, tone: "primary" }]));
    setTagInput("");
    setToast("已添加标签「" + v + "」");
  };

  const clearTags = () => setConfirm({
    text: "清空当前所有标签?此操作只影响 demo 本地状态。",
    onOk: () => {
      setTags([]);
      setConfirm(null);
      setToast("已清空全部标签");
    },
  });

  const resample = () => {
    setProgress(ps => ps.map(p => {
      const v = Math.max(0, Math.min(p.total, Math.round(p.total * (0.2 + Math.random() * 0.8))));
      return { ...p, value: v };
    }));
    setToast("已重新采样进度值");
  };

  const themeLabel = theme === "dark" ? "亮色" : "暗色";
  const toggleTheme = () => {
    const next = theme === "dark" ? "light" : "dark";
    setTheme(next);
    setToast("已切换到" + (next === "dark" ? "暗色" : "亮色") + "主题");
  };

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>数据展示件陈列</h1>
          <p class="sub">Chip / 元数据 / 进度 / 统计瓦片 —— 一屏看清所有原子在亮暗两态的样子</p>
        </div>
        <button class="btn theme-btn" onClick=${toggleTheme} aria-label="切换主题">
          <span>${theme === "dark" ? "🌙" : "☀️"}</span>
          <span>切到${themeLabel}</span>
        </button>
      </header>

      <div class="wall">
        <!-- 1. Chip / Badge / Tag 变体 + 交互 TagList -->
        <section class="card block">
          <h3>Chip / Badge / Tag</h3>
          <p class="hint">语义变体(一览)+ 可增删的标签列表</p>
          <div class="chip-row">
            <span class="chip chip-on">活跃</span>
            <span class="chip chip-warn">警告</span>
            <span class="chip chip-off">停用</span>
            <span class="chip chip-primary">主色</span>
            <span class="chip chip-danger">危险</span>
          </div>
          <hr class="divider" />
          ${tags.length === 0
            ? html`<div class="empty-slot">还没有标签 —— 从下方输入框加一个</div>`
            : html`
              <div class="chip-row">
                ${tags.map((t, i) => html`
                  <span key=${t.text} class=${"chip removable chip-" + t.tone}>
                    ${t.text}
                    <button class="x" aria-label=${"移除 " + t.text}
                            onClick=${() => removeTag(i)}>×</button>
                  </span>`)}
              </div>`}
          <div class="tag-add">
            <input placeholder="新标签名…" value=${tagInput}
                   onChange=${e => setTagInput(e.target.value)}
                   onKeyDown=${e => { if (e.key === "Enter") addTag(); }} />
            <button class="btn sm" onClick=${addTag}>添加</button>
            <button class="btn sm" style=${{ color: "var(--danger)" }}
                    onClick=${clearTags} disabled=${tags.length === 0}>清空</button>
          </div>
        </section>

        <!-- 2. MetadataList -->
        <section class="card block">
          <h3>Metadata List(键值元数据)</h3>
          <p class="hint">label 灰色定宽,value 加粗跟随 tabular-nums</p>
          <dl class="meta">
            ${META.map(m => html`
              <${React.Fragment} key=${m.label}>
                <dt>${m.label}</dt>
                <dd>${m.valueChip
                  ? html`<span class=${"chip chip-" + m.valueChip.tone}>${m.valueChip.text}</span>`
                  : m.value}</dd>
              <//>`)}
          </dl>
        </section>

        <!-- 3. Progress -->
        <section class="card block">
          <h3>Progress(配额 / 百分比)</h3>
          <p class="hint">阈值 → 语义色:&lt;80% 绿 / 80–95% 橙 / ≥95% 红</p>
          ${progress.map(p => {
            const pct = p.total > 0 ? Math.min(100, Math.max(0, Math.round((p.value / p.total) * 100))) : 0;
            const tone = progressTone(pct);
            return html`
              <div key=${p.label} class="prog-item">
                <div class="prog-head">
                  <span class="l">${p.label}</span>
                  <span class="r">${p.value.toLocaleString()} / ${p.total.toLocaleString()} · ${pct}%</span>
                </div>
                <div class="bar"><i class=${"tone-" + tone} style=${{ width: pct + "%" }}></i></div>
              </div>`;
          })}
          <hr class="divider" />
          <button class="btn sm" onClick=${resample}>重新采样</button>
        </section>

        <!-- 4. Stat 瓦片 + sparkline -->
        <section class="card block">
          <h3>Stat 瓦片(指标 + 涨跌 + sparkline)</h3>
          <p class="hint">涨跌决定 delta / sparkline 颜色</p>
          <div class="stat-grid">
            ${STATS.map(s => {
              const up = s.delta >= 0;
              return html`
                <div key=${s.label} class="stat-tile">
                  <p class="stat-label">${s.label}</p>
                  <div class="row">
                    <p class="v">${s.display}</p>
                    <${Spark} points=${s.spark} up=${up} />
                  </div>
                  <p class=${up ? "delta-up" : "delta-down"}>
                    ${up ? "↑" : "↓"} ${Math.abs(s.delta).toFixed(1)}% vs 上周
                  </p>
                </div>`;
            })}
          </div>
        </section>
      </div>

      ${confirm && html`<${Confirm} text=${confirm.text} onOk=${confirm.onOk} onCancel=${() => setConfirm(null)} />`}
      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>