cursor-vue3表单skills

我爱海鲸 2026-08-14 11:53:36 暂无标签

简介vue3表单skills、导出

可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。


注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。

在项目的根目录下创建:.cursor\skills\admin-table-page

SKILL.md

---
name: admin-table-page
description: Scaffolds Vue 3 + Element Plus admin list pages with inline filter form, fixed-layout scrollable table, pagination, optional Excel export, column visibility/order persisted to localStorage (three keys: visible-columns, column-order, visible-column-order + sortablejs), fixed-right actions column, and CreateTimeRangeFilter (today shortcut for createTime). Resolves API paths and types from the workspace. Use when adding 后台列表页, 筛选表单, 表格分页, Excel 导出, 列显示, 列拖拽, 列配置持久化, 创建时间今天, or cms admin table views.
---

# 后台「筛选 + 表格 + 分页」列表页(Vue 3 + Element Plus)

在 **cms** 模块新增或与现有页面对齐的**管理端列表页**时,按下列约定生成代码。

**脱敏约定**:技能正文与示例**不出现**具体业务页面名、业务 API 路径、业务字段/枚举;占位符 `{pageId}`、`Record*`、`{apiPrefix}` 落地时从工作区同类文件替换。正文与生成代码**不使用 emoji 或装饰性 Unicode 符号**(如箭头、节号、树形框线等),统一用中文或 ASCII 表述。

## 0. 先读再写

| 要确定的内容 | 做法 |
|-------------|------|
| **页面模板** | 打开 `cms/src/views` 下已有列表页(如带导出、列设置的页面),复制 Layout / 卡片 / 表格区结构。 |
| **API 模块** | 打开 `cms/src/api` 下同域 `*.ts`:`get` 分页、`request.get` + `responseType: 'blob'` 导出。 |
| **类型** | 在 `cms/src/types` 对照 `*QueryParams`、`MpPage`、行 `Item` 定义。 |
| **请求工具** | 从 `@/utils/request` 复制 `get` / 默认 `request` 用法;时间格式与后端 `yyyy-MM-dd HH:mm:ss` 一致。 |
| **创建时间筛选** | 凡筛选含 `createTimeBegin` / `createTimeEnd` 的列表,统一用 `@/components/filter/CreateTimeRangeFilter.vue`(含「今天」快捷勾选),勿单独写裸 `el-date-picker`。 |
| **表头配色** | 若项目已有统一表头色,复制其 `:header-cell-style` 与 `:deep(th...)`;否则用 Element 默认。 |
| **列持久化参考** | 宽表列显示/拖拽须**刷新后仍生效**时,在工作区搜索已启用「列设置 + 表头拖拽」的列表页,复制第 5 节的三键 `localStorage` 与 `restoreVisibleColumnOrderFromStorage` 流程。 |

更完整的分步骨架见 [examples.md](examples.md)。后端分页/导出配对见 [`spring-admin-page-export`](../spring-admin-page-export/SKILL.md)。

---

## 1. 页面分区(自上而下)

```
Layout
  .{page}-page          (height: calc(100vh - 顶栏与 padding 合计))
    el-card.main-card    (flex 列,min-height: 0)
      el-form.search-form       (筛选,flex-shrink: 0)
      [可选] .summary-bar       (汇总/提示条,flex-shrink: 0)
      .table-panel              (flex: 1,min-height: 0)
        .table-wrap (ref)       (表格滚动区;overflow: visible,见第 4 节)
          el-table (:height="tableHeight")
        .pager                   (el-pagination,flex-shrink: 0)
```

**可选**:`el-dialog` 详情 / 二次确认;行内 `link` 按钮放**固定右侧操作列**。

---

## 2. 筛选表单

- `el-form`:`inline`、`@submit.prevent="onSearch"`。
- `queryForm` 用 `reactive`;字段与后端 Query DTO **驼峰**对齐。
- 常用控件:`el-input`(精确)、`el-select`(单/多选、`clearable`)。
- **创建时间**(有 `createTimeBegin` / `createTimeEnd` 的后端分页查询):
    - `queryForm.timeRange: [string, string] | null`;
    - 模板使用 **`CreateTimeRangeFilter`**(`v-model="queryForm.timeRange"`),**禁止**在该场景单独写 `el-date-picker`;
    - `buildParams` / `buildExportParams` / `appendFilterParams` 中统一映射:

      ```typescript
      if (queryForm.timeRange?.length === 2) {
        target.createTimeBegin = queryForm.timeRange[0]
        target.createTimeEnd = queryForm.timeRange[1]
      }
      ```

    - **`onReset`** 须 `queryForm.timeRange = null`(会同步取消「今天」勾选)。
