From b8efcca1cfa3df4659ee61f6a6b04832e935d735 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 1 Sep 2026 18:47:48 +0800 Subject: [PATCH] docs: specify viewport-fill list table scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align Fresh Mint §7.6 and OpenSpec ui-conventions with the confirmed UX; include design and plan. Co-authored-by: Cursor --- docs/list-table-scroll-demo.html | 804 ++++++++++++++++++ ...01-web-admin-list-table-viewport-scroll.md | 650 ++++++++++++++ ...admin-list-table-viewport-scroll-design.md | 147 ++++ docs/web-admin-ui-spec-fresh-mint.md | 18 +- .../specs/ui-conventions/spec.md | 14 + 5 files changed, 1629 insertions(+), 4 deletions(-) create mode 100644 docs/list-table-scroll-demo.html create mode 100644 docs/superpowers/plans/2026-09-01-web-admin-list-table-viewport-scroll.md create mode 100644 docs/superpowers/specs/2026-09-01-web-admin-list-table-viewport-scroll-design.md diff --git a/docs/list-table-scroll-demo.html b/docs/list-table-scroll-demo.html new file mode 100644 index 000000000..999f5308c --- /dev/null +++ b/docs/list-table-scroll-demo.html @@ -0,0 +1,804 @@ + + + + + + 列表滚动体验 Demo · Fresh Mint + + + +
+
+

列表横向 / 纵向滚动体验 Demo

+

+ 对齐 web-admin Fresh Mint。用下方 Tab 切换「现状问题」与三种改法, + 请在灰色模拟视口内纵向滚动到中部、再试横向滚动,感受差异。 +

+
+ 问题 1:横滚条在表最底部,行多时够不着 + 问题 2:纵滚到中部后看不到表头 + 目标:横滚条常在可视区底部 · 表头始终可见 +
+
+ +
+ + + + +
+ + +
+
+
+

现状:整页拉长,表跟着页面滚

+

与多数业务列表一致:表格无固定高度,横向滚动发生在整张表底部。

+
+
模拟视口
请先向下滚半屏
+
+

+ 复现:① 纵向滚到行 20+ → 表头消失;② 想横看「操作」列 → 必须滚到最底才能碰到横滚条。 +

+
+ 已选 0 项 + + +
+
+
内容区可视范围(向下滚)
+
+
+
+
共 60 条1 / 3 页(示意)
+
+
+ + + + + + + + + + +
+
+

与现有代码的关系

+

+ 规范 §7.6 已写 max-height + overflow;HTML demo 也有 sticky 表头。 + 业务页多数未给 el-table 设高度,bx 的 .table-scroll 仅 overflow-x,故问题仍在。 +

+
+
+

推荐落地路径

+

+ 优先方案 A:公共列表用 useListTableViewportHeight 填满视口(分页下 10px),分页留在表外。 + 列极多的页可再加方案 C 顶条。 +

+
+
+

注意点

+

+ 固定高度后筛选区展开会挤表体,需用「视口剩余高度」动态计算; + 展开行 / 树表要回归测 doLayout。 +

+
+
+ +
+

看完后请选择倾向方案(可点选,并在对话里告诉我)

+
+
+ A表体内嵌滚动 +

成本最低,一次修两个问题;与 Element Plus / 规范最贴合。

+
+
+ Bsticky + 悬浮横条 +

保留「整表随页面变高」;同步逻辑与兼容成本更高。

+
+
+ CA + 顶部镜像条 +

在 A 上增强横滚可达性;适合列特别宽的列表。

+
+
+
+
+ + + + diff --git a/docs/superpowers/plans/2026-09-01-web-admin-list-table-viewport-scroll.md b/docs/superpowers/plans/2026-09-01-web-admin-list-table-viewport-scroll.md new file mode 100644 index 000000000..cded498bf --- /dev/null +++ b/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) + /** 分页条根节点;无分页可省略 */ + pagerEl?: Ref | (() => HTMLElement | null | undefined) + /** 可选:ListTable / el-table 组件实例,高度变化后调用 doLayout */ + tableRef?: Ref + minHeight?: number + bottomGap?: number + /** 主内容滚动根;默认 #app-content */ + scrollRootSelector?: string + /** 为 false 时不自动监听(隐藏 Tab 可用) */ + enabled?: Ref | boolean +} + +function resolveEl( + source?: Ref | (() => 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 +
+ +
+ +
+