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

20 KiB
Raw Blame History

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 → 单位):

  1. 分页条下方到内容区底约 10px,分页始终可见
  2. 若有足够行数:表体内纵滚,表头不消失
  3. 缩窄窗口使列溢出:横滚条在表体底,无需滚到最后一行
  4. 空数据时表区仍保持较大高度(不塌成一条)
  • 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">

改动要点:

  1. ListTable 增加 ref="reimburseApplyTableRef"、:height="tableHeight"
  2. 分页根增加 ref="reimburseApplyPagerRef"
  3. 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
})
  1. 列头筛选 / 工具栏导致布局变化处调用 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.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 一次(示例)

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 全量可能有历史噪声:以「新文件无报错」为准。