可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。
注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。
在项目的根目录下创建:.cursor\skills\admin-table-page
---
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 (localStorage + sortablejs), and fixed-right actions column. Resolves API paths and types from the workspace. Use when adding 后台列表页, 筛选表单, 表格分页, Excel 导出, 列显示, 列拖拽, or cms admin table views.
---
# 后台「筛选 + 表格 + 分页」列表页(Vue 3 + Element Plus)
在 **cms** 模块新增或与现有页面对齐的**管理端列表页**时,按下列约定生成代码。**技能不含具体业务字段、接口路径、枚举文案**;落地前须从工作区打开同类页面与 `src/api`、`src/types` 复制命名与路径。
## 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` 一致。 |
| **表头配色** | 若项目已有统一表头色,复制其 `:header-cell-style` 与 `:deep(th...)`;否则用 Element 默认。 |
更完整的分步骨架见 [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`)、`el-date-picker`(`datetimerange`,`value-format="YYYY-MM-DD HH:mm:ss"`)。
- 操作按钮:**查询**(`onSearch` → `pageNum=1` + `fetchList`)、**重置**(清空表单 + 默认分页 + `fetchList`)。
- **导出 Excel**(若后端有 `/export`):`type="success" plain`、`:loading="exporting"`;参数用**无分页**的 `buildExportParams()`(与列表筛选一致,不含 `pageNum`/`pageSize`)。
---
## 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"` 会被裁切。
宽表:`.order-table { width: max(100%, Npx) }`,body 区 `overflow-x: auto`。
---
## 5. 列配置(显示 / 顺序 / 拖拽)
适用宽表、字段多的列表;简单列表可写死 `el-table-column`。
1. **配置表** `TABLE_COLUMNS: { key, label }[]` + `tableColumnBindMap`(`prop`、`label`、`width`/`minWidth`、`showOverflowTooltip`)。
2. **可见列** `visibleColumnKeys` + `localStorage`(key 建议:`{页面标识}-visible-columns`);至少保留 1 列。
3. **列顺序** `columnOrder` + 独立 `localStorage`;`orderedVisibleColumnKeys = columnOrder.filter(visible)`。
4. **渲染**:`v-for="colKey in orderedVisibleColumnKeys"` + `v-bind="tableColumnBindMap[colKey]"`;需自定义单元格时用 `#default` + `v-if/v-else-if` 按 `colKey` 分支。
5. **列设置入口**:放在**操作列表头**(小图标 + `el-popover` + `el-checkbox-group`);「恢复默认」重置可见性与顺序。
6. **表头拖拽**:依赖 `sortablejs`;`initColumnDrag` 绑定 `.el-table__header-wrapper thead tr`,`draggable: '.draggable-column-header'`;`onEnd` 更新 `columnOrder` 并 `persist`;`onUnmounted` 销毁实例。列变化后 `nextTick` → `doLayout` → 重新 `initColumnDrag`。
操作列:`fixed="right"`,不参与拖拽;表头 `label-class-name="draggable-column-header"` 仅加在数据列。
---
## 6. API 层(分页 + 导出)
在对应 `src/api/*.ts` 中(命名随模块):
```typescript
// 分页
pageRecords(params?: RecordQueryParams): Promise<MpPage<RecordItem>> {
return get<MpPage<RecordItem>>('/cms-api/.../page', params)
}
// 导出 blob
exportRecordsExcel(params?: RecordQueryParams): Promise<Blob> {
return request.get('/cms-api/.../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`)
- [ ] 导出参数与列表筛选一致且无分页字段
- [ ] `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)
# 后台列表页:结构骨架(无业务字段)
下列占位符落地时替换:`Record*`、`{pageId}`、`{apiPrefix}`、`/cms-api/...`。包路径、import 从工作区同类文件复制。
---
## 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 逐项添加 -->
<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">
<template #header>
<div class="ops-column-header">
<span class="ops-column-title">操作</span>
<!-- 列显示 popover(见 SKILL.md §5) -->
</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
const queryForm = reactive({
// 与 RecordQueryParams 对齐
timeRange: null as [string, string] | null
})
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 buildParams(): RecordQueryParams {
const p: RecordQueryParams = {
pageNum: pageNum.value,
pageSize: pageSize.value
}
// if (queryForm.xxx) p.xxx = queryForm.xxx
if (queryForm.timeRange?.length === 2) {
p.createTimeBegin = queryForm.timeRange[0]
p.createTimeEnd = queryForm.timeRange[1]
}
return p
}
function buildExportParams(): RecordQueryParams {
const p: RecordQueryParams = {}
// 与 buildParams 相同筛选,不含 pageNum/pageSize
return p
}
```
---
## 4. 列 localStorage 常量
```typescript
const COLUMN_STORAGE_KEY = '{pageId}-visible-columns'
const COLUMN_ORDER_STORAGE_KEY = '{pageId}-column-order'
type TableColumnKey = 'id' | 'name' /* ... */
const TABLE_COLUMNS: { key: TableColumnKey; label: string }[] = [
{ key: 'id', label: 'ID' }
// ...
]
```
加载时需过滤无效 key,并 append 配置中新增但 storage 中缺失的列。
---
## 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. 表头样式(项目统一色示例)
```typescript
const tableHeaderCellStyle = {
background: '#f0fdfa',
color: '#0f766e',
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 recordApi.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` 未声明,安装后再引用。