- 操作按钮:**查询**(`onSearch`:`pageNum=1` 后 `fetchList`)、**重置**(清空表单 + 默认分页 + `fetchList`)。
- **导出 Excel**(若后端有 `/export`):`type="success" plain`、`:loading="exporting"`;参数用**无分页**的 `buildExportParams()`(与列表筛选一致,不含 `pageNum`/`pageSize`)。

### 2.1 创建时间 +「今天」快捷(`CreateTimeRangeFilter`)

**组件路径**:`cms/src/components/filter/CreateTimeRangeFilter.vue`(以工作区实际路径为准)

**工具函数**(`@/utils/datetime.ts`):

| 函数 | 说明 |
|------|------|
| `getTodayDateTimeRange()` | 返回 `[当天 00:00:00, 当天 23:59:59]`,格式 `YYYY-MM-DD HH:mm:ss` |
| `isTodayDateTimeRange(range)` | 判断当前范围是否等于「今天」全天 |

**交互约定**:

| 操作 | 行为 |
|------|------|
| 勾选「今天」 | `timeRange` 设为当天 `00:00:00` 至 `23:59:59` |
| 取消「今天」 | `timeRange` 设为 `null` |
| 手动改日期范围 | 若等于今天全天则自动勾选;否则自动取消 |
| 重置表单 | `timeRange = null`,「今天」同步取消 |

**页面接入**:

```vue
<script setup lang="ts">
import CreateTimeRangeFilter from '@/components/filter/CreateTimeRangeFilter.vue'
</script>

<el-form-item label="创建时间">
  <CreateTimeRangeFilter v-model="queryForm.timeRange" />
</el-form-item>
```

组件内部已固定 `datetimerange`、`value-format="YYYY-MM-DD HH:mm:ss"` 与宽度;新列表页**直接复用**,勿复制 date-picker 配置。

---

## 3. 列表数据流

| 函数 | 职责 |
|------|------|
| `buildParams()` | 分页 + 当前筛选,传给 `page*` API |
| `buildExportParams()` | 仅筛选,传给 `export*` / `download*Excel` |
| `fetchList()` | `loading`;更新 `tableData`、`total`;`nextTick` 后 `updateTableHeight()`、`initColumnDrag()`(若启用列拖拽) |
| `onSizeChange()` | `pageNum = 1` 后 `fetchList` |

分页状态:`pageNum`、`pageSize`、`total`;`el-pagination` 绑定 `@current-change="fetchList"`、`@size-change="onSizeChange"`。

---

## 4. 表格布局(固定高度 + 内部滚动)

- 页面根容器 **禁止** 整体纵向滚动;表格区 `flex: 1; min-height: 0`。
- `tableWrapRef` + `ResizeObserver`(及 `window.resize`)计算 `tableHeight = max(wrap.clientHeight, 200)`。
- `el-table` 设 `:height="tableHeight"`;数据变更后 `tableRef.doLayout()`。
- **固定右侧操作列**:`.table-wrap` 与 `.table-panel` 使用 `overflow: visible`,**不要** `overflow: hidden`,否则 `fixed="right"` 会被裁切。

宽表:`.data-table { width: max(100%, Npx) }`,body 区 `overflow-x: auto`。

---

## 5. 列配置(显示 / 顺序 / 拖拽 + **本地持久化**)

适用宽表、字段多的列表;简单列表可写死 `el-table-column`。

**目标**:用户拖动表头调整列顺序、勾选/取消列显示后,**刷新页面仍保持**上次配置,不要恢复默认。

### 5.1 三个 localStorage Key(必配)

| Key 后缀 | 存储内容 | 何时写入 |
|----------|----------|----------|
| `{pageId}-visible-columns` | 当前**可见**列 key 数组 | 勾选/取消列、`恢复默认` |
| `{pageId}-column-order` | **全部**列(含隐藏)的全局顺序 | 拖拽结束、显隐变更、`恢复默认` |
| `{pageId}-visible-column-order` | **仅可见列**的排列顺序 | **仅**表头拖拽结束时 |

