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>
This commit is contained in:
1 parent
aef6927181
commit
b8efcca1cf
5 files changed
+1629
-4
No files matched your search
@@ -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>
|
||||
@@ -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` 全量可能有历史噪声:以「新文件无报错」为准。
|
||||
@@ -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`
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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`,使浏览器视口宽度变化时这些列按基础比例等比伸缩。
|
||||
|
||||
Reference in new issue
Block a user