Align Fresh Mint §7.6 and OpenSpec ui-conventions with the confirmed UX; include design and plan. Co-authored-by: Cursor <cursoragent@cursor.com>
20 KiB
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:
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:
cd web-admin && pnpm exec tsx scripts/check-list-table-viewport-height.ts
Expected: 失败(模块不存在 / 无法解析)
- Step 3: 实现纯函数
创建 web-admin/src/composables/listTableViewportHeight.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:
cd web-admin && pnpm exec tsx scripts/check-list-table-viewport-height.ts
Expected: 打印 check-list-table-viewport-height: ok
- Step 5: Commit
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:
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:
cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -n 40
Expected: 与本次新文件相关无新增错误(若仓库已有历史错误,确认不含 useListTableViewportHeight / listTableViewportHeight)
- Step 3: Commit
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 只包住表格(分页保持为兄弟节点)。将:
<div class="list-body list-wrap">
<ListTable table-key="demo-unit"
class="list-table"
:data="tableData"
改为:
<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:
</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/...):
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:
cd web-admin && pnpm dev
打开 /#/demo/unit(或侧栏 Demo → 单位):
- 分页条下方到内容区底约 10px,分页始终可见
- 若有足够行数:表体内纵滚,表头不消失
- 缩窄窗口使列溢出:横滚条在表体底,无需滚到最后一行
- 空数据时表区仍保持较大高度(不塌成一条)
- Step 4: Commit
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
该页已有:
<div ref="reimburseApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable ...>
与分页:
<div class="pagination list-pager-bar">
改动要点:
ListTable增加ref="reimburseApplyTableRef"、:height="tableHeight"- 分页根增加
ref="reimburseApplyPagerRef" - script 中:
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
})
- 列头筛选 / 工具栏导致布局变化处调用
recalcTableHeight()(至少在筛选setFilterState后nextTick一次,可用现有 watch 挂载点)。
- Step 2: 调整 bx
.table-scroll注释与行为
将 web-admin/src/styles/bx/index.scss 中:
.table-scroll {
overflow-x: auto;
overflow-y: hidden;
}
改为(避免与 EP height 内滚双滚动;横向交由 el-table):
/* 主列表使用 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
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 替换为(保持文档语气与周边章节一致):
### 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 合适位置(列表相关章节附近)追加:
### 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
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.vueweb-admin/src/views/bx/reimburse/approve/index.vue(双表:每个 Tab 各自 height +enabled)web-admin/src/views/bx/advance/**/index.vueweb-admin/src/views/bx/payment/index.vueweb-admin/src/views/pl/plan/index.vue、plan-archive、freight/components/*Panel.vueweb-admin/src/views/db/{weighing,inventory,plans}/index.vueweb-admin/src/views/demo/{order,product,user,category}/index.vue(模板族)
每页最小改动清单:
- 表外包/已有 wrap:
ref测顶 - 分页:
ref测高 ListTable/el-table::height="tableHeight"+ 可选ref供doLayoutuseListTableViewportHeight({ tableEl, pagerEl, tableRef })- Tab 多表:
enabled: computed(() => activeTab === 'xxx') - 勿给 wrap 再加与 height 冲突的
overflow-y: auto
-
Step 1: 按模块批量改并本地点验 2~3 个代表页
-
Step 2: 每完成一个模块 Commit 一次(示例)
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: 跑公式脚本回归
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全量可能有历史噪声:以「新文件无报错」为准。