- `{pageId}` 用页面唯一前缀(如路由 slug、`{module}-{resource}`),避免多页冲突。
- **禁止**只存 `column-order` 而不存 `visible-column-order`:刷新后 `orderedVisibleColumnKeys = columnOrder.filter(visible)` 会按「全表默认顺序」过滤可见列,**丢失用户拖拽后的可见列顺序**。

### 5.2 常量与初始加载顺序

```typescript
const COLUMN_STORAGE_KEY = '{pageId}-visible-columns'
const COLUMN_ORDER_STORAGE_KEY = '{pageId}-column-order'
const VISIBLE_COLUMN_ORDER_STORAGE_KEY = '{pageId}-visible-column-order'

const DEFAULT_COLUMN_ORDER = TABLE_COLUMNS.map((col) => col.key) // 全量列顺序

const visibleColumnKeys = ref<TableColumnKey[]>(loadVisibleColumnKeys())
const columnOrder = ref<TableColumnKey[]>(loadColumnOrder())
restoreVisibleColumnOrderFromStorage() // 必须在上面两行之后、computed 之前

const orderedVisibleColumnKeys = computed(() =>
  columnOrder.value.filter((key) => visibleColumnKeys.value.includes(key))
)
```

**加载规则**:

- `loadVisibleColumnKeys`:JSON 解析后只保留 `DEFAULT_COLUMN_ORDER` 中仍存在的 key;空或非法则回退 `DEFAULT_VISIBLE_COLUMN_KEYS`。
- `loadColumnOrder`:过滤无效 key 后,**append** 配置里新增但 storage 缺失的列(版本升级兼容)。
- `restoreVisibleColumnOrderFromStorage`:优先读 `visible-column-order` 并 `applyVisibleColumnReorder`;无存档则按 `DEFAULT_VISIBLE_COLUMN_KEYS` 与当前可见列的交集排序。

### 5.3 核心函数(复制即用)

```typescript
function applyVisibleColumnReorder(reorderedVisible: TableColumnKey[]) {
  const hidden = columnOrder.value.filter((key) => !visibleColumnKeys.value.includes(key))
  columnOrder.value = [...reorderedVisible, ...hidden]
}

function persistVisibleColumnOrder(visibleOrder: TableColumnKey[]) {
  localStorage.setItem(VISIBLE_COLUMN_ORDER_STORAGE_KEY, JSON.stringify(visibleOrder))
}

function onColumnVisibleChange(keys: string[]) {
  if (!keys.length) visibleColumnKeys.value = [DEFAULT_VISIBLE_COLUMN_KEYS[0]] // 至少保留 1 列
  persistVisibleColumnKeys()
  const saved = loadVisibleColumnOrder()
  if (saved) {
    applyVisibleColumnReorder(saved)
  } else {
    const visibleSet = new Set(visibleColumnKeys.value)
    applyVisibleColumnReorder(DEFAULT_VISIBLE_COLUMN_KEYS.filter((key) => visibleSet.has(key)))
  }
  persistColumnOrder()
  nextTick(() => { updateTableHeight(); initColumnDrag() })
}

function resetTableColumns() {
  visibleColumnKeys.value = [...DEFAULT_VISIBLE_COLUMN_KEYS]
  columnOrder.value = [...DEFAULT_COLUMN_ORDER]
  localStorage.removeItem(VISIBLE_COLUMN_ORDER_STORAGE_KEY)
  applyVisibleColumnReorder([...DEFAULT_VISIBLE_COLUMN_KEYS])
  persistVisibleColumnKeys()
  persistColumnOrder()
  nextTick(() => { updateTableHeight(); initColumnDrag() })
}
```

### 5.4 表头拖拽(sortablejs)

- 数据列:`label-class-name="draggable-column-header"`。
- 操作列:`header-cell-class-name="ops-column-header"`、`fixed="right"`,**不参与**拖拽。
- `initColumnDrag` 在 `fetchList` 完成、`onMounted`、`onColumnVisibleChange`、`resetTableColumns` 后 `nextTick` 调用;`onUnmounted` 必须 `destroy()`。

