Files
shanxia2.0/docs/list-table-scroll-demo.html
zlwuandCursor b8efcca1cf 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>
2026-09-01 18:47:48 +08:00

805 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>