Files
shanxia2.0/docs/superpowers/plans/2026-09-01-web-admin-list-table-viewport-scroll.md
T
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

651 lines
20 KiB
Markdown
Raw 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.
# 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` 全量可能有历史噪声:以「新文件无报错」为准。