```typescript
columnSortable = Sortable.create(headerRow, {
  animation: 150,
  draggable: 'th.draggable-column-header',
  filter: '.gutter, .ops-column-header',
  preventOnFilter: true,
  ghostClass: 'column-drag-ghost',
  onEnd(evt) {
    const keys = [...orderedVisibleColumnKeys.value]
    const moved = keys.splice(oldIndex, 1)[0]
    keys.splice(newIndex, 0, moved)
    applyVisibleColumnReorder(keys)
    persistVisibleColumnOrder(keys)  // 刷新后顺序靠此 key 恢复
    persistColumnOrder()
    nextTick(() => { tableRef.value?.doLayout?.(); initColumnDrag() })
  }
})
```

### 5.5 UI 与渲染

1. **配置表** `TABLE_COLUMNS: { key, label }[]` + `tableColumnBindMap`(`prop`、`label`、`width`/`minWidth`、`showOverflowTooltip`)。
2. **渲染**:`v-for="colKey in orderedVisibleColumnKeys"` + `v-bind="tableColumnBindMap[colKey]"`;自定义单元格用 `#default` + `v-if/v-else-if` 按 `colKey` 分支。
3. **列设置入口**:操作列表头小图标 + `el-popover` + `el-checkbox-group`;提示文案写明「配置会保存到本地」;「恢复默认」走 `resetTableColumns`(含 `removeItem(visible-column-order)`)。

### 5.6 持久化检查清单

- [ ] 三个 storage key 均已定义且 `{pageId}` 唯一
- [ ] 初始化顺序:`loadVisible`,然后 `loadColumnOrder`,然后 `restoreVisibleColumnOrderFromStorage`
- [ ] 拖拽 `onEnd` 同时 `persistVisibleColumnOrder` + `persistColumnOrder`
- [ ] 显隐 `@change` 后尝试恢复 `visible-column-order`,并 `persistColumnOrder`
- [ ] `loadVisibleColumnKeys` 校验范围是 **全量** `DEFAULT_COLUMN_ORDER`,不是仅默认可见列
- [ ] `恢复默认` 清除 `visible-column-order` 并写回默认可见顺序
- [ ] 刷新浏览器后列顺序与显隐与操作前一致

---

## 6. API 层(分页 + 导出)

在对应 `src/api/*.ts` 中(命名随模块;URL 从工作区同类 API 复制):

```typescript
// 分页
pageRecords(params?: RecordQueryParams): Promise<MpPage<RecordItem>> {
  return get<MpPage<RecordItem>>('{apiPrefix}/.../page', params)
}

// 导出 blob
exportRecordsExcel(params?: RecordQueryParams): Promise<Blob> {
  return request.get('{apiPrefix}/.../export', {
    params,
    responseType: 'blob',
    timeout: 120_000
  }) as unknown as Promise<Blob>
}

// 触发浏览器下载
async downloadRecordsExcel(params?: RecordQueryParams): Promise<void> {
  const blob = await api.exportRecordsExcel(params)
  // createObjectURL + <a download> + revokeObjectURL
}
```

页面内:`await api.downloadRecordsExcel(buildExportParams())`,成功 `ElMessage.success`,失败 `ElMessage.error`。

---

## 7. 操作列与行内交互

- 操作按钮:`type="primary" link size="small"`,包在 `.table-ops`(纵向 `flex`,居中)。
- 异步行操作:用 `actionLoadingRowId` 等 ref 绑定 `:loading`,`finally` 清空。
- 危险操作:`ElMessageBox.confirm`(必要时两步确认)。
- 详情:`el-dialog` + `destroy-on-close`;`v-loading` 包裹内容区。

---

## 8. 样式检查清单

- [ ] 页面高度固定,筛选/分页不随表格滚动
- [ ] 固定操作列完整可见
- [ ] 表头与表体列宽在列显隐/拖拽后仍正常(`doLayout`)
- [ ] 导出参数与列表筛选一致且无分页字段
- [ ] 含创建时间筛选时使用 `CreateTimeRangeFilter`,且 `onReset` 清空 `timeRange`
- [ ] 列显隐/拖拽使用 **三键** localStorage(第 5.1 节),刷新后配置不丢失
- [ ] 拖拽结束写入 `visible-column-order`;`恢复默认` 清除该 key
- [ ] `localStorage` key 含页面唯一前缀,避免多页冲突
- [ ] 无业务魔法字符串散落:枚举/格式化抽到 `utils` 或 `types`(按项目习惯)

---

## 9. 与后端技能边界

- 本技能:**前端**列表页结构与交互。
- [`spring-admin-page-export`](../spring-admin-page-export/SKILL.md):**后端** `GET /page` + `GET /export` 配对。

