# V3 Create Crud

> 创建增删改查（CRUD）页面，基于 Element Plus 组件库，包含表格、搜索、分页、新增/编辑弹窗、删除确认等功能。当用户提到以下任何场景时都应触发：创建管理页面、创建列表页、创建表格页。即使用户没有明确说 CRUD，只要意图是创建带表格和表单操作的后台页面就应该使用此 Skill。使用时需提供模块名称和字段信息。

- Skill: `un-pany/v3-create-crud` (Agent Skill)
- Install (CLI): `npx skillmds@latest add un-pany/v3-create-crud`
- Raw SKILL.md: https://api.skillmd.com/api/skills/un-pany/v3-create-crud/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: un-pany (https://skillmd.com/u/un-pany)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/un-pany/v3-create-crud

---


# 创建 CRUD 页面

根据用户提供的模块名称和字段信息，生成增删改查页面。

本 Skill 定义的是默认 CRUD 模式，当用户的实际需求与本 Skill 约定冲突时，以用户需求为准，灵活调整。

## 输入要求

用户需提供：
1. **模块名称**（如 `product`、`order`）— 用于目录名和命名
2. **字段列表** — 每个字段需明确：
   - 字段名（英文，camelCase）
   - 中文标签
   - 类型（string / number / boolean / enum / date 等）
   - 是否必填
   - 是否作为搜索条件
   - 是否需要自定义渲染（如 tag 状态展示）

如果用户信息不完整，主动询问补全后再生成。

## 生成文件

在 `src/pages/<模块路径>/` 下生成：

1. `index.vue` — 页面主文件
2. `apis/index.ts` — 接口文件
3. `apis/type.ts` — 类型定义

## 设计决策指引

生成代码时需要做出的判断（不要机械套用，根据具体场景决定）：

**弹窗宽度** — 默认用 `30%`，有复杂布局用 `50%` 或更大的比例。

**工具栏按钮** — 按需组合，不必全部包含：
- "新增" 几乎总是需要的
- "批量删除" 仅在有批量操作需求时添加（对应表格 selection 列）
- "下载/导出" 仅在有导出需求时添加
- "刷新当前页" 推荐保留，方便调试和手动刷新

**删除确认文案** — 选择对用户最有辨识度的字段作为确认提示（如用户名、订单编号、商品名称），而不是 id。

**搜索参数传递** — 保持简单，避免类型体操：
- 字段少（2-3 个）时可以逐个列出
- 字段多时用 `...searchData` 展开更简洁
- 核心原则：不要出现 `as any`，除非明确要求

**表单字段 vs 表格列** — 不是所有字段都同时出现在两处：
- 仅创建时需要的字段（如密码）：表单中有，表格中无
- 系统生成的字段（如创建时间）：表格中有，表单中无
- 用 `v-if="formData.id === undefined"` 控制仅新增时显示的字段

## 页面结构规范

页面整体包裹在 `<div class="app-container">` 中，由搜索区域、表格区域、弹窗三部分组成。

### 1. 搜索区域

- `el-card`：`v-loading="loading"` + `shadow="never"` + `class="search-wrapper"`
- 内含 `el-form`：`ref="searchFormRef"` + `:inline="true"` + `:model="searchData"`
- 每个搜索字段设置 `prop`（`resetFields` 依赖它）和 `label`
- 末尾放查询按钮（`:icon="Search"` + `type="primary"`）和重置按钮（`:icon="Refresh"`）

### 2. 表格区域

- `el-card`：`v-loading="loading"` + `shadow="never"`
- 工具栏（`div.toolbar-wrapper`）分左右两侧：
  - 左侧：文字按钮（新增、批量删除等）
  - 右侧：圆形图标按钮 + `el-tooltip`（下载、刷新当前页等）
- 表格（`div.table-wrapper` > `el-table :data="tableData"`）：
  - 需要批量操作时加 `type="selection"` 首列
  - 数据列：`prop` + `label` + `align="center"`
  - 操作列：`fixed="right"` + 适当 `width` + `align="center"`，内含修改/删除按钮（`text bg size="small"`）
  - 操作列按钮将 `scope.row` 传给强类型 handler 时，使用 `scope.row as XxxData` 明确行类型，避免 Element Plus 默认 `DefaultRow` 导致 `vue-tsc` 报错，不要使用 `as any`
- 分页（`div.pager-wrapper` > `el-pagination`）

### 3. 弹窗

