Просмотр исходного кода

docs: specify viewport-fill list table scrolling

Align Fresh Mint §7.6 and OpenSpec ui-conventions with the confirmed UX; include design and plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 месяц назад
Родитель
Сommit
b8efcca1cf

+ 804 - 0
docs/list-table-scroll-demo.html

@@ -0,0 +1,804 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>列表滚动体验 Demo · Fresh Mint</title>
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #1e293b;
+      --text-muted: #64748b;
+      --border: #e2e8f0;
+      --surface: #fff;
+      --bg: #f1f5f9;
+      --table-head: #f8fafc;
+      --row-alt: #f8fafc;
+      --row-hover: #f0fdf4;
+      --radius: 10px;
+      --danger: #dc2626;
+      --warning: #d97706;
+      --shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
+      font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+    }
+
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      background: var(--bg);
+      color: var(--text);
+      font-size: 14px;
+      line-height: 1.5;
+    }
+
+    .page {
+      max-width: 1180px;
+      margin: 0 auto;
+      padding: 24px 20px 48px;
+    }
+
+    .hero {
+      background: linear-gradient(135deg, #ecfdf5 0%, #fff 55%, #f8fafc 100%);
+      border: 1px solid var(--border);
+      border-radius: 14px;
+      padding: 22px 24px;
+      margin-bottom: 18px;
+      box-shadow: var(--shadow);
+    }
+    .hero h1 {
+      margin: 0 0 8px;
+      font-size: 22px;
+      font-weight: 700;
+      color: var(--text);
+    }
+    .hero h1::before {
+      content: "";
+      display: inline-block;
+      width: 4px;
+      height: 1.05em;
+      margin-right: 10px;
+      border-radius: 2px;
+      background: var(--primary);
+      vertical-align: -0.12em;
+    }
+    .hero p {
+      margin: 0;
+      color: var(--text-muted);
+      max-width: 780px;
+    }
+    .problem-list {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-top: 14px;
+    }
+    .pill {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      padding: 4px 10px;
+      border-radius: 999px;
+      font-size: 12px;
+      font-weight: 600;
+      border: 1px solid transparent;
+    }
+    .pill.warn {
+      background: #fffbeb;
+      color: #b45309;
+      border-color: #fde68a;
+    }
+    .pill.ok {
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      border-color: #a7f3d0;
+    }
+
+    .tabs {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-bottom: 14px;
+    }
+    .tab {
+      appearance: none;
+      border: 1px solid var(--border);
+      background: var(--surface);
+      color: var(--text);
+      border-radius: 8px;
+      padding: 8px 14px;
+      font-size: 13px;
+      font-weight: 600;
+      cursor: pointer;
+      transition: 0.15s ease;
+    }
+    .tab:hover { border-color: #a7f3d0; background: var(--primary-bg); }
+    .tab.active {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    .tab .badge {
+      display: inline-block;
+      margin-left: 6px;
+      padding: 1px 6px;
+      border-radius: 999px;
+      font-size: 11px;
+      font-weight: 700;
+      background: rgba(255,255,255,0.22);
+    }
+    .tab:not(.active) .badge {
+      background: #fee2e2;
+      color: #b91c1c;
+    }
+    .tab:not(.active).recommend .badge {
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+    }
+
+    .panel {
+      background: var(--surface);
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      box-shadow: var(--shadow);
+      overflow: hidden;
+    }
+    .panel-top {
+      display: flex;
+      align-items: flex-start;
+      justify-content: space-between;
+      gap: 16px;
+      padding: 14px 18px;
+      border-bottom: 1px solid var(--border);
+      background: var(--table-head);
+    }
+    .panel-top h2 {
+      margin: 0 0 4px;
+      font-size: 16px;
+      font-weight: 700;
+    }
+    .panel-top .desc {
+      margin: 0;
+      color: var(--text-muted);
+      font-size: 13px;
+      max-width: 720px;
+    }
+    .meta {
+      flex-shrink: 0;
+      font-size: 12px;
+      color: var(--text-muted);
+      text-align: right;
+    }
+    .meta strong { color: var(--primary-dark); }
+
+    .callout {
+      margin: 0;
+      padding: 10px 18px;
+      font-size: 13px;
+      border-bottom: 1px solid var(--border);
+    }
+    .callout.bad { background: #fef2f2; color: #991b1b; }
+    .callout.good { background: var(--primary-bg); color: #065f46; }
+    .callout code {
+      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      font-size: 12px;
+      background: rgba(0,0,0,0.05);
+      padding: 1px 5px;
+      border-radius: 4px;
+    }
+
+    .toolbar {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+      padding: 8px 16px;
+      background: var(--table-head);
+      border-bottom: 1px solid var(--border);
+    }
+    .sel {
+      font-size: 12px;
+      font-weight: 600;
+      color: var(--text-muted);
+      padding-right: 10px;
+      margin-right: 4px;
+      border-right: 1px solid var(--border);
+      min-height: 36px;
+      display: inline-flex;
+      align-items: center;
+    }
+    .sel b { color: var(--primary-dark); margin: 0 2px; }
+    .btn {
+      height: 36px;
+      padding: 0 14px;
+      border-radius: 8px;
+      border: 1px solid #d9e8e2;
+      background: #fff;
+      color: var(--text);
+      font-size: 13px;
+      font-weight: 600;
+      cursor: default;
+    }
+    .btn.main {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+
+    .viewport {
+      /* 模拟管理端内容区可视高度 */
+      height: min(420px, 58vh);
+      overflow: auto;
+      background: #fff;
+      position: relative;
+    }
+    .viewport-label {
+      position: sticky;
+      top: 0;
+      z-index: 20;
+      pointer-events: none;
+      display: flex;
+      justify-content: flex-end;
+      padding: 6px 10px 0 0;
+      height: 0;
+    }
+    .viewport-label span {
+      pointer-events: none;
+      font-size: 11px;
+      font-weight: 700;
+      color: #0369a1;
+      background: #e0f2fe;
+      border: 1px solid #7dd3fc;
+      border-radius: 999px;
+      padding: 2px 8px;
+    }
+
+    table.data {
+      width: max-content;
+      min-width: 1400px;
+      border-collapse: collapse;
+      font-size: 13px;
+    }
+    table.data th,
+    table.data td {
+      padding: 10px 14px;
+      border-bottom: 1px solid var(--border);
+      text-align: center;
+      white-space: nowrap;
+    }
+    table.data th {
+      font-size: 12px;
+      font-weight: 600;
+      color: var(--text-muted);
+      background: var(--table-head);
+    }
+    table.data tbody tr:nth-child(even) td { background: var(--row-alt); }
+    table.data tbody tr:hover td { background: var(--row-hover); }
+    table.data .left { text-align: left; }
+    table.data .op { min-width: 180px; }
+    .link { color: var(--primary-dark); font-weight: 600; text-decoration: none; }
+    .tag {
+      display: inline-block;
+      padding: 2px 8px;
+      border-radius: 6px;
+      font-size: 12px;
+      font-weight: 600;
+      color: #fff;
+      background: var(--primary-dark);
+    }
+    .tag.warn { background: var(--warning); }
+
+    .pager {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 16px;
+      border-top: 1px solid var(--border);
+      color: var(--text-muted);
+      font-size: 13px;
+      background: #fff;
+    }
+
+    /* —— 方案 A:表体内嵌滚动(推荐) —— */
+    .scheme-a .table-frame {
+      height: 320px;
+      overflow: auto;
+      border-top: 1px solid var(--border);
+    }
+    .scheme-a thead th {
+      position: sticky;
+      top: 0;
+      z-index: 3;
+      box-shadow: 0 1px 0 rgba(0,0,0,0.06);
+    }
+    .scheme-a .th-check,
+    .scheme-a .td-check {
+      position: sticky;
+      left: 0;
+      z-index: 2;
+      background: #fff;
+      box-shadow: 6px 0 10px -6px rgba(0,0,0,0.12);
+    }
+    .scheme-a thead .th-check {
+      z-index: 6;
+      background: var(--table-head);
+    }
+    .scheme-a tbody tr:nth-child(even) .td-check { background: var(--row-alt); }
+    .scheme-a tbody tr:hover .td-check { background: var(--row-hover); }
+    .scheme-a .th-op,
+    .scheme-a .td-op {
+      position: sticky;
+      right: 0;
+      z-index: 2;
+      background: #fff;
+      box-shadow: -6px 0 10px -6px rgba(0,0,0,0.12);
+    }
+    .scheme-a thead .th-op {
+      z-index: 5;
+      background: var(--table-head);
+    }
+    .scheme-a tbody tr:nth-child(even) .td-op { background: var(--row-alt); }
+    .scheme-a tbody tr:hover .td-op { background: var(--row-hover); }
+
+    /* —— 方案 B:页面滚动 + 表头 sticky + 底部悬浮横滚条 —— */
+    .scheme-b .page-scroll {
+      height: 320px;
+      overflow: auto;
+      border-top: 1px solid var(--border);
+      position: relative;
+    }
+    .scheme-b thead th {
+      position: sticky;
+      top: 0;
+      z-index: 3;
+      box-shadow: 0 1px 0 rgba(0,0,0,0.06);
+    }
+    .scheme-b .float-hbar-wrap {
+      position: sticky;
+      bottom: 0;
+      z-index: 8;
+      height: 14px;
+      background: rgba(248, 250, 252, 0.92);
+      border-top: 1px solid var(--border);
+      backdrop-filter: blur(2px);
+    }
+    .scheme-b .float-hbar {
+      overflow-x: auto;
+      overflow-y: hidden;
+      height: 14px;
+    }
+    .scheme-b .float-hbar-inner {
+      height: 1px;
+    }
+
+    /* —— 方案 C:顶部镜像横滚条 + 表体内嵌滚动 —— */
+    .scheme-c .top-hbar {
+      overflow-x: auto;
+      overflow-y: hidden;
+      height: 14px;
+      border-top: 1px solid var(--border);
+      background: var(--table-head);
+    }
+    .scheme-c .top-hbar-inner { height: 1px; }
+    .scheme-c .table-frame {
+      height: 306px;
+      overflow: auto;
+    }
+    .scheme-c thead th {
+      position: sticky;
+      top: 0;
+      z-index: 3;
+      box-shadow: 0 1px 0 rgba(0,0,0,0.06);
+    }
+
+    .hint-row {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 10px;
+      margin-top: 14px;
+    }
+    @media (max-width: 900px) {
+      .hint-row { grid-template-columns: 1fr; }
+    }
+    .hint-card {
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 12px 14px;
+      box-shadow: var(--shadow);
+    }
+    .hint-card h3 {
+      margin: 0 0 6px;
+      font-size: 13px;
+      font-weight: 700;
+    }
+    .hint-card p {
+      margin: 0;
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+    .hidden { display: none !important; }
+
+    .choose {
+      margin-top: 18px;
+      padding: 16px 18px;
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 12px;
+    }
+    .choose h3 { margin: 0 0 10px; font-size: 15px; }
+    .choose-options {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 10px;
+    }
+    @media (max-width: 900px) {
+      .choose-options { grid-template-columns: 1fr; }
+    }
+    .choose-opt {
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 12px;
+      cursor: pointer;
+      transition: 0.15s ease;
+      background: #fff;
+    }
+    .choose-opt:hover { border-color: #a7f3d0; background: var(--primary-bg); }
+    .choose-opt.selected {
+      border-color: var(--primary);
+      box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
+      background: var(--primary-bg);
+    }
+    .choose-opt .letter {
+      display: inline-flex;
+      width: 22px;
+      height: 22px;
+      align-items: center;
+      justify-content: center;
+      border-radius: 6px;
+      background: var(--primary);
+      color: #fff;
+      font-size: 12px;
+      font-weight: 700;
+      margin-right: 6px;
+    }
+    .choose-opt strong { font-size: 13px; }
+    .choose-opt p {
+      margin: 8px 0 0;
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+  </style>
+</head>
+<body>
+  <div class="page">
+    <div class="hero">
+      <h1>列表横向 / 纵向滚动体验 Demo</h1>
+      <p>
+        对齐 web-admin Fresh Mint。用下方 Tab 切换「现状问题」与三种改法,
+        请在灰色模拟视口内<strong>纵向滚动到中部</strong>、再试<strong>横向滚动</strong>,感受差异。
+      </p>
+      <div class="problem-list">
+        <span class="pill warn">问题 1:横滚条在表最底部,行多时够不着</span>
+        <span class="pill warn">问题 2:纵滚到中部后看不到表头</span>
+        <span class="pill ok">目标:横滚条常在可视区底部 · 表头始终可见</span>
+      </div>
+    </div>
+
+    <div class="tabs" role="tablist">
+      <button class="tab active" data-tab="now" type="button">现状(问题复现)<span class="badge">Bug</span></button>
+      <button class="tab recommend" data-tab="a" type="button">方案 A · 表体内嵌滚动<span class="badge">推荐</span></button>
+      <button class="tab" data-tab="b" type="button">方案 B · 表头 sticky + 悬浮横条</button>
+      <button class="tab" data-tab="c" type="button">方案 C · 顶部镜像横条</button>
+    </div>
+
+    <!-- 现状 -->
+    <section class="panel scheme-now" data-panel="now">
+      <div class="panel-top">
+        <div>
+          <h2>现状:整页拉长,表跟着页面滚</h2>
+          <p class="desc">与多数业务列表一致:表格无固定高度,横向滚动发生在整张表底部。</p>
+        </div>
+        <div class="meta">模拟视口<br /><strong>请先向下滚半屏</strong></div>
+      </div>
+      <p class="callout bad">
+        复现:① 纵向滚到行 20+ → 表头消失;② 想横看「操作」列 → 必须滚到最底才能碰到横滚条。
+      </p>
+      <div class="toolbar">
+        <span class="sel">已选 <b>0</b> 项</span>
+        <button class="btn main" type="button">新增</button>
+        <button class="btn" type="button">批量导出</button>
+      </div>
+      <div class="viewport" id="vp-now">
+        <div class="viewport-label"><span>内容区可视范围(向下滚)</span></div>
+        <div class="table-scroll-full" style="overflow-x:auto;">
+          <table class="data" id="table-now"></table>
+        </div>
+        <div class="pager"><span>共 60 条</span><span>1 / 3 页(示意)</span></div>
+      </div>
+    </section>
+
+    <!-- 方案 A -->
+    <section class="panel scheme-a hidden" data-panel="a">
+      <div class="panel-top">
+        <div>
+          <h2>方案 A:表格视口填满高度(内嵌滚动)</h2>
+          <p class="desc">
+            对齐规范:动态 <code>height</code> 使表体尽量撑满,
+            <strong>分页始终可见且下方留 10px</strong>;
+            纵滚在表体内,表头 sticky,横滚条贴表体可视区底。
+          </p>
+        </div>
+        <div class="meta">实现成本低<br /><strong>与 EP 官方行为一致</strong></div>
+      </div>
+      <p class="callout good">
+        一次解决两个问题:表头始终在表体顶部;横滚条始终在表体可视区底部(不必滚到第 60 行);分页下约 10px。
+      </p>
+      <div class="toolbar">
+        <span class="sel">已选 <b>0</b> 项</span>
+        <button class="btn main" type="button">新增</button>
+        <button class="btn" type="button">批量导出</button>
+      </div>
+      <div class="viewport" style="height:auto; overflow:visible;">
+        <div class="table-frame" id="vp-a">
+          <table class="data" id="table-a"></table>
+        </div>
+        <div class="pager"><span>共 60 条 · 分页固定在表外</span><span>1 / 3 页</span></div>
+      </div>
+    </section>
+
+    <!-- 方案 B -->
+    <section class="panel scheme-b hidden" data-panel="b">
+      <div class="panel-top">
+        <div>
+          <h2>方案 B:保留整页滚动 + sticky 表头 + 底部悬浮横滚条</h2>
+          <p class="desc">
+            表仍可随页面变高;用 sticky 钉住表头;在滚动容器底部再放一条「镜像」横滚条,始终可见。
+          </p>
+        </div>
+        <div class="meta">交互灵活<br /><strong>实现与同步成本更高</strong></div>
+      </div>
+      <p class="callout good">
+        适合「不想裁切表体高度、希望一屏看更多行」的场景;需维护真实横滚与悬浮条 scrollLeft 双向同步。
+      </p>
+      <div class="toolbar">
+        <span class="sel">已选 <b>0</b> 项</span>
+        <button class="btn main" type="button">新增</button>
+        <button class="btn" type="button">批量导出</button>
+      </div>
+      <div class="viewport" style="height:auto; overflow:visible;">
+        <div class="page-scroll" id="vp-b">
+          <div class="table-x" id="b-table-x" style="overflow-x:auto;">
+            <table class="data" id="table-b"></table>
+          </div>
+          <div class="float-hbar-wrap">
+            <div class="float-hbar" id="b-float-hbar">
+              <div class="float-hbar-inner" id="b-float-inner"></div>
+            </div>
+          </div>
+        </div>
+        <div class="pager"><span>共 60 条</span><span>试拖底部细条或表内横滚</span></div>
+      </div>
+    </section>
+
+    <!-- 方案 C -->
+    <section class="panel scheme-c hidden" data-panel="c">
+      <div class="panel-top">
+        <div>
+          <h2>方案 C:表体内嵌滚动 + 顶部镜像横滚条</h2>
+          <p class="desc">
+            在方案 A 基础上,表头上方再放一条横滚条:人眼在看标题附近时也能横移,无需移到表底。
+          </p>
+        </div>
+        <div class="meta">A 的增强<br /><strong>双条同步,略占高度</strong></div>
+      </div>
+      <p class="callout good">
+        横滚可达性最好(顶/底都能拖)。若采用 A,可按「列特别多」的页面再叠加顶部条。
+      </p>
+      <div class="toolbar">
+        <span class="sel">已选 <b>0</b> 项</span>
+        <button class="btn main" type="button">新增</button>
+        <button class="btn" type="button">批量导出</button>
+      </div>
+      <div class="viewport" style="height:auto; overflow:visible;">
+        <div class="top-hbar" id="c-top-hbar">
+          <div class="top-hbar-inner" id="c-top-inner"></div>
+        </div>
+        <div class="table-frame" id="vp-c">
+          <table class="data" id="table-c"></table>
+        </div>
+        <div class="pager"><span>共 60 条</span><span>顶条与表体横滚已同步</span></div>
+      </div>
+    </section>
+
+    <div class="hint-row">
+      <div class="hint-card">
+        <h3>与现有代码的关系</h3>
+        <p>
+          规范 §7.6 已写 <code>max-height + overflow</code>;HTML demo 也有 sticky 表头。
+          业务页多数未给 <code>el-table</code> 设高度,bx 的 <code>.table-scroll</code> 仅 <code>overflow-x</code>,故问题仍在。
+        </p>
+      </div>
+      <div class="hint-card">
+        <h3>推荐落地路径</h3>
+        <p>
+          优先方案 A:公共列表用 <code>useListTableViewportHeight</code> 填满视口(分页下 10px),分页留在表外。
+          列极多的页可再加方案 C 顶条。
+        </p>
+      </div>
+      <div class="hint-card">
+        <h3>注意点</h3>
+        <p>
+          固定高度后筛选区展开会挤表体,需用「视口剩余高度」动态计算;
+          展开行 / 树表要回归测 <code>doLayout</code>。
+        </p>
+      </div>
+    </div>
+
+    <div class="choose">
+      <h3>看完后请选择倾向方案(可点选,并在对话里告诉我)</h3>
+      <div class="choose-options">
+        <div class="choose-opt" data-choice="a" tabindex="0">
+          <span class="letter">A</span><strong>表体内嵌滚动</strong>
+          <p>成本最低,一次修两个问题;与 Element Plus / 规范最贴合。</p>
+        </div>
+        <div class="choose-opt" data-choice="b" tabindex="0">
+          <span class="letter">B</span><strong>sticky + 悬浮横条</strong>
+          <p>保留「整表随页面变高」;同步逻辑与兼容成本更高。</p>
+        </div>
+        <div class="choose-opt" data-choice="c" tabindex="0">
+          <span class="letter">C</span><strong>A + 顶部镜像条</strong>
+          <p>在 A 上增强横滚可达性;适合列特别宽的列表。</p>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    const COLS = [
+      { key: "check", label: "", cls: "th-check" },
+      { key: "idx", label: "序号" },
+      { key: "code", label: "单据编号", left: true },
+      { key: "name", label: "名称", left: true },
+      { key: "customer", label: "客户" },
+      { key: "region", label: "区域路径" },
+      { key: "amount", label: "金额" },
+      { key: "status", label: "状态" },
+      { key: "owner", label: "负责人" },
+      { key: "dept", label: "所属部门" },
+      { key: "created", label: "创建时间" },
+      { key: "updated", label: "更新时间" },
+      { key: "remark", label: "备注", left: true },
+      { key: "op", label: "操作", cls: "th-op op" }
+    ];
+
+    function buildTable(tableEl, rows) {
+      const thead = document.createElement("thead");
+      const hr = document.createElement("tr");
+      COLS.forEach((c) => {
+        const th = document.createElement("th");
+        th.textContent = c.label;
+        if (c.cls) th.className = c.cls;
+        if (c.left) th.classList.add("left");
+        hr.appendChild(th);
+      });
+      thead.appendChild(hr);
+
+      const tbody = document.createElement("tbody");
+      for (let i = 1; i <= rows; i++) {
+        const tr = document.createElement("tr");
+        const cells = [
+          { html: '<input type="checkbox" />', cls: "td-check" },
+          { text: String(i) },
+          { html: `<a class="link" href="#">SO-2026-${String(i).padStart(4, "0")}</a>`, left: true },
+          { text: `示例业务名称 ${i}`, left: true },
+          { text: i % 3 === 0 ? "华东化工" : i % 3 === 1 ? "山峡贸易" : "三墙供应链" },
+          { text: "华东 / 上海 / 浦东" },
+          { text: (1200 + i * 37).toLocaleString("zh-CN") },
+          { html: i % 5 === 0 ? '<span class="tag warn">待处理</span>' : '<span class="tag">进行中</span>' },
+          { text: i % 2 ? "张三" : "李四" },
+          { text: "销售一部" },
+          { text: "2026-08-01 09:12" },
+          { text: "2026-09-01 18:00" },
+          { text: i % 4 === 0 ? "需要横向滚动才能看到本列与操作列" : "—", left: true },
+          { html: '<a class="link" href="#">查看</a> · 编辑', cls: "td-op op" }
+        ];
+        cells.forEach((cell) => {
+          const td = document.createElement("td");
+          if (cell.html) td.innerHTML = cell.html;
+          else td.textContent = cell.text;
+          if (cell.cls) td.className = cell.cls;
+          if (cell.left) td.classList.add("left");
+          tr.appendChild(td);
+        });
+        tbody.appendChild(tr);
+      }
+
+      tableEl.innerHTML = "";
+      tableEl.appendChild(thead);
+      tableEl.appendChild(tbody);
+    }
+
+    ["table-now", "table-a", "table-b", "table-c"].forEach((id) => {
+      buildTable(document.getElementById(id), 60);
+    });
+
+    // Tabs
+    const tabs = document.querySelectorAll(".tab");
+    const panels = document.querySelectorAll("[data-panel]");
+    tabs.forEach((tab) => {
+      tab.addEventListener("click", () => {
+        tabs.forEach((t) => t.classList.remove("active"));
+        tab.classList.add("active");
+        const key = tab.dataset.tab;
+        panels.forEach((p) => {
+          p.classList.toggle("hidden", p.dataset.panel !== key);
+        });
+        if (key === "b") syncB();
+        if (key === "c") syncC();
+      });
+    });
+
+    // Choice
+    document.querySelectorAll(".choose-opt").forEach((el) => {
+      el.addEventListener("click", () => {
+        document.querySelectorAll(".choose-opt").forEach((x) => x.classList.remove("selected"));
+        el.classList.add("selected");
+      });
+    });
+
+    // Scheme B sync
+    function syncB() {
+      const x = document.getElementById("b-table-x");
+      const bar = document.getElementById("b-float-hbar");
+      const inner = document.getElementById("b-float-inner");
+      const table = document.getElementById("table-b");
+      inner.style.width = table.scrollWidth + "px";
+      let lock = false;
+      const bind = (from, to) => {
+        from.onscroll = () => {
+          if (lock) return;
+          lock = true;
+          to.scrollLeft = from.scrollLeft;
+          lock = false;
+        };
+      };
+      bind(x, bar);
+      bind(bar, x);
+    }
+
+    // Scheme C sync
+    function syncC() {
+      const frame = document.getElementById("vp-c");
+      const top = document.getElementById("c-top-hbar");
+      const inner = document.getElementById("c-top-inner");
+      const table = document.getElementById("table-c");
+      inner.style.width = table.scrollWidth + "px";
+      let lock = false;
+      frame.onscroll = () => {
+        if (lock) return;
+        lock = true;
+        top.scrollLeft = frame.scrollLeft;
+        lock = false;
+      };
+      top.onscroll = () => {
+        if (lock) return;
+        lock = true;
+        frame.scrollLeft = top.scrollLeft;
+        lock = false;
+      };
+    }
+
+    // init sync widths after paint
+    requestAnimationFrame(() => {
+      syncB();
+      syncC();
+    });
+    window.addEventListener("resize", () => {
+      syncB();
+      syncC();
+    });
+  </script>
+</body>
+</html>

+ 650 - 0
docs/superpowers/plans/2026-09-01-web-admin-list-table-viewport-scroll.md

@@ -0,0 +1,650 @@
+# web-admin 列表表体内嵌滚动(视口填满)Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 web-admin 主列表在视口内表体内嵌滚动:表头固定、横滚条贴在表体可视区底,分页始终可见且距内容区底 10px。
+
+**Architecture:** 抽出纯函数 `calcListTableViewportHeight`;composable `useListTableViewportHeight` 测量 `#app-content` 底、表顶、分页高并绑定 `ListTable`/`el-table` 的 `:height`;先接模板页与 bx 样板,再文档/OpenSpec,最后按模块批推。
+
+**Tech Stack:** Vue 3 · `@vueuse/core` · Element Plus `el-table` height · TypeScript · `tsx` 脚本单测
+
+**Spec:** `docs/superpowers/specs/2026-09-01-web-admin-list-table-viewport-scroll-design.md`
+
+**Demo:** `docs/list-table-scroll-demo.html`
+
+---
+
+## File map
+
+| File | Responsibility |
+|------|----------------|
+| `web-admin/src/composables/listTableViewportHeight.ts` | 纯函数:高度公式 + 默认常量 |
+| `web-admin/scripts/check-list-table-viewport-height.ts` | 无浏览器依赖的公式断言(tsx) |
+| `web-admin/src/composables/useListTableViewportHeight.ts` | 测量 DOM、resize/观察、返回 `tableHeight` + `recalc` |
+| `web-admin/src/views/demo/unit/index.vue` | 模板样板页接入 |
+| `web-admin/src/views/bx/reimburse/apply/index.vue` | ListTable + wrap 样板接入 |
+| `web-admin/src/styles/bx/index.scss` | 澄清 `.table-scroll` 与 EP height 模式不双滚 |
+| `docs/web-admin-ui-spec-fresh-mint.md` §7.6 | 规范改为视口填满策略 |
+| `docs/list-table-scroll-demo.html` | Demo 文案对齐「分页下 10px」 |
+| `openspec/.../ui-conventions/spec.md` | 新增 SHALL |
+| 后续批处理列表页 | 同构接入(Task 6 清单) |
+
+---
+
+### Task 1: 纯函数高度公式 + 脚本单测
+
+**Files:**
+- Create: `web-admin/src/composables/listTableViewportHeight.ts`
+- Create: `web-admin/scripts/check-list-table-viewport-height.ts`
+
+- [ ] **Step 1: 写失败断言脚本(文件尚不存在时应无法通过)**
+
+创建 `web-admin/scripts/check-list-table-viewport-height.ts`:
+
+```ts
+import assert from 'node:assert/strict'
+import {
+  LIST_TABLE_VIEWPORT_BOTTOM_GAP,
+  LIST_TABLE_VIEWPORT_MIN_HEIGHT,
+  calcListTableViewportHeight
+} from '../src/composables/listTableViewportHeight'
+
+assert.equal(LIST_TABLE_VIEWPORT_BOTTOM_GAP, 10)
+assert.equal(LIST_TABLE_VIEWPORT_MIN_HEIGHT, 240)
+
+// contentBottom=1000, tableTop=200, pager=48 → 1000-200-48-10 = 742
+assert.equal(
+  calcListTableViewportHeight({
+    contentViewportBottom: 1000,
+    tableTop: 200,
+    pagerHeight: 48
+  }),
+  742
+)
+
+// 无分页
+assert.equal(
+  calcListTableViewportHeight({
+    contentViewportBottom: 800,
+    tableTop: 100,
+    pagerHeight: 0
+  }),
+  690
+)
+
+// 过矮时夹到 minHeight
+assert.equal(
+  calcListTableViewportHeight({
+    contentViewportBottom: 400,
+    tableTop: 300,
+    pagerHeight: 48,
+    minHeight: 240,
+    bottomGap: 10
+  }),
+  240
+)
+
+// 自定义 min / gap
+assert.equal(
+  calcListTableViewportHeight({
+    contentViewportBottom: 1000,
+    tableTop: 100,
+    pagerHeight: 40,
+    minHeight: 100,
+    bottomGap: 16
+  }),
+  844
+)
+
+console.log('check-list-table-viewport-height: ok')
+```
+
+- [ ] **Step 2: 跑脚本确认失败**
+
+Run:
+
+```bash
+cd web-admin && pnpm exec tsx scripts/check-list-table-viewport-height.ts
+```
+
+Expected: 失败(模块不存在 / 无法解析)
+
+- [ ] **Step 3: 实现纯函数**
+
+创建 `web-admin/src/composables/listTableViewportHeight.ts`:
+
+```ts
+/** 分页底边到内容区底的默认空隙(px) */
+export const LIST_TABLE_VIEWPORT_BOTTOM_GAP = 10
+
+/** 表体动态高度下限(px),避免筛选展开时表被压没 */
+export const LIST_TABLE_VIEWPORT_MIN_HEIGHT = 240
+
+export type CalcListTableViewportHeightInput = {
+  /** 主内容滚动容器可视底边(如 #app-content.getBoundingClientRect().bottom) */
+  contentViewportBottom: number
+  /** 表格锚点顶边(同坐标系,通常 getBoundingClientRect().top) */
+  tableTop: number
+  /** 分页条实际高度;无分页传 0 */
+  pagerHeight: number
+  minHeight?: number
+  bottomGap?: number
+}
+
+/**
+ * 计算列表表体应设置的 height,使「表 + 分页」落在内容视口内,且分页下方留 bottomGap。
+ * tableHeight = floor(contentBottom - tableTop - pagerHeight - bottomGap),再与 minHeight 取大。
+ */
+export function calcListTableViewportHeight(
+  input: CalcListTableViewportHeightInput
+): number {
+  const minHeight = input.minHeight ?? LIST_TABLE_VIEWPORT_MIN_HEIGHT
+  const bottomGap = input.bottomGap ?? LIST_TABLE_VIEWPORT_BOTTOM_GAP
+  const raw = Math.floor(
+    input.contentViewportBottom - input.tableTop - input.pagerHeight - bottomGap
+  )
+  if (!Number.isFinite(raw)) return minHeight
+  return Math.max(raw, minHeight)
+}
+```
+
+- [ ] **Step 4: 再跑脚本确认通过**
+
+Run:
+
+```bash
+cd web-admin && pnpm exec tsx scripts/check-list-table-viewport-height.ts
+```
+
+Expected: 打印 `check-list-table-viewport-height: ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/composables/listTableViewportHeight.ts \
+  web-admin/scripts/check-list-table-viewport-height.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add list table viewport height formula
+
+Pure calc keeps pagination visible with a 10px bottom gap for main list scroll UX.
+EOF
+)"
+```
+
+---
+
+### Task 2: composable `useListTableViewportHeight`
+
+**Files:**
+- Create: `web-admin/src/composables/useListTableViewportHeight.ts`
+
+- [ ] **Step 1: 实现 composable**
+
+创建 `web-admin/src/composables/useListTableViewportHeight.ts`:
+
+```ts
+import {
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  unref,
+  watch,
+  type Ref
+} from 'vue'
+import { useEventListener, useResizeObserver } from '@vueuse/core'
+import {
+  LIST_TABLE_VIEWPORT_BOTTOM_GAP,
+  LIST_TABLE_VIEWPORT_MIN_HEIGHT,
+  calcListTableViewportHeight
+} from '@/composables/listTableViewportHeight'
+
+export type UseListTableViewportHeightOptions = {
+  /** 表格锚点(包住 ListTable/el-table 的元素,不含分页) */
+  tableEl: Ref<HTMLElement | null | undefined> | (() => HTMLElement | null | undefined)
+  /** 分页条根节点;无分页可省略 */
+  pagerEl?: Ref<HTMLElement | null | undefined> | (() => HTMLElement | null | undefined)
+  /** 可选:ListTable / el-table 组件实例,高度变化后调用 doLayout */
+  tableRef?: Ref<any>
+  minHeight?: number
+  bottomGap?: number
+  /** 主内容滚动根;默认 #app-content */
+  scrollRootSelector?: string
+  /** 为 false 时不自动监听(隐藏 Tab 可用) */
+  enabled?: Ref<boolean> | boolean
+}
+
+function resolveEl(
+  source?: Ref<HTMLElement | null | undefined> | (() => HTMLElement | null | undefined)
+): HTMLElement | null {
+  if (!source) return null
+  if (typeof source === 'function') return source() ?? null
+  return unref(source) ?? null
+}
+
+export function useListTableViewportHeight(options: UseListTableViewportHeightOptions) {
+  const tableHeight = ref(LIST_TABLE_VIEWPORT_MIN_HEIGHT)
+  const minHeight = options.minHeight ?? LIST_TABLE_VIEWPORT_MIN_HEIGHT
+  const bottomGap = options.bottomGap ?? LIST_TABLE_VIEWPORT_BOTTOM_GAP
+  const scrollRootSelector = options.scrollRootSelector ?? '#app-content'
+
+  const isEnabled = () => {
+    const e = options.enabled
+    if (e === undefined) return true
+    return !!unref(e)
+  }
+
+  const recalc = () => {
+    if (!isEnabled()) return
+    if (typeof window === 'undefined') return
+
+    const table = resolveEl(options.tableEl)
+    if (!table) return
+
+    const root =
+      (document.querySelector(scrollRootSelector) as HTMLElement | null) ?? null
+    const contentViewportBottom = root
+      ? root.getBoundingClientRect().bottom
+      : window.innerHeight
+
+    const tableTop = table.getBoundingClientRect().top
+    const pager = resolveEl(options.pagerEl)
+    const pagerHeight = pager ? Math.ceil(pager.getBoundingClientRect().height) : 0
+
+    const next = calcListTableViewportHeight({
+      contentViewportBottom,
+      tableTop,
+      pagerHeight,
+      minHeight,
+      bottomGap
+    })
+
+    if (next !== tableHeight.value) {
+      tableHeight.value = next
+    }
+
+    nextTick(() => {
+      const inst = options.tableRef ? unref(options.tableRef) : null
+      if (inst && typeof inst.doLayout === 'function') {
+        inst.doLayout()
+      }
+    })
+  }
+
+  let raf = 0
+  const scheduleRecalc = () => {
+    if (raf) cancelAnimationFrame(raf)
+    raf = requestAnimationFrame(() => {
+      raf = 0
+      recalc()
+    })
+  }
+
+  onMounted(() => {
+    nextTick(scheduleRecalc)
+  })
+
+  onBeforeUnmount(() => {
+    if (raf) cancelAnimationFrame(raf)
+  })
+
+  useEventListener(window, 'resize', scheduleRecalc)
+
+  // 观察表锚点与分页尺寸变化(筛选展开会改变 tableTop)
+  useResizeObserver(
+    () => resolveEl(options.tableEl),
+    () => scheduleRecalc()
+  )
+  useResizeObserver(
+    () => resolveEl(options.pagerEl),
+    () => scheduleRecalc()
+  )
+  useResizeObserver(
+    () => document.querySelector(scrollRootSelector) as HTMLElement | null,
+    () => scheduleRecalc()
+  )
+
+  if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
+    watch(options.enabled, () => scheduleRecalc())
+  }
+
+  return { tableHeight, recalc: scheduleRecalc }
+}
+```
+
+- [ ] **Step 2: 类型检查(抽查无报错)**
+
+Run:
+
+```bash
+cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -n 40
+```
+
+Expected: 与本次新文件相关无新增错误(若仓库已有历史错误,确认不含 `useListTableViewportHeight` / `listTableViewportHeight`)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/composables/useListTableViewportHeight.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add useListTableViewportHeight composable
+
+Measure content viewport, table top, and pager to drive el-table height.
+EOF
+)"
+```
+
+---
+
+### Task 3: 接入模板样板页 `demo/unit`
+
+**Files:**
+- Modify: `web-admin/src/views/demo/unit/index.vue`
+
+- [ ] **Step 1: 模板增加锚点 ref,绑定 height**
+
+在 `list-body` 内,用 wrap 只包住表格(分页保持为兄弟节点)。将:
+
+```vue
+      <div class="list-body list-wrap">
+        <ListTable table-key="demo-unit"
+          class="list-table"
+          :data="tableData"
+```
+
+改为:
+
+```vue
+      <div class="list-body list-wrap">
+        <div ref="unitTableWrapRef" class="list-table-viewport-anchor">
+          <ListTable
+            ref="unitTableRef"
+            table-key="demo-unit"
+            class="list-table"
+            :height="tableHeight"
+            :data="tableData"
+```
+
+在 `ListTable` 闭合标签后、分页前闭合 wrap:
+
+```vue
+        </ListTable>
+        </div>
+        <div ref="unitPagerRef" class="pagination list-pager-bar">
+```
+
+(原分页 `class="pagination list-pager-bar"` 增加 `ref="unitPagerRef"`。)
+
+- [ ] **Step 2: script 接入 composable**
+
+在 `<script setup>` 中增加(保持现有 import 风格,路径用 `@/composables/...`):
+
+```ts
+import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
+
+const unitTableWrapRef = ref<HTMLElement | null>(null)
+const unitPagerRef = ref<HTMLElement | null>(null)
+const unitTableRef = ref<any>(null)
+
+const { tableHeight, recalc: recalcTableHeight } = useListTableViewportHeight({
+  tableEl: unitTableWrapRef,
+  pagerEl: unitPagerRef,
+  tableRef: unitTableRef
+})
+```
+
+若该页有筛选重置/搜索导致布局变化,在 `handleSearch` / `handleReset` / `loadData` 完成后 `nextTick(() => recalcTableHeight())`(可选但推荐至少在 `loadData` 结束 `finally` 里调一次)。
+
+- [ ] **Step 3: 手动验收(dev)**
+
+Run:
+
+```bash
+cd web-admin && pnpm dev
+```
+
+打开 `/#/demo/unit`(或侧栏 Demo → 单位):
+
+1. 分页条下方到内容区底约 10px,分页始终可见  
+2. 若有足够行数:表体内纵滚,表头不消失  
+3. 缩窄窗口使列溢出:横滚条在表体底,无需滚到最后一行  
+4. 空数据时表区仍保持较大高度(不塌成一条)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/views/demo/unit/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): use viewport table height on demo unit list
+
+Pilot the fill-viewport list scroll pattern on the standard list template page.
+EOF
+)"
+```
+
+---
+
+### Task 4: 接入 bx 样板 + 澄清 `.table-scroll`
+
+**Files:**
+- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
+- Modify: `web-admin/src/styles/bx/index.scss`
+
+- [ ] **Step 1: 报销申请列表绑定 height**
+
+该页已有:
+
+```vue
+<div ref="reimburseApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
+  <ListTable ...>
+```
+
+与分页:
+
+```vue
+<div class="pagination list-pager-bar">
+```
+
+改动要点:
+
+1. `ListTable` 增加 `ref="reimburseApplyTableRef"`、`:height="tableHeight"`  
+2. 分页根增加 `ref="reimburseApplyPagerRef"`  
+3. script 中:
+
+```ts
+import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
+
+// 已有 reimburseApplyTableWrapRef 则复用
+const reimburseApplyPagerRef = ref<HTMLElement | null>(null)
+const reimburseApplyTableRef = ref<any>(null)
+
+const { tableHeight, recalc: recalcTableHeight } = useListTableViewportHeight({
+  tableEl: reimburseApplyTableWrapRef,
+  pagerEl: reimburseApplyPagerRef,
+  tableRef: reimburseApplyTableRef
+})
+```
+
+4. 列头筛选 / 工具栏导致布局变化处调用 `recalcTableHeight()`(至少在筛选 `setFilterState` 后 `nextTick` 一次,可用现有 watch 挂载点)。
+
+- [ ] **Step 2: 调整 bx `.table-scroll` 注释与行为**
+
+将 `web-admin/src/styles/bx/index.scss` 中:
+
+```scss
+.table-scroll {
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+```
+
+改为(避免与 EP `height` 内滚双滚动;横向交由 el-table):
+
+```scss
+/* 主列表使用 el-table :height 内滚后,wrap 只负责最小宽度收缩,勿再 overflow-y */
+.table-scroll {
+  min-width: 0;
+  overflow: visible;
+}
+```
+
+若个别页仍依赖 wrap 横滚且未设 height,批处理前可暂保留 `overflow-x: auto`,但**已设 height 的页**必须以 EP 为准。推荐全局改为 `overflow: visible` + `min-width: 0`,与 Task 4 样板一起验证。
+
+- [ ] **Step 3: 手动验收**
+
+打开报销申请列表:固定列、列筛选、分页底隙、表头与横滚条行为同 Task 3。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/views/bx/reimburse/apply/index.vue web-admin/src/styles/bx/index.scss
+git commit -m "$(cat <<'EOF'
+feat(web-admin): viewport-fill scroll on reimburse apply list
+
+Wire ListTable height to the shared composable and stop double-scroll on wraps.
+EOF
+)"
+```
+
+---
+
+### Task 5: 规范、Demo、OpenSpec
+
+**Files:**
+- Modify: `docs/web-admin-ui-spec-fresh-mint.md`(§7.6)
+- Modify: `docs/list-table-scroll-demo.html`(方案 A 说明对齐)
+- Modify: `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md`
+
+- [ ] **Step 1: 更新 Fresh Mint §7.6**
+
+将 §7.6 替换为(保持文档语气与周边章节一致):
+
+```markdown
+### 7.6 主列表视口填满滚动
+
+主内容区业务列表(`list-body` + 表 + `list-pager-bar`)SHALL 使用 Element Plus 表体固定可视高度(`height` / 动态像素),使:
+
+- **纵向滚动发生在表体内**,表头固定在表体顶部;
+- **横向滚动条贴在表体可视区底部**(不必滚到最后一行);
+- **分页条在表外且始终可见**,分页底边到内容区底保留 **10px**。
+
+推荐通过 composable `useListTableViewportHeight` 计算:
+
+`height = max(240, floor(contentViewportBottom - tableTop - pagerHeight - 10))`
+
+测量根优先 `#app-content`。弹窗内明细小表、统计卡内小表不强制本策略,可继续使用局部 `max-height`。
+
+历史 HTML demo 类名 **`.table-scroll`** 仅作结构参考;落地以 `el-table`/`ListTable` 的 `height` 为准,避免外层再套冲突的 `overflow-y: auto`。
+```
+
+- [ ] **Step 2: OpenSpec 增加 Requirement**
+
+在 `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` 合适位置(列表相关章节附近)追加:
+
+```markdown
+### Requirement: 主列表表体内嵌滚动与视口填满
+
+`web-admin` 主内容区带分页的业务列表 SHALL 为 `el-table` / `ListTable` 设置动态可视 `height`,使纵滚与横滚发生在表体内;表头 SHALL 在表体顶部保持可见;分页条 SHALL 保持在表外可见,且分页底边到主内容区底 SHALL 保留约 10px。SHALL NOT 依赖「整表随页面拉长」导致横滚条沉在最后一行之后。弹窗内嵌套小表 MAY 使用局部 `max-height`,不强制本条。
+
+#### Scenario: 新增长列表页
+
+- **WHEN** 在 `web-admin` 新增主内容区业务列表页(含 `list-pager-bar`)
+- **THEN** 表格 SHALL 使用视口剩余高度策略(或等价 `useListTableViewportHeight`),且分页下方保留约 10px 底隙
+
+#### Scenario: 验收横滚与表头
+
+- **WHEN** 列表行数与列宽足以产生纵滚与横滚
+- **THEN** 用户纵滚到表体中部时表头仍可见,且无需滚到最后一行即可操作横向滚动条
+```
+
+- [ ] **Step 3: Demo 文案**
+
+更新 `docs/list-table-scroll-demo.html` 中方案 A 的描述,明确「分页下方 10px、分页始终可见」(若已写则可微调数字/文案一致)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/web-admin-ui-spec-fresh-mint.md \
+  docs/list-table-scroll-demo.html \
+  openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md
+git commit -m "$(cat <<'EOF'
+docs: specify viewport-fill list table scrolling
+
+Align Fresh Mint §7.6 and OpenSpec ui-conventions with the confirmed UX.
+EOF
+)"
+```
+
+---
+
+### Task 6: 批处理推广(同构列表)
+
+**Files(按批修改,模式同 Task 3/4):**
+
+优先批(已有 `table-scroll` / `*TableWrapRef` + `ListTable`):
+
+- `web-admin/src/views/bx/view/{mine,todo,all}/index.vue`
+- `web-admin/src/views/bx/reimburse/approve/index.vue`(双表:每个 Tab 各自 height + `enabled`)
+- `web-admin/src/views/bx/advance/**/index.vue`
+- `web-admin/src/views/bx/payment/index.vue`
+- `web-admin/src/views/pl/plan/index.vue`、`plan-archive`、`freight/components/*Panel.vue`
+- `web-admin/src/views/db/{weighing,inventory,plans}/index.vue`
+- `web-admin/src/views/demo/{order,product,user,category}/index.vue`(模板族)
+
+每页最小改动清单:
+
+1. 表外包/已有 wrap:`ref` 测顶  
+2. 分页:`ref` 测高  
+3. `ListTable`/`el-table`:`:height="tableHeight"` + 可选 `ref` 供 `doLayout`  
+4. `useListTableViewportHeight({ tableEl, pagerEl, tableRef })`  
+5. Tab 多表:`enabled: computed(() => activeTab === 'xxx')`  
+6. 勿给 wrap 再加与 height 冲突的 `overflow-y: auto`
+
+- [ ] **Step 1: 按模块批量改并本地点验 2~3 个代表页**
+
+- [ ] **Step 2: 每完成一个模块 Commit 一次**(示例)
+
+```bash
+git add web-admin/src/views/bx web-admin/src/views/pl web-admin/src/views/db
+git commit -m "$(cat <<'EOF'
+feat(web-admin): roll out viewport list height to bx/pl/db lists
+
+Keep headers sticky and horizontal scrollbars in the table viewport.
+EOF
+)"
+```
+
+(可拆成多次 commit,按模块更清晰。)
+
+- [ ] **Step 3: 跑公式脚本回归**
+
+```bash
+cd web-admin && pnpm exec tsx scripts/check-list-table-viewport-height.ts
+```
+
+Expected: `ok`
+
+---
+
+## Spec coverage checklist
+
+| Spec 项 | Task |
+|---------|------|
+| 方案 A 表体内嵌滚动 | 2–4, 6 |
+| 分页下 10px / 分页始终可见 | 1 公式, 2 测量, 3–4 验收 |
+| minHeight 240 | 1 |
+| resize / 筛选变化重算 | 2 `useResizeObserver` + 页内 `recalc` |
+| 不进 ListTable 隐式写死 | 2(页显式接入) |
+| 空数据仍撑高度 | 3 用 `height` 非仅 max-height |
+| 规范 §7.6 / OpenSpec | 5 |
+| 非目标 B/C、弹窗小表、小程序 | 未立 task(刻意不做) |
+| 批推广 | 6 |
+
+---
+
+## Self-review notes
+
+- 无 TBD/placeholder。  
+- 公式常量与 composable 默认值一致(10 / 240)。  
+- 仓库无 vitest:用 `tsx` 脚本测纯函数,UI 用手动验收。  
+- `vue-tsc` 全量可能有历史噪声:以「新文件无报错」为准。

+ 147 - 0
docs/superpowers/specs/2026-09-01-web-admin-list-table-viewport-scroll-design.md

@@ -0,0 +1,147 @@
+# web-admin 列表表体内嵌滚动(视口填满)
+
+**日期:** 2026-09-01  
+**状态:** 已确认  
+**范围:** `web-admin` 主内容区业务列表(`list-body` + `el-table`/`ListTable` + `list-pager-bar`);弹窗内小表、小程序不在本期。  
+**交互 Demo:** `docs/list-table-scroll-demo.html`
+
+## 背景
+
+用户反馈两个问题:
+
+1. **横向滚动条在表最底部**:数据行很多时,必须滚到表底才能横滚,操作不便。  
+2. **纵向滚到中部后看不到表头**:列标题随页面滚走,难以对照列含义。
+
+### 根因
+
+多数业务列表未给表格设定可视高度,整表随 `#app-content` 页面滚动:
+
+- 横滚发生在「整张表」的底部(行越多越远)  
+- 表头不是表体内 sticky / EP 固定头,纵滚后离开视口  
+
+规范 §7.6 与 `fresh-mint-green.html` 已描述 `.table-scroll` + `max-height` + sticky 表头,但业务落地不一致;部分模块(bx 等)仅有 `overflow-x: auto`、无高度约束,问题仍在。
+
+## 决策摘要
+
+| 项 | 结论 |
+|----|------|
+| 方案 | **A:表体内嵌滚动**(`el-table` / `ListTable` 动态 `height`) |
+| 高度策略 | 尽量撑满视口;**分页条始终可见**;**分页底边到内容区底留 10px** |
+| 不做 | 方案 B(页面滚 + 悬浮横条)、方案 C(顶部镜像横条)本期不做 |
+| 非目标 | 弹窗内明细小表默认行为、小程序、改分页交互本身 |
+
+## 布局与高度
+
+### 纵向结构(落地后)
+
+```text
+#app-content 可视区域
+│ 内容区 padding(现有,如 24px)
+│ page-head / panel
+│   filter-block(可选,高度可变)
+│   list-toolbar
+│   ┌─ 表(height = 动态)────────────┐
+│   │ thead 固定在表体顶               │
+│   │ tbody 纵向滚动                   │
+│   │ 横向滚动条贴表体可视区底部       │
+│   └──────────────────────────────────┘
+│   pagination.list-pager-bar(始终可见)
+│   ░░░░░ 10px ░░░░░  ← 分页底边 → 内容区底
+```
+
+### 高度公式
+
+```text
+tableHeight = floor( contentViewportBottom - tableTop - pagerHeight - 10 )
+tableHeight = max(tableHeight, MIN_HEIGHT)   // 建议 MIN_HEIGHT = 240
+```
+
+- **contentViewportBottom**:主内容滚动容器(`#app-content`)的可视底边(或等价测量点),而非整窗 `window`,避免顶栏/Tab 误差。  
+- **tableTop**:表格根节点相对同一坐标系的顶边。  
+- **pagerHeight**:同页 `.pagination.list-pager-bar` 实际高度(含 padding);无分页时该项为 0,底隙 10px 仍保留在表底与内容区底之间。  
+- **10px**:产品确认——距底空隙在**分页下方**,不是表体下方挤掉分页。
+
+### 重算时机
+
+- 窗口 / 内容区 `resize`  
+- 筛选区展开/收起、工具栏换行导致表顶变化  
+- 路由进入 / Tab 显示后(`nextTick`)  
+- 可选:`ResizeObserver` 观察筛选区、工具栏、分页条
+
+重算后对表格调用 `doLayout()`(若持有 `ListTable`/`el-table` ref)。
+
+## 架构
+
+### 公共能力
+
+新增 composable,建议名:`useListTableViewportHeight`(路径:`web-admin/src/composables/`)。
+
+**输入(示意):**
+
+- 表格锚点 ref(测 `tableTop`)  
+- 分页条 ref(测 `pagerHeight`;可空)  
+- 可选:`minHeight`(默认 240)、`bottomGap`(默认 10)  
+- 可选:内容滚动根选择器 / ref(默认 `#app-content`)
+
+**输出:**
+
+- `tableHeight: Ref<number>` — 绑定 `:height="tableHeight"`  
+- `recalc()` — 供页面在筛选展开等时机手动触发
+
+不在 `ListTable` 内隐式写死高度(避免弹窗/嵌套表误伤);由列表页显式接入。
+
+### 页面接入约定
+
+1. 保持结构:`list-body.list-wrap` → 表 → `.pagination.list-pager-bar`。  
+2. 表使用 `:height="tableHeight"`(优先 `height` 而非仅 `max-height`,以便空数据/少行时高度稳定)。  
+3. 横向/纵向滚动交给 Element Plus 表体;**不要**再在外层 `.table-scroll` 上设与表体冲突的 `overflow-y: auto` + 另一套 max-height(若保留 `.table-scroll` 仅作横向兜底,须与 EP 高度模式兼容,推荐 wrap 仅 `min-width: 0`)。  
+4. 操作列继续 `fixed="right"`;滚动在 EP 内部时与现有全局「`list-wrap` 避免乱设 overflow:hidden」规则不冲突。
+
+### 与现有代码关系
+
+| 现状 | 处理 |
+|------|------|
+| 规范 §7.6 `max-height: min(520px, …)` | 更新为「视口填满 − 分页 − 10px」,去掉写死 520px 作为主策略 |
+| bx `.table-scroll { overflow-x: auto; overflow-y: hidden }` | 接入动态 height 后,以 EP 内滚为准,避免双滚动容器 |
+| 已有 `:max-height="400"` 等局部小表 | 弹窗/统计卡小表保持原样;仅主列表替换为视口高度策略 |
+
+## 边界情况
+
+| 场景 | 行为 |
+|------|------|
+| 筛选展开导致表区过矮 | `height` 不低于 `MIN_HEIGHT`(240);超出部分由 `#app-content` 外层滚动 |
+| 展开行 / 树表 | 内容在表体内滚动;展开/收起后 `doLayout` |
+| 空数据 / 行很少 | 仍使用同一动态 `height`,避免空态高度跳动、横条位置不稳 |
+| 无分页列表 | `pagerHeight = 0`,表底到内容区底留 10px;注意无分页时 `list-wrap` 圆角裁剪既有规则仍适用 |
+| 窄屏 | 公式不变;内容区 padding 变化由测量自然消化 |
+| 双表 / Tab 多列表 | 每个可见列表各自锚点 + 各自 height;隐藏 Tab 可暂停观察或显示时再算 |
+
+## 推广节奏
+
+1. 实现 composable + 文档/规范 §7.6 修订 + Demo 页(已有 `docs/list-table-scroll-demo.html`)对齐文案。  
+2. 先接入模板参考页与高频 `ListTable` 列表(如 system 模板页、bx/pl/db 已统一结构的列表)验证。  
+3. 其余业务列表按模块批量接入;**新建列表默认接入**。  
+4. OpenSpec `ui-conventions` 增加对应 SHALL(与 Fresh Mint 文档同步)。
+
+## 验收标准
+
+- [ ] 数据行较多时,纵向滚到表体中部:**表头仍可见**。  
+- [ ] 数据行较多且列溢出时:**不必滚到最后一行**即可使用横向滚动条(横条在表体可视区底部)。  
+- [ ] **分页条始终在视口内可见**,其下到内容区底约 **10px**。  
+- [ ] 窗口缩放、筛选展开/收起后,高度与底隙仍正确。  
+- [ ] 操作列 `fixed="right"`、勾选列表现正常。  
+- [ ] 空数据时表区高度不塌缩到「仅表头+空文案」的跳动态(与有数据时同策略)。
+
+## 非目标(重申)
+
+- 顶部/悬浮镜像横向滚动条(方案 C/B)  
+- 小程序列表滚动  
+- 弹窗内嵌套明细表的默认视口填满(可按页单独 `max-height`)  
+- 虚拟滚动 / 分页大小策略变更  
+
+## 参考
+
+- 交互对比 Demo:`docs/list-table-scroll-demo.html`  
+- UI 规范:`docs/web-admin-ui-spec-fresh-mint.md` §7.5–7.6  
+- HTML 基线:`docs/fresh-mint-green.html`(`.table-scroll`)  
+- 列表封装:`web-admin/src/components/business/list-table/index.vue`

+ 14 - 4
docs/web-admin-ui-spec-fresh-mint.md

@@ -394,11 +394,21 @@
 - **`.td-total-label`**:`letter-spacing: 0.12em`
 - **`.td-check` / `.td-op` 在 tfoot**:背景仍为 `var(--primary-bg)`
 
-### 7.6 滚动 `.table-scroll`
+### 7.6 主列表视口填满滚动
 
-- `overflow-x: auto`,`overflow-y: auto`
-- **`max-height: min(520px, calc(100vh - 300px))`**(可按页面微调)
-- `-webkit-overflow-scrolling: touch`
+主内容区业务列表(`list-body` + 表 + `list-pager-bar`)SHALL 使用 Element Plus 表体固定可视高度(`height` / 动态像素),使:
+
+- **纵向滚动发生在表体内**,表头固定在表体顶部;
+- **横向滚动条贴在表体可视区底部**(不必滚到最后一行);
+- **分页条在表外且始终可见**,分页底边到内容区底保留 **10px**。
+
+推荐通过 composable `useListTableViewportHeight` 计算:
+
+`height = max(240, floor(contentViewportBottom - tableTop - pagerHeight - 10))`
+
+测量根优先 `#app-content`。弹窗内明细小表、统计卡内小表不强制本策略,可继续使用局部 `max-height`。
+
+历史 HTML demo 类名 **`.table-scroll`** 仅作结构参考;落地以 `el-table`/`ListTable` 的 `height` 为准,避免外层再套冲突的 `overflow-y: auto`。
 
 ### 7.7 状态标签(`el-tag` / `.tag`)
 

+ 14 - 0
openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md

@@ -189,6 +189,20 @@ Web 管理端弹窗 SHALL 遵循规范:表单 `label-position="top"`;遮罩
 - **THEN** 审查者 SHALL 确认该列包含 `fixed="right"` 属性,且宽度与按钮数量匹配(2 按钮 260px,3+ 按钮 320px)
 - **THEN** 若页面属于标准 CRUD + 查看弹窗且仅编辑/删除,审查者 SHALL 要求移除外置至工具栏与 `#hero-actions`,而非保留操作列
 
+### Requirement: 主列表表体内嵌滚动与视口填满
+
+`web-admin` 主内容区带分页的业务列表 SHALL 为 `el-table` / `ListTable` 设置动态可视 `height`,使纵滚与横滚发生在表体内;表头 SHALL 在表体顶部保持可见;分页条 SHALL 保持在表外可见,且分页底边到主内容区底 SHALL 保留约 10px。SHALL NOT 依赖「整表随页面拉长」导致横滚条沉在最后一行之后。弹窗内嵌套小表 MAY 使用局部 `max-height`,不强制本条。推荐使用 `useListTableViewportHeight`(测量根优先 `#app-content`)。
+
+#### Scenario: 新增长列表页
+
+- **WHEN** 在 `web-admin` 新增主内容区业务列表页(含 `list-pager-bar`)
+- **THEN** 表格 SHALL 使用视口剩余高度策略(或等价 `useListTableViewportHeight`),且分页下方保留约 10px 底隙
+
+#### Scenario: 验收横滚与表头
+
+- **WHEN** 列表行数与列宽足以产生纵滚与横滚
+- **THEN** 用户纵滚到表体中部时表头仍可见,且无需滚到最后一行即可操作横向滚动条
+
 ### Requirement: 表格列宽使用 min-width 实现等比缩放
 
 `web-admin` 中 `el-table` 的**业务数据列与操作列**的列宽属性 SHALL 使用 `min-width` 替代 `width`,使浏览器视口宽度变化时这些列按基础比例等比伸缩。