新增导出时前后端须共用同一套 Query 筛选语义。

---

## 10. 附加资源

- 结构骨架与常量命名:[examples.md](examples.md)

examples.md:

# 后台列表页:结构骨架(无业务字段)

下列占位符落地时替换:`Record*`、`{pageId}`、`{apiPrefix}`。包路径、import、API 完整 URL 从工作区同类文件复制。

---

## 1. 模板骨架

```vue
<template>
  <Layout>
    <div class="{pageId}-page">
      <el-card shadow="never" class="main-card">
        <el-form :inline="true" :model="queryForm" class="search-form" @submit.prevent="onSearch">
          <!-- 筛选表单项:按后端 Query DTO 逐项添加 -->
          <!-- 有 createTimeBegin/createTimeEnd 时: -->
          <!-- <el-form-item label="创建时间">
            <CreateTimeRangeFilter v-model="queryForm.timeRange" />
          </el-form-item> -->
          <el-form-item>
            <el-button type="primary" @click="onSearch">查询</el-button>
            <el-button @click="onReset">重置</el-button>
            <el-button v-if="hasExport" type="success" plain :loading="exporting" @click="onExport">
              导出 Excel
            </el-button>
          </el-form-item>
        </el-form>

        <!-- 可选汇总条 -->
        <!-- <div class="summary-bar">...</div> -->

        <div class="table-panel">
          <div ref="tableWrapRef" class="table-wrap">
            <el-table
                ref="tableRef"
                v-loading="loading"
                class="data-table"
                :data="tableData"
                :height="tableHeight"
                :header-cell-style="tableHeaderCellStyle"
                border
                stripe
                size="small"
            >
              <el-table-column
                  v-for="colKey in orderedVisibleColumnKeys"
                  :key="colKey"
                  :column-key="colKey"
                  label-class-name="draggable-column-header"
                  v-bind="tableColumnBindMap[colKey]"
              >
                <!-- 需要格式化/插槽的列按 colKey 分支 -->
              </el-table-column>

              <el-table-column width="W" fixed="right" align="center" class-name="ops-column" header-cell-class-name="ops-column-header">
                <template #header>
                  <div class="ops-column-header">
                    <span class="ops-column-title">操作</span>
                    <!-- 列显示 popover:提示「配置会保存到本地」;恢复默认清除 visible-column-order -->
                  </div>
                </template>
                <template #default="{ row }">
                  <div class="table-ops">
                    <el-button type="primary" link size="small" @click="openDetail(row)">查看详情</el-button>
                  </div>
                </template>
              </el-table-column>
            </el-table>
          </div>

          <div class="pager">
            <el-pagination
                v-model:current-page="pageNum"
                v-model:page-size="pageSize"
                :total="total"
                :page-sizes="[10, 20, 50, 100]"
                layout="total, sizes, prev, pager, next, jumper"
                background
                @current-change="fetchList"
                @size-change="onSizeChange"
            />
          </div>
        </div>
      </el-card>
    </div>
  </Layout>
</template>
```

---

## 2. Script 核心状态

```typescript
import CreateTimeRangeFilter from '@/components/filter/CreateTimeRangeFilter.vue'

const queryForm = reactive({
  // 与 RecordQueryParams 对齐
  timeRange: null as [string, string] | null, // 映射 createTimeBegin / createTimeEnd
})

const pageNum = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableData = ref<RecordItem[]>([])
const loading = ref(false)
const exporting = ref(false)

const tableWrapRef = ref<HTMLElement | null>(null)
const tableRef = ref<TableInstance>()
const tableHeight = ref(360)
```

---

## 3. 参数构建

```typescript
function appendFilterParams(target: RecordQueryParams) {
  // if (queryForm.xxx) target.xxx = queryForm.xxx
  if (queryForm.timeRange?.length === 2) {
    target.createTimeBegin = queryForm.timeRange[0]
    target.createTimeEnd = queryForm.timeRange[1]
  }
}

function buildParams(): RecordQueryParams {
  const p: RecordQueryParams = {
    pageNum: pageNum.value,
    pageSize: pageSize.value,
  }
  appendFilterParams(p)
  return p
}

function buildExportParams(): RecordQueryParams {
  const p: RecordQueryParams = {}
  // 与 buildParams 相同筛选,不含 pageNum/pageSize
  appendFilterParams(p)
  return p
}

function onReset() {
  // ...清空其它 queryForm 字段
  queryForm.timeRange = null // 同步取消 CreateTimeRangeFilter「今天」
  pageNum.value = 1
  pageSize.value = 10
  fetchList()
}
```

