跳转至

紧凑列表(密集行 + 懒加载分区)

行只有「主标签 + 次要说明」、不需要成列对齐时,别上表格——用紧凑列表:首部头像、主次两行文本、尾部状态和时间,一行一条塞得更密。人员、动态、通知流都是它。蓝本给两个变体:lite(平铺 + 本地搜索)和 lazy(分区首次展开才取数)。

试这几下:

  • 平铺模式搜索标题 / 人名 / 内容——即时过滤,无结果有空态行
  • 列表底部加载更多——先出骨架行再补一批(模拟增量取数)
  • 切到按类型分组——分区首次展开先骨架屏后出行;收起再展开是即时的(已缓存)
  • 点任意行弹详情,前往处理用 toast 模拟跳转

规矩

  • 密度即卖点:紧凑行、次要文字弱化、没有任何表格骨架——无表头、无列线。行结构固定:头像/图标 + 主行 + 次行 + 尾部 meta
  • 空态和加载态显式处理:搜索无结果给空态行;lazy 变体首次展开给骨架屏,取到后缓存、不重复取。demo 用 setTimeout 模拟取数,真实版换成该分区的列表查询(useQuery
  • 影响结果集的搜索/筛选,真实资源要进 URL;demo 和蓝本捆绑示例一样放局部 useState
  • 变体二选一:lite 平铺给短列表(本地过滤就够),lazy 分区给量大且天然分组的数据。真实版都从 RepositoryuseResourceList/useQuery 驱动,替掉内存数组

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

demo 源码:assets/demo-list-view.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-list-view.html —— 「紧凑列表」形状的最小可玩实现(蓝本:open-dashboard 的 add-list-view + list-lite / list-lazy 两个模板)。
  复现的不变量:
  - 密度即卖点:紧凑行 = 首部头像 + 主行/次行两行文本 + 尾部状态与时间,没有表格骨架(无表头、无列线)
  - 空态、加载态显式处理:搜索无结果有空态行;「加载更多」与分区首次展开都先出骨架行
  - lazy 变体:按类型分组,分区首次展开才“取数”,取到后缓存——收起再展开即时,不重复取
  - 搜索/筛选放局部 state(蓝本捆绑 demo 同款);真实资源该状态进 URL
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .rowlist { background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
  .rowline, .sechead { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 12px;
                       border: 0; background: none; font: inherit; color: inherit;
                       text-align: left; cursor: pointer; }
  .rowline:hover, .sechead:hover { background: var(--bg); }
  .sechead { padding: 9px 12px; font-weight: 550; }
  .rowlist > * + *, .secbody > * { border-top: 1px solid var(--border); }
  .secbody .rowline { padding-left: 34px; }

  .avatar { flex: 0 0 30px; width: 30px; height: 30px; border-radius: 50%; display: flex;
            align-items: center; justify-content: center; font-size: 13px; font-weight: 600; }
  .av-deploy  { background: var(--chip-on-bg);   color: var(--chip-on-fg); }
  .av-alert   { background: var(--chip-warn-bg); color: var(--chip-warn-fg); }
  .av-approve { background: var(--primary);      color: var(--primary-fg); }
  .av-comment { background: var(--chip-off-bg);  color: var(--chip-off-fg); }

  .rowmain { flex: 1; min-width: 0; }
  .primary, .secondary { display: block; line-height: 1.4; white-space: nowrap;
                         overflow: hidden; text-overflow: ellipsis; }
  .primary { font-size: 13px; font-weight: 550; }
  .secondary { font-size: 12px; color: var(--muted); }
  .rowmeta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
  .time { width: 64px; text-align: right; color: var(--muted); font-size: 12px;
          font-variant-numeric: tabular-nums; }

  .caret { display: inline-block; font-size: 10px; color: var(--muted); transition: transform .15s; }
  .caret.open { transform: rotate(90deg); }
  .seccount { margin-left: auto; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }

  .skelrow { display: flex; align-items: center; gap: 10px; padding: 9px 12px; }
  .skelrow .skel { margin: 0; }
  .secbody .skelrow { padding-left: 34px; }

  .morebtn { width: 100%; padding: 9px 12px; border: 0; background: none; color: var(--primary);
             font: inherit; cursor: pointer; }
  .morebtn:hover { background: var(--bg); }
  .foot { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
</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, useMemo, useEffect } = React;
const html = htm.bind(React.createElement);

const PAGE = 8;   // 平铺模式首屏条数
const STEP = 6;   // 每次「加载更多」的条数
const TYPES = ["部署", "告警", "审批", "评论"];
const AV_CLASS = { "部署": "av-deploy", "告警": "av-alert", "审批": "av-approve", "评论": "av-comment" };
const CHIP = { "已处理": "chip-on", "待处理": "chip-warn", "已忽略": "chip-off" };

const FEED = [
  { id: 1,  type: "告警", actor: "监控",   title: "生产库 CPU 连续 5 分钟 > 90%",      detail: "pg-primary · 当前 93%",            time: "4 分钟前",  status: "待处理" },
  { id: 2,  type: "评论", actor: "郑舟",   title: "在《七月发布计划》中提到了你",       detail: "「@你 这段的风险评估补一下?」",    time: "8 分钟前",  status: "待处理" },
  { id: 3,  type: "审批", actor: "李慕白", title: "申请生产数据库只读权限",             detail: "有效期 7 天 · 用于故障排查",        time: "12 分钟前", status: "待处理" },
  { id: 4,  type: "部署", actor: "陈禾",   title: "api-server v2.4.1 发布到生产",       detail: "流水线 #482 · 全量 12 分钟",        time: "18 分钟前", status: "已处理" },
  { id: 5,  type: "部署", actor: "沈砚",   title: "web-console 灰度 20% → 50%",         detail: "流水线 #479 · 错误率 0.02%",        time: "26 分钟前", status: "待处理" },
  { id: 6,  type: "评论", actor: "冯霁",   title: "回复了你的评审意见",                 detail: "PR #1024 · 「已改成批量接口」",     time: "40 分钟前", status: "已处理" },
  { id: 7,  type: "告警", actor: "监控",   title: "订单接口 P99 延迟超阈值",            detail: "orders-api · 1.8s(阈值 1s)",      time: "1 小时前",  status: "待处理" },
  { id: 8,  type: "部署", actor: "韩澈",   title: "billing-service 回滚到 v1.9.0",      detail: "流水线 #475 · 触发原因:超时告警",  time: "2 小时前",  status: "已处理" },
  { id: 9,  type: "审批", actor: "王小满", title: "新增供应商「云杉科技」",             detail: "合同编号 HT-2026-114",              time: "3 小时前",  status: "待处理" },
  { id: 10, type: "审批", actor: "赵青",   title: "报销单 ¥2,380(差旅)",              detail: "6 月上海出差 · 附 5 张发票",        time: "昨天",      status: "已处理" },
  { id: 11, type: "告警", actor: "监控",   title: "对象存储用量达 80%",                 detail: "bucket:assets · 1.6 TB / 2 TB",     time: "昨天",      status: "已处理" },
  { id: 12, type: "评论", actor: "褚遂",   title: "在工单 #667 中 @ 了你",              detail: "「复现步骤见附件视频」",            time: "昨天",      status: "已处理" },
  { id: 13, type: "部署", actor: "陈禾",   title: "cron-worker v0.8.3 发布到预发",      detail: "流水线 #471 · 全量 4 分钟",         time: "昨天",      status: "已处理" },
  { id: 14, type: "审批", actor: "周芷",   title: "申请开通 CRM 管理员角色",            detail: "转岗销售运营 · 直属审批通过",       time: "2 天前",    status: "已处理" },
  { id: 15, type: "告警", actor: "监控",   title: "证书 30 天内到期",                   detail: "*.corp.cn · 2026-08-02 到期",       time: "2 天前",    status: "已忽略" },
  { id: 16, type: "部署", actor: "沈砚",   title: "search-api v3.1.0 发布失败",         detail: "流水线 #468 · 镜像构建出错",        time: "3 天前",    status: "已忽略" },
  { id: 17, type: "审批", actor: "吴限",   title: "延长测试环境保留期",                 detail: "env-feature-pay · +14 天",          time: "3 天前",    status: "已忽略" },
  { id: 18, type: "评论", actor: "卫疏",   title: "赞了你的复盘文档",                   detail: "《6·18 大促值班复盘》",             time: "4 天前",    status: "已忽略" },
];

// 一条紧凑行:首部头像(首字圆形,按类型着色)+ 主次两行 + 尾部状态与时间
function Row({ item, onOpen }) {
  return html`
    <button type="button" class="rowline" onClick=${() => onOpen(item)}>
      <span class=${"avatar " + AV_CLASS[item.type]}>${item.actor[0]}</span>
      <span class="rowmain">
        <span class="primary">${item.title}</span>
        <span class="secondary">${item.type} · ${item.actor} · ${item.detail}</span>
      </span>
      <span class="rowmeta">
        <span class=${"chip " + CHIP[item.status]}>${item.status}</span>
        <span class="time">${item.time}</span>
      </span>
    </button>`;
}

function SkelRow() {
  return html`
    <div class="skelrow" aria-hidden="true">
      <span class="skel" style=${{ flex: "0 0 30px", width: 30, height: 30, borderRadius: "50%" }}></span>
      <span style=${{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
        <span class="skel" style=${{ width: "45%", height: 12 }}></span>
        <span class="skel" style=${{ width: "70%", height: 10 }}></span>
      </span>
      <span class="skel" style=${{ width: 56, height: 18, borderRadius: 999 }}></span>
    </div>`;
}

function Detail({ item, onClose, onGo }) {
  return html`
    <div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onClose(); }}>
      <div class="modal">
        <h2>${item.title}</h2>
        <p style=${{ margin: 0, color: "var(--muted)", fontSize: 13 }}>
          ${item.type} · ${item.actor} · ${item.time}
        </p>
        <p style=${{ margin: 0 }}>${item.detail}</p>
        <p style=${{ margin: 0 }}>状态:<span class=${"chip " + CHIP[item.status]}>${item.status}</span></p>
        <div class="actions">
          <button class="btn" onClick=${onClose}>关闭</button>
          <button class="btn primary" onClick=${() => onGo(item)}>前往处理</button>
        </div>
      </div>
    </div>`;
}

function App() {
  const [mode, setMode] = useState("flat");     // flat = lite 变体;grouped = lazy 变体
  const [q, setQ] = useState("");
  const [visible, setVisible] = useState(PAGE); // 平铺模式已“取到”的条数
  const [loadingMore, setLoadingMore] = useState(false);
  const [secs, setSecs] = useState({});         // 分区状态:type -> { expanded, loading, loaded }
  const [detail, setDetail] = useState(null);
  const [toast, setToast] = useState("");

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

  const kw = q.trim().toLowerCase();
  const matched = useMemo(() =>
    FEED.filter(n => !kw || (n.title + n.actor + n.detail).toLowerCase().includes(kw)), [kw]);

  // lite 变体:有搜索词时本地全量过滤;无搜索词时增量显示 + 「加载更多」
  const flatRows = kw ? matched : FEED.slice(0, visible);
  const remain = FEED.length - visible;

  const loadMore = () => {
    setLoadingMore(true);
    setTimeout(() => {
      setVisible(v => Math.min(v + STEP, FEED.length));
      setLoadingMore(false);
    }, 550);
  };

  // lazy 变体:分区首次展开才“取数”,取到后缓存;再展开直接显示
  const toggleSec = type => {
    setSecs(prev => {
      const cur = prev[type] || { expanded: false, loading: false, loaded: false };
      if (cur.expanded) return { ...prev, [type]: { ...cur, expanded: false } };
      if (cur.loaded) return { ...prev, [type]: { ...cur, expanded: true } };
      setTimeout(() => {
        setSecs(p => ({ ...p, [type]: { ...p[type], loading: false, loaded: true } }));
      }, 600);
      return { ...prev, [type]: { expanded: true, loading: true, loaded: false } };
    });
  };

  const goHandle = item => {
    setDetail(null);
    setToast("已模拟跳转:" + item.type + " · " + item.title);
  };

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>团队动态</h1>
          <p class="sub">紧凑行:头像 + 主次两行 + 尾部状态/时间 · 点行看详情</p>
        </div>
        <div class="seg">
          <button class=${mode === "flat" ? "on" : ""} onClick=${() => setMode("flat")}>平铺</button>
          <button class=${mode === "grouped" ? "on" : ""} onClick=${() => setMode("grouped")}>按类型分组</button>
        </div>
      </header>

      ${mode === "flat" && html`
        <div>
          <div class="toolbar">
            <input class="grow" placeholder="搜索标题、人名或内容…" value=${q}
                   onChange=${e => setQ(e.target.value)} />
          </div>
          <div class="rowlist">
            ${flatRows.map(n => html`<${Row} key=${n.id} item=${n} onOpen=${setDetail} />`)}
            ${loadingMore && [1, 2, 3].map(i => html`<${SkelRow} key=${"s" + i} />`)}
            ${flatRows.length === 0 && html`<div class="empty">没有匹配「${q.trim()}」的动态</div>`}
            ${!kw && remain > 0 && !loadingMore && html`
              <button class="morebtn" onClick=${loadMore}>加载更多(还有 ${remain} 条)</button>`}
          </div>
          <p class="foot">
            ${kw ? "匹配 " + flatRows.length + " 条" : "已加载 " + flatRows.length + " / " + FEED.length + " 条"}
            · lite 变体:本地搜索过滤
          </p>
        </div>`}

      ${mode === "grouped" && html`
        <div>
          <div class="rowlist">
            ${TYPES.map(type => {
              const s = secs[type] || {};
              const items = FEED.filter(n => n.type === type);
              return html`
                <div key=${type}>
                  <button type="button" class="sechead" aria-expanded=${!!s.expanded}
                          onClick=${() => toggleSec(type)}>
                    <span class=${"caret" + (s.expanded ? " open" : "")}>▶</span>
                    ${type}
                    <span class="seccount">${items.length} 条</span>
                  </button>
                  ${s.expanded && html`
                    <div class="secbody">
                      ${s.loading && items.map(n => html`<${SkelRow} key=${"s" + n.id} />`)}
                      ${!s.loading && items.map(n => html`<${Row} key=${n.id} item=${n} onOpen=${setDetail} />`)}
                    </div>`}
                </div>`;
            })}
          </div>
          <p class="foot">lazy 变体分区首次展开才取数骨架屏  缓存再展开即时</p>
        </div>`}

      ${detail && html`<${Detail} item=${detail} onClose=${() => setDetail(null)} onGo=${goHandle} />`}
      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

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