- `el-dialog`：`v-model="dialogVisible"` + 通过 `formData.id === undefined` 判断新增/修改标题 + `@closed="resetForm"`
- 使用 `@closed` 而非 `@close`（确保关闭动画结束后再重置，避免用户看到表单内容闪烁）
- 表单：`ref="formRef"` + `:model="formData"` + `:rules="formRules"` + `label-width="auto"`
- footer：取消按钮 + 确认按钮（`type="primary"` + `:loading="loading"`）

## 代码组织规范

使用 `<script lang="ts" setup>` + `defineOptions({ name: "PascalCase 模块名" })`。

顶部声明共享的 `loading` `ref` 和 `usePagination` 解构，分页请求统一通过 `callback`、`resetCurrentPage`、`watchPagination` 组织。

逻辑按增删改查分区，用 `// #region` 和 `// #endregion` 标记：

### 增（Create）

```ts
const DEFAULT_FORM_DATA: CreateOrUpdateXxxRequestData = {
  id: undefined,
  // ... 所有表单字段的初始值
}
const dialogVisible = ref<boolean>(false)
const formRef = useTemplateRef("formRef")
const formData = ref<CreateOrUpdateXxxRequestData>(cloneDeep(DEFAULT_FORM_DATA))
const formRules: FormRules<CreateOrUpdateXxxRequestData> = { /* 验证规则 */ }

function handleCreateOrUpdate() {
  formRef.value?.validate((valid) => {
    if (!valid) { ElMessage.error("表单校验不通过"); return }
    loading.value = true
    const api = formData.value.id === undefined ? createXxxApi : updateXxxApi
    api(formData.value).then(() => {
      ElMessage.success("操作成功")
      dialogVisible.value = false
    }).finally(() => {
      loading.value = false
      getTableData()
    })
  })
}

function resetForm() {
  formRef.value?.clearValidate()
  formData.value = cloneDeep(DEFAULT_FORM_DATA)
}
```

**为什么新增和编辑共用一个弹窗**：减少重复代码，通过 `id` 是否存在区分状态，共享验证规则和提交逻辑。

### 删（Delete）

```ts
function handleDelete(row: XxxData) {
  ElMessageBox.confirm(`正在删除${模块中文名}：${row.辨识字段}，确认删除？`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning"
  }).then(() => {
    loading.value = true
    deleteXxxApi(row.id).then(() => {
      ElMessage.success("删除成功")
    }).finally(() => {
      loading.value = false
      getTableData()
    })
  })
}
```

### 改（Update）

```ts
function handleUpdate(row: XxxData) {
  dialogVisible.value = true
  formData.value = cloneDeep(row)
}
```

**为什么用 `cloneDeep`**：避免编辑时直接修改表格行数据（引用类型），用户取消编辑时表格数据不会被污染。

### 查（Read）

```ts
const tableData = ref<XxxData[]>([])
const searchFormRef = useTemplateRef("searchFormRef")
const searchData = reactive({ /* 搜索字段，初始值为空字符串 */ })

function getTableData() {
  loading.value = true
  getXxxApi({
    currentPage: paginationData.currentPage,
    size: paginationData.pageSize,
    ...searchData
  }).then(({ data }) => {
    paginationData.total = data.total
    tableData.value = data.list
  }).catch(() => {
    tableData.value = []
  }).finally(() => { loading.value = false })
}

function handleSearch() {
  resetCurrentPage()
}

function resetSearch() {
  searchFormRef.value?.resetFields()
  handleSearch()
}
```

**`handleSearch` 的逻辑**：直接调用 `resetCurrentPage`。`resetCurrentPage` 会在当前已是第 1 页时直接请求，否则重置页码并由分页监听触发请求，避免重复调用。

### 分页监听

```ts
watchPagination()
```

**为什么用 `watchPagination` 而不是 `onMounted`**：让分页变化和初始加载共享同一个入口，数据获取逻辑只写一处。

## usePagination 用法

```ts
import { usePagination } from "@@/composables/usePagination"

const { paginationData, resetCurrentPage, watchPagination } = usePagination({
  callback: getTableData
})
```

`paginationData` 是 `reactive` 对象，包含：`total`、`currentPage`、`pageSizes`、`pageSize`、`layout`。模板中使用 `v-model` 双向绑定页码和每页条数：

```html
<el-pagination
  v-model:current-page="paginationData.currentPage"
  v-model:page-size="paginationData.pageSize"
  :page-sizes="paginationData.pageSizes"
  :total="paginationData.total"
  :layout="paginationData.layout"
  background
/>
```

## 表格列的自定义渲染

对于需要视觉区分的字段（状态、角色、类型等），使用 `el-tag` 渲染。思路是：突出重要/异常值，其余用温和颜色兜底。