---

## 3.1 创建时间 +「今天」(`CreateTimeRangeFilter`)

- 组件:`@/components/filter/CreateTimeRangeFilter.vue`
- 工具:`getTodayDateTimeRange()`、`isTodayDateTimeRange()`(`@/utils/datetime.ts`)
- 勾选「今天」:`[YYYY-MM-DD 00:00:00, YYYY-MM-DD 23:59:59]`;取消:`null`
- 手动选日期与「今天」勾选双向同步;**不要**在列表页再写一套 today 逻辑

---

## 4. 列 localStorage 持久化(三键 + 完整加载/拖拽)

完整骨架见 [SKILL.md 第 5 节](SKILL.md#5-列配置显示--顺序--拖拽--本地持久化);下列为占位符版,落地时替换 `{pageId}`、`TableColumnKey` 与列配置表。

```typescript
const COLUMN_STORAGE_KEY = '{pageId}-visible-columns'
const COLUMN_ORDER_STORAGE_KEY = '{pageId}-column-order'
const VISIBLE_COLUMN_ORDER_STORAGE_KEY = '{pageId}-visible-column-order'

type TableColumnKey = 'id' | 'name' /* ... */

const TABLE_COLUMNS: { key: TableColumnKey; label: string }[] = [
  { key: 'id', label: 'ID' }
  // ...
]

const DEFAULT_VISIBLE_COLUMN_KEYS: TableColumnKey[] = ['id', 'name' /* ... */]
const DEFAULT_COLUMN_ORDER = TABLE_COLUMNS.map((col) => col.key)

function loadColumnOrder(): TableColumnKey[] {
  try {
    const raw = localStorage.getItem(COLUMN_ORDER_STORAGE_KEY)
    if (!raw) return [...DEFAULT_COLUMN_ORDER]
    const parsed = JSON.parse(raw) as string[]
    const valid = parsed.filter((key): key is TableColumnKey =>
      DEFAULT_COLUMN_ORDER.includes(key as TableColumnKey)
    )
    const missing = DEFAULT_COLUMN_ORDER.filter((key) => !valid.includes(key))
    return valid.length > 0 ? [...valid, ...missing] : [...DEFAULT_COLUMN_ORDER]
  } catch {
    return [...DEFAULT_COLUMN_ORDER]
  }
}

function loadVisibleColumnKeys(): TableColumnKey[] {
  try {
    const raw = localStorage.getItem(COLUMN_STORAGE_KEY)
    if (!raw) return [...DEFAULT_VISIBLE_COLUMN_KEYS]
    const parsed = JSON.parse(raw) as string[]
    const valid = parsed.filter((key): key is TableColumnKey =>
      DEFAULT_COLUMN_ORDER.includes(key as TableColumnKey)
    )
    return valid.length > 0 ? valid : [...DEFAULT_VISIBLE_COLUMN_KEYS]
  } catch {
    return [...DEFAULT_VISIBLE_COLUMN_KEYS]
  }
}

function loadVisibleColumnOrder(): TableColumnKey[] | null {
  try {
    const raw = localStorage.getItem(VISIBLE_COLUMN_ORDER_STORAGE_KEY)
    if (!raw) return null
    const parsed = JSON.parse(raw) as string[]
    const visibleSet = new Set(visibleColumnKeys.value)
    const valid = parsed.filter(
      (key): key is TableColumnKey =>
        DEFAULT_COLUMN_ORDER.includes(key as TableColumnKey) && visibleSet.has(key as TableColumnKey)
    )
    const missing = visibleColumnKeys.value.filter((key) => !valid.includes(key))
    return valid.length > 0 ? [...valid, ...missing] : null
  } catch {
    return null
  }
}

const visibleColumnKeys = ref<TableColumnKey[]>(loadVisibleColumnKeys())
const columnOrder = ref<TableColumnKey[]>(loadColumnOrder())

function applyVisibleColumnReorder(reorderedVisible: TableColumnKey[]) {
  const hidden = columnOrder.value.filter((key) => !visibleColumnKeys.value.includes(key))
  columnOrder.value = [...reorderedVisible, ...hidden]
}

function restoreVisibleColumnOrderFromStorage() {
  const saved = loadVisibleColumnOrder()
  if (saved) {
    applyVisibleColumnReorder(saved)
    return
  }
  const visibleSet = new Set(visibleColumnKeys.value)
  const defaultVisibleOrder = DEFAULT_VISIBLE_COLUMN_KEYS.filter((key) => visibleSet.has(key))
  if (defaultVisibleOrder.length > 0) {
    applyVisibleColumnReorder(defaultVisibleOrder)
  }
}

restoreVisibleColumnOrderFromStorage()

const orderedVisibleColumnKeys = computed(() =>
  columnOrder.value.filter((key) => visibleColumnKeys.value.includes(key))
)

function persistVisibleColumnKeys() {
  localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumnKeys.value))
}

function persistColumnOrder() {
  localStorage.setItem(COLUMN_ORDER_STORAGE_KEY, JSON.stringify(columnOrder.value))
}

function persistVisibleColumnOrder(visibleOrder: TableColumnKey[]) {
  localStorage.setItem(VISIBLE_COLUMN_ORDER_STORAGE_KEY, JSON.stringify(visibleOrder))
}

// sortablejs onEnd:persistVisibleColumnOrder(keys) + persistColumnOrder()
// onColumnVisibleChange:persistVisibleColumnKeys,然后尝试 loadVisibleColumnOrder,然后 persistColumnOrder()
// resetTableColumns:removeItem(VISIBLE_COLUMN_ORDER_STORAGE_KEY) + 写回默认
```

**操作列表头**(不参与拖拽):

```vue
<el-table-column
  fixed="right"
  class-name="ops-column"
  header-cell-class-name="ops-column-header"
>
```

**sortablejs**:

```typescript
Sortable.create(headerRow, {
  draggable: 'th.draggable-column-header',
  filter: '.gutter, .ops-column-header',
  preventOnFilter: true,
  onEnd(evt) {
    // ...reorder orderedVisibleColumnKeys
    persistVisibleColumnOrder(keys)
    persistColumnOrder()
  }
})
```

---

## 5. 表格高度

```typescript
function updateTableHeight() {
  const el = tableWrapRef.value
  if (!el) return
  tableHeight.value = Math.max(el.clientHeight, 200)
  nextTick(() => tableRef.value?.doLayout?.())
}

let tableResizeObserver: ResizeObserver | null = null

onMounted(async () => {
  await fetchList()
  await nextTick()
  updateTableHeight()
  initColumnDrag()
  if (typeof ResizeObserver !== 'undefined' && tableWrapRef.value) {
    tableResizeObserver = new ResizeObserver(() => updateTableHeight())
    tableResizeObserver.observe(tableWrapRef.value)
  }
  window.addEventListener('resize', updateTableHeight)
})

onUnmounted(() => {
  destroyColumnDrag()
  tableResizeObserver?.disconnect()
  window.removeEventListener('resize', updateTableHeight)
})
```

---

## 6. 表头样式

从工作区同类列表页复制 `:header-cell-style` 与 `:deep(th...)`;无统一规范时用 Element 默认。

```typescript
// 示例占位:颜色与类名以项目既有页面为准
const tableHeaderCellStyle = {
  background: 'var(--el-fill-color-light)',
  color: 'var(--el-text-color-primary)',
  fontWeight: 600
} as const
```

```scss
.{pageId}-page {
  height: calc(100vh - 100px);
  max-height: calc(100vh - 100px);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.main-card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;

  :deep(.el-card__body) {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
}

.table-panel {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: visible;
}

.table-wrap {
  flex: 1;
  min-height: 0;
  overflow: visible;
  position: relative;
}

.pager {
  flex-shrink: 0;
  margin-top: 12px;
  display: flex;
  justify-content: flex-end;
}
```

---

## 7. 导出

```typescript
async function onExport() {
  exporting.value = true
  try {
    await xxxApi.downloadRecordsExcel(buildExportParams())
    ElMessage.success('导出已开始,请查看浏览器下载')
  } catch (e: unknown) {
    const err = e as { msg?: string; message?: string }
    ElMessage.error(err?.msg || err?.message || '导出失败')
  } finally {
    exporting.value = false
  }
}
```

---

## 8. 依赖

列拖拽需 `sortablejs`(及 `@types/sortablejs`)。若 `cms/package.json` 未声明,安装后再引用。

你好:我的2025