```html
<!-- boolean：二元对立，可以用 success / danger 对比 -->
<el-table-column prop="status" label="状态" align="center">
  <template #default="scope">
    <el-tag v-if="scope.row.status" type="success" effect="plain" disable-transitions>启用</el-tag>
    <el-tag v-else type="danger" effect="plain" disable-transitions>禁用</el-tag>
  </template>
</el-table-column>

<!-- enum：值高亮 -->
<el-table-column prop="roles" label="角色" align="center">
  <template #default="scope">
    <el-tag v-if="scope.row.roles === 'admin'" type="primary" effect="plain" disable-transitions>admin</el-tag>
    <el-tag v-else type="warning" effect="plain" disable-transitions>{{ scope.row.roles }}</el-tag>
  </template>
</el-table-column>
```

当 enum 值需要明确区分时，逐个用 `v-if / v-else-if` 列出，最后用 `v-else` 兜底。

## 表单字段组件选择

根据字段语义选择组件：

| 字段类型 | 组件 | 备注 |
|---------|------|------|
| string（短文本）| `el-input` | |
| string（长文本）| `el-input type="textarea"` | 描述、备注等 |
| string（密码）| `el-input type="password"` | |
| number | `el-input-number` | `:min` / `:max` 根据业务约束设定 |
| boolean | `el-switch` | |
| enum | `el-select` + `el-option` | 搜索区域加 `clearable` |
| date | `el-date-picker type="date" value-format="YYYY-MM-DD"` | |
| datetime | `el-date-picker type="datetime" value-format="YYYY-MM-DD HH:mm:ss"` | |

验证规则的 `trigger`：输入型组件（input、textarea）用 `"blur"`，选择型组件（select、date-picker、switch）用 `"change"`。

## 接口规范

接口文件使用命名空间导入类型：`import type * as Xxx from "./type"`

导出四个函数（注释用"增删改查"标记）：

- `createXxxApi` — POST
- `deleteXxxApi` — DELETE，参数为 id
- `updateXxxApi` — PUT
- `getXxxApi` — GET，参数为分页 + 搜索条件

使用 `import { request } from "@/http/axios"` 发起请求。查询接口需指定泛型：`request<Xxx.XxxResponseData>({ ... })`。

## 类型规范

类型文件导出（`ApiResponseData` 是全局类型，无需导入）：

- `CreateOrUpdateXxxRequestData` — 表单提交数据（`id?: number` + 各表单字段）
- `XxxRequestData` — 列表查询参数（`currentPage: number` + `size: number` + 搜索字段用 `?:` 可选标记）
- `XxxData` — 表格行数据（`id: number` + 各展示字段）
- `XxxResponseData` — `ApiResponseData<{ list: XxxData[]; total: number }>`

搜索字段在 `XxxRequestData` 中默认用 `string` 类型（除非明确指定类型），因为搜索框传递的默认是字符串值（除非明确指定类型），需直接保持类型正常，不要随意使用类型断言。

页面中使用具名导入：`import type { CreateOrUpdateXxxRequestData, XxxData } from "./apis/type"`

## 导入规范

```ts
// 类型导入
import type { CreateOrUpdateXxxRequestData, XxxData } from "./apis/type"
import type { FormRules } from "element-plus"

// API 导入
import { createXxxApi, deleteXxxApi, getXxxApi, updateXxxApi } from "./apis"

// Composable 导入
import { usePagination } from "@@/composables/usePagination"

// 图标导入（按需，只导入实际使用的）
import { CirclePlus, Delete, Download, Refresh, RefreshRight, Search } from "@element-plus/icons-vue"

// 工具导入
import { cloneDeep } from "lodash-es"
```

以下为自动导入，无需手动引入：`ElMessage`、`ElMessageBox`、`ref`、`reactive`、`useTemplateRef`。

## 样式规范

使用 `<style lang="scss" scoped>`，以下为推荐的基础样式：

```scss
.search-wrapper {
  margin-bottom: 20px;
  :deep(.el-card__body) {
    padding-bottom: 2px;
  }
}

.toolbar-wrapper {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
}

.table-wrapper {
  margin-bottom: 20px;
}

.pager-wrapper {
  display: flex;
  justify-content: flex-end;
}
```

## 命名约定

模块名为 `product` 时：

| 位置 | 命名 |
|------|------|
| 目录 | `src/pages/.../product/` |
| 组件 name | `Product` |
| API 函数 | `createProductApi` / `deleteProductApi` / `updateProductApi` / `getProductApi` |
| 类型 | `CreateOrUpdateProductRequestData` / `ProductRequestData` / `ProductData` / `ProductResponseData` |
| 命名空间 | `import type * as Product from "./type"` |

## 路由提示

生成代码后，提醒用户在路由配置中添加对应路由。

