mirror of
https://github.com/fantastic-admin/basic.git
synced 2026-10-06 13:43:32 +08:00
feat: 新增 fa-crud-page-generator skill 以及示例页面
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: fa-crud-page-generator
|
||||
description: "为 Fantastic-admin 框架生成完整的 CRUD 业务模块,包含列表页、详情/编辑页、表单组件、API 模块和 Mock 数据。当用户说:'帮我做一个商品管理页面'、'搭一个用户列表,能搜索、能分页、能新增编辑删除'、'生成一个订单模块'、'我需要一个带增删改查的页面'、'快速搭一个管理页面骨架',即使用户只是说'新建一个 XX 管理模块'也应触发此技能。"
|
||||
---
|
||||
|
||||
# CRUD 页面生成器
|
||||
|
||||
在 Fantastic-admin 框架中生成完整的 CRUD 业务模块,一次性生成所有相关文件,让开发者专注于业务逻辑而非重复代码。
|
||||
|
||||
**生成的文件清单:**
|
||||
- `apps/<app>/src/views/{path}/{name}/list.vue` — 列表页(搜索栏 + 表格 + 分页)
|
||||
- `apps/<app>/src/views/{path}/{name}/detail.vue` — 详情/编辑页(仅 router 模式需要)
|
||||
- `apps/<app>/src/views/{path}/{name}/components/DetailForm/index.vue` — 表单组件
|
||||
- `apps/<app>/src/api/modules/{fileName}.ts` — API 模块
|
||||
- `apps/<app>/src/api/modules/{fileName}.fake.ts` — Mock 数据(可选)
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认工作区(必须阻塞等待用户回复)
|
||||
|
||||
本项目是 monorepo 架构,`apps/` 目录下存放各应用。**在执行任何文件读写操作之前**,必须先确认目标应用:
|
||||
|
||||
1. 执行 `ls apps/` 列出所有可用应用
|
||||
2. **立即向用户提问**,明确询问要在哪个应用中生成 CRUD 模块,并**停止等待回复**
|
||||
3. 收到用户明确回复后,才能继续后续步骤
|
||||
|
||||
> **严格规则**:如果用户没有在请求中明确说明目标应用(例如"在 example 应用中"、"apps/core"),则必须提问,不得自行猜测或默认选择任何应用。
|
||||
|
||||
确认后,后续所有文件路径均以该应用目录为根。
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### Step 1:收集基本信息
|
||||
|
||||
向用户询问以下内容(可合并为一次提问):
|
||||
|
||||
1. **模块名**(英文,用于文件名和路由,如 `user`、`product`、`order`)
|
||||
2. **模块中文名**(用于页面标题和提示文字,如 `用户`、`商品`、`订单`)
|
||||
3. **存放路径**(在 `apps/<app>/src/views/` 下的子目录,如 `system`、`mall/product`,留空则直接放在 `src/views/` 下)
|
||||
|
||||
### Step 2:收集字段信息
|
||||
|
||||
询问用户该模块有哪些字段,每个字段需要:
|
||||
- **字段名**(英文,如 `name`、`status`、`createdAt`)
|
||||
- **中文标签**(如 `姓名`、`状态`、`创建时间`)
|
||||
- **字段类型**(`string` | `number` | `boolean` | `date`)
|
||||
- **是否在列表中显示**
|
||||
- **是否在搜索栏中显示**
|
||||
- **是否在表单中显示**(新增/编辑)
|
||||
- **是否必填**(表单验证)
|
||||
|
||||
如果用户没有提供字段信息,使用默认字段 `title`(标题,string,必填)作为示例占位,并在生成后提示用户替换。
|
||||
|
||||
### Step 3:收集功能选项
|
||||
|
||||
以下三个选项会直接影响生成的文件结构,需要在同一轮提问中一起确认,避免后续重复生成:
|
||||
|
||||
4. **详情展示模式**:
|
||||
- `router` — 跳转到独立的详情页(默认,适合复杂表单)
|
||||
- `modal` — 弹窗(适合简单表单)
|
||||
- `drawer` — 抽屉(适合中等复杂度表单)
|
||||
5. **是否生成 Mock 数据**(是/否,开发阶段推荐开启)
|
||||
6. **是否同时生成路由配置**(是/否,仅 `app.routeBaseOn` 为 `frontend` 时有效;选是时需额外询问归属的主导航分组名称)
|
||||
|
||||
### Step 4:确认并生成
|
||||
|
||||
汇总信息,展示将要生成的文件列表,确认后写入文件。
|
||||
|
||||
---
|
||||
|
||||
## 命名规范
|
||||
|
||||
给定模块名 `name` 和路径 `path`(相对于 `apps/<app>/src/views/`):
|
||||
|
||||
| 用途 | 规则 | 示例(path=system, name=user) |
|
||||
|------|------|-------------------------------|
|
||||
| 视图目录 | `apps/<app>/src/views/{path}/{snakeCase(name)}/` | `apps/<app>/src/views/system/user/` |
|
||||
| API 文件名 | path 非空时:`{camelCase(path)}_{camelCase(name)}`;path 为空时:`{camelCase(name)}` | `systemUser` |
|
||||
| 组件名 | `PascalCase({path}-{name}-list/detail)` | `SystemUserList` |
|
||||
| API URL 前缀 | `/{path}/{snakeCase(name)}/` | `/system/user/` |
|
||||
|
||||
**snakeCase 规则**:多个单词用下划线连接,如 `productCategory` → `product_category`
|
||||
|
||||
---
|
||||
|
||||
## 代码模板
|
||||
|
||||
详细的代码模板见 [references/templates.md](references/templates.md),其中也包含 faker.js 字段类型映射表。
|
||||
|
||||
生成代码时,根据用户提供的字段信息替换模板中的占位符:
|
||||
- 列表页的 `ElTableColumn` 根据"列表显示"字段生成
|
||||
- 搜索栏的 `ElFormItem` 根据"搜索显示"字段生成
|
||||
- 表单组件的 `ElFormItem` 根据"表单显示"字段生成
|
||||
- API 模块的 TypeScript 类型根据字段定义生成
|
||||
- Mock 数据根据字段类型使用合适的 faker.js 方法生成(见 templates.md 中的映射表)
|
||||
|
||||
---
|
||||
|
||||
## 路由生成(可选)
|
||||
|
||||
如果用户选择同时生成路由,先读取 `apps/<app>/src/settings.ts` 确认 `app.routeBaseOn === 'frontend'`,然后使用 `fa-route-generator` 技能生成对应路由配置:
|
||||
- **router 模式**:生成列表路由 + 详情路由(详情路由带 `menu: false` 和 `activeMenu` 指向列表)
|
||||
- **modal/drawer 模式**:只生成列表路由
|
||||
|
||||
---
|
||||
|
||||
## 生成后提示用户
|
||||
|
||||
文件生成完成后简要告知:如果未生成路由,可用 `fa-route-generator` 技能添加;生成了 `.fake.ts` 则 API 请求会自动走 mock;表单字段类型(Select、DatePicker 等)需根据业务自行替换。
|
||||
@@ -0,0 +1,614 @@
|
||||
# CRUD 代码模板
|
||||
|
||||
以下模板基于 `src/views/pages_example/manager` 真实模块提炼,使用以下占位符:
|
||||
|
||||
- `{cname}` — 模块中文名,如 `用户`
|
||||
- `{componentNameList}` — 列表页组件名(PascalCase),如 `SystemUserList`
|
||||
- `{componentNameDetail}` — 详情页组件名(PascalCase),如 `SystemUserDetail`
|
||||
- `{fileName}` — API 文件名(camelCase),如 `systemUser`
|
||||
- `{apiPrefix}` — API URL 前缀(无前导斜杠),如 `system/user`
|
||||
- `{routeListName}` — 列表路由 name,如 `systemUserList`
|
||||
- `{routeDetailName}` — 详情路由 name,如 `systemUserDetail`(仅 router 模式)
|
||||
|
||||
---
|
||||
|
||||
## list.vue 模板
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import api{FileName} from '@/api/modules/{fileName}'
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import DetailForm from './components/DetailForm/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: '{componentNameList}',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { pagination, getParams, onSizeChange, onCurrentChange, onSortChange } = usePagination()
|
||||
|
||||
// 表格是否自适应高度
|
||||
const tableAutoHeight = ref(false)
|
||||
|
||||
/**
|
||||
* 详情展示模式
|
||||
* router 路由跳转
|
||||
* modal 模态框
|
||||
* drawer 抽屉
|
||||
*/
|
||||
const formMode = ref<'router' | 'modal' | 'drawer'>('{formMode}')
|
||||
|
||||
// 详情
|
||||
const formModeProps = ref({
|
||||
id: '',
|
||||
})
|
||||
|
||||
// 搜索
|
||||
const searchDefault = {
|
||||
{searchDefaults}
|
||||
}
|
||||
const search = ref({ ...searchDefault })
|
||||
function searchReset() {
|
||||
Object.assign(search.value, searchDefault)
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
const batch = ref({
|
||||
enable: true,
|
||||
selectionDataList: [],
|
||||
})
|
||||
|
||||
// 列表
|
||||
const loading = ref(false)
|
||||
const dataList = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
if (formMode.value === 'router') {
|
||||
eventBus.on('get-data-list', () => {
|
||||
getDataList()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (formMode.value === 'router') {
|
||||
eventBus.off('get-data-list')
|
||||
}
|
||||
})
|
||||
|
||||
function getDataList() {
|
||||
loading.value = true
|
||||
const params = {
|
||||
...getParams(),
|
||||
{searchParams}
|
||||
}
|
||||
api{FileName}.list(params).then((res: any) => {
|
||||
loading.value = false
|
||||
dataList.value = res.data.list
|
||||
pagination.value.total = res.data.total
|
||||
})
|
||||
}
|
||||
|
||||
// 每页数量切换
|
||||
function sizeChange(size: number) {
|
||||
onSizeChange(size).then(() => getDataList())
|
||||
}
|
||||
|
||||
// 当前页码切换(翻页)
|
||||
function currentChange(page = 1) {
|
||||
onCurrentChange(page).then(() => getDataList())
|
||||
}
|
||||
|
||||
// 字段排序
|
||||
function sortChange({ prop, order }: { prop: string, order: string }) {
|
||||
onSortChange(prop, order).then(() => getDataList())
|
||||
}
|
||||
|
||||
const formRef = ref<InstanceType<typeof DetailForm>>()
|
||||
|
||||
const { open: openModal, update: updateModal } = useFaModal().create({
|
||||
destroyOnClose: true,
|
||||
closeOnClickOverlay: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, done) => {
|
||||
if (action === 'confirm') {
|
||||
// 调用 DetailForm 组件内部 submit 方法
|
||||
formRef.value?.submit().then(() => {
|
||||
getDataList()
|
||||
done()
|
||||
})
|
||||
}
|
||||
else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
content: () => h(DetailForm, {
|
||||
ref: formRef,
|
||||
id: formModeProps.value.id,
|
||||
}),
|
||||
})
|
||||
|
||||
const { open: openDrawer, update: updateDrawer } = useFaDrawer().create({
|
||||
destroyOnClose: true,
|
||||
closeOnClickOverlay: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, done) => {
|
||||
if (action === 'confirm') {
|
||||
// 调用 DetailForm 组件内部 submit 方法
|
||||
formRef.value?.submit().then(() => {
|
||||
getDataList()
|
||||
done()
|
||||
})
|
||||
}
|
||||
else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
content: () => h(DetailForm, {
|
||||
ref: formRef,
|
||||
id: formModeProps.value.id,
|
||||
}),
|
||||
})
|
||||
|
||||
function onCreate() {
|
||||
if (formMode.value === 'router') {
|
||||
router.push({ name: '{routeDetailName}' })
|
||||
}
|
||||
else {
|
||||
formModeProps.value.id = ''
|
||||
if (formMode.value === 'modal') {
|
||||
updateModal({ title: '新增{cname}' })
|
||||
openModal()
|
||||
}
|
||||
else {
|
||||
updateDrawer({ title: '新增{cname}' })
|
||||
openDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: any) {
|
||||
if (formMode.value === 'router') {
|
||||
router.push({ name: '{routeDetailName}', params: { id: row.id } })
|
||||
}
|
||||
else {
|
||||
formModeProps.value.id = row.id
|
||||
if (formMode.value === 'modal') {
|
||||
updateModal({ title: '编辑{cname}' })
|
||||
openModal()
|
||||
}
|
||||
else {
|
||||
updateDrawer({ title: '编辑{cname}' })
|
||||
openDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDel(row: any) {
|
||||
useFaModal().confirm({
|
||||
title: '确认信息',
|
||||
content: `确认删除「${row.{firstField}}」吗?`,
|
||||
onConfirm: () => {
|
||||
api{FileName}.delete(row.id).then(() => {
|
||||
getDataList()
|
||||
faToast.success('删除成功')
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'absolute flex flex-col size-full': tableAutoHeight }">
|
||||
<FaPageHeader title="{cname}管理" class="mb-0" />
|
||||
<FaPageMain :class="{ 'flex-1 overflow-auto': tableAutoHeight }" :main-class="{ 'flex-1 flex flex-col overflow-auto': tableAutoHeight }">
|
||||
<FaSearchBar :show-toggle="false">
|
||||
<template #default="{ fold, toggle }">
|
||||
<div class="gap-x-8 gap-y-2 grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
|
||||
{fields.search}
|
||||
<div class="flex gap-2 col-end--1 justify-end">
|
||||
<FaButton variant="outline" @click="searchReset(); currentChange()">
|
||||
重置
|
||||
</FaButton>
|
||||
<FaButton type="primary" @click="currentChange()">
|
||||
<FaIcon name="i-ri:search-line" />
|
||||
筛选
|
||||
</FaButton>
|
||||
<FaButton variant="ghost" @click="toggle">
|
||||
{{ fold ? '展开' : '收起' }}
|
||||
<FaIcon :name="fold ? 'i-ep:caret-bottom' : 'i-ep:caret-top'" />
|
||||
</FaButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaSearchBar>
|
||||
<div class="mx--5 my-4 border-t border-t-dashed" />
|
||||
<div class="flex-center-between gap-2">
|
||||
<FaButton v-if="batch.enable" variant="outline" :disabled="!batch.selectionDataList.length">
|
||||
批量操作
|
||||
</FaButton>
|
||||
<FaButton @click="onCreate">
|
||||
<FaIcon name="i-ri:add-line" />
|
||||
新增
|
||||
</FaButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" class="my-4" :data="dataList" stripe highlight-current-row border height="100%" @sort-change="sortChange" @selection-change="batch.selectionDataList = $event">
|
||||
<ElTableColumn v-if="batch.enable" type="selection" align="center" fixed />
|
||||
{fields.list}
|
||||
<ElTableColumn label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="flex-center gap-2">
|
||||
<FaButton variant="outline" size="icon-sm" @click="onEdit(scope.row)">
|
||||
<FaIcon name="i-ri:edit-line" />
|
||||
</FaButton>
|
||||
<FaDropdown
|
||||
:items="[
|
||||
[
|
||||
{ label: '删除', variant: 'destructive', handle: () => onDel(scope.row) },
|
||||
],
|
||||
]"
|
||||
>
|
||||
<FaButton variant="outline" size="icon-sm">
|
||||
<FaIcon name="i-ri:more-line" />
|
||||
</FaButton>
|
||||
</FaDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<FaPagination :page="pagination.page" :size="pagination.size" :total="pagination.total" @page-change="currentChange" @size-change="sizeChange" />
|
||||
</FaPageMain>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> 注意:`list.vue` 不需要 `<style scoped>` 块,所有样式均通过 UnoCSS 工具类实现。
|
||||
|
||||
---
|
||||
|
||||
## detail.vue 模板(仅 router 模式)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import DetailForm from './components/DetailForm/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: '{componentNameDetail}',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
|
||||
function onSubmit() {
|
||||
formRef.value?.submit().then(() => {
|
||||
eventBus.emit('get-data-list')
|
||||
onCancel()
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back({ name: '{routeListName}' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FaFixedBar position="top" class="p-0">
|
||||
<FaPageHeader :title="route.params.id ? '编辑{cname}' : '新增{cname}'" class="mb-0 border-b-none">
|
||||
<FaButton variant="outline" size="sm" class="rounded-full" @click="onCancel">
|
||||
<FaIcon name="i-ep:arrow-left" />
|
||||
返回
|
||||
</FaButton>
|
||||
</FaPageHeader>
|
||||
</FaFixedBar>
|
||||
<FaPageMain>
|
||||
<ElRow>
|
||||
<ElCol :md="24" :lg="16">
|
||||
<DetailForm :id="route.params.id as string" ref="formRef" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</FaPageMain>
|
||||
<FaFixedBar position="bottom" class="flex-center gap-4">
|
||||
<FaButton @click="onSubmit">
|
||||
提交
|
||||
</FaButton>
|
||||
<FaButton variant="outline" @click="onCancel">
|
||||
取消
|
||||
</FaButton>
|
||||
</FaFixedBar>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## components/DetailForm/index.vue 模板
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import api{FileName} from '@/api/modules/{fileName}'
|
||||
|
||||
export interface Props {
|
||||
id?: number | string
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<Props>(),
|
||||
{
|
||||
id: '',
|
||||
},
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||
const form = ref({
|
||||
id: props.id,
|
||||
{fields.formInit}
|
||||
})
|
||||
const formRules = ref<FormRules>({
|
||||
{fields.rules}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (form.value.id !== '') {
|
||||
getInfo()
|
||||
}
|
||||
})
|
||||
|
||||
function getInfo() {
|
||||
loading.value = true
|
||||
api{FileName}.detail(form.value.id).then((res: any) => {
|
||||
loading.value = false
|
||||
{fields.formAssign}
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit() {
|
||||
return new Promise<void>((resolve) => {
|
||||
formRef.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
if (form.value.id === '') {
|
||||
api{FileName}.create(form.value).then(() => {
|
||||
faToast.success('新增成功')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
else {
|
||||
api{FileName}.edit(form.value).then(() => {
|
||||
faToast.success('编辑成功')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px" label-suffix=":">
|
||||
{fields.form}
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## api.ts 模板
|
||||
|
||||
```typescript
|
||||
import api from '../index'
|
||||
|
||||
export default {
|
||||
list: (data: {
|
||||
{searchTypes}
|
||||
from: number
|
||||
limit: number
|
||||
}) => api.get('{apiPrefix}/list', {
|
||||
params: data,
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
detail: (id: number | string) => api.get('{apiPrefix}/detail', {
|
||||
params: { id },
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
create: (data: any) => api.post('{apiPrefix}/create', data, {
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
edit: (data: any) => api.post('{apiPrefix}/edit', data, {
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
delete: (id: number | string) => api.post('{apiPrefix}/delete', { id }, {
|
||||
fake: true,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
> 不需要 Mock 数据时,去掉所有 `fake: true` 选项即可。
|
||||
|
||||
---
|
||||
|
||||
## fake.ts 模板
|
||||
|
||||
```typescript
|
||||
import { faker } from '@faker-js/faker/locale/zh_CN'
|
||||
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
|
||||
|
||||
const {moduleName}List: any[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
{moduleName}List.push({
|
||||
id: i + 1,
|
||||
{fields.mock}
|
||||
})
|
||||
}
|
||||
|
||||
export default defineFakeRoute([
|
||||
{
|
||||
url: '/fake/{apiPrefix}/list',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const { {searchQueryFields}, from, limit } = query
|
||||
let list = {moduleName}List
|
||||
{searchFilters}
|
||||
const pageList = list.filter((_item, index) => {
|
||||
return index >= ~~from && index < (~~from + ~~limit)
|
||||
})
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
list: pageList,
|
||||
total: list.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/detail',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const info = {moduleName}List.filter(item => item.id === ~~query.id)
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: info[0],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/create',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return { error: '', status: 1, data: { isSuccess: true } }
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/edit',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return { error: '', status: 1, data: { isSuccess: true } }
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/delete',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return { error: '', status: 1, data: { isSuccess: true } }
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段生成规则
|
||||
|
||||
### 搜索栏字段
|
||||
|
||||
每个搜索字段用 `FaLabel` 包裹,内部控件根据字段类型自行选择合适的 Fa* 或 El* 组件。次要搜索条件加 `v-show="!fold"` 实现折叠。
|
||||
|
||||
```vue
|
||||
<FaLabel label="{label}" class="col-span-1">
|
||||
<!-- 内部放对应的输入控件,string 用 FaInput,枚举用 FaSelect,日期用 ElDatePicker 等 -->
|
||||
</FaLabel>
|
||||
```
|
||||
|
||||
### 列表列
|
||||
|
||||
普通列直接用 `prop` 渲染,需要自定义展示时使用 `#default` slot:
|
||||
|
||||
```vue
|
||||
<!-- 普通列 -->
|
||||
<ElTableColumn prop="{field}" label="{label}" />
|
||||
|
||||
<!-- 需要自定义渲染时 -->
|
||||
<ElTableColumn prop="{field}" label="{label}" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<!-- 根据字段含义选择合适的展示方式,如 ElTag、FaSwitch 等 -->
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
```
|
||||
|
||||
### 表单字段
|
||||
|
||||
每个表单字段用 `ElFormItem` 包裹,内部控件根据字段类型自行选择合适的 Fa* 或 El* 组件,注意加 `class="w-full"`。
|
||||
|
||||
```vue
|
||||
<ElFormItem label="{label}" prop="{field}">
|
||||
<!-- 内部放对应的输入控件 -->
|
||||
</ElFormItem>
|
||||
```
|
||||
|
||||
### 表单验证规则
|
||||
|
||||
```typescript
|
||||
// 必填文本
|
||||
{field}: [
|
||||
{ required: true, message: '请输入{label}', trigger: 'blur' },
|
||||
],
|
||||
// 必填选择
|
||||
{field}: [
|
||||
{ required: true, message: '请选择{label}', trigger: 'change' },
|
||||
],
|
||||
```
|
||||
|
||||
### Mock 数据字段映射
|
||||
|
||||
| 字段类型 | faker 方法 |
|
||||
|---------|-----------|
|
||||
| 姓名 | `faker.person.fullName()` |
|
||||
| 账号/用户名 | `faker.person.firstName()` |
|
||||
| 手机号 | `faker.phone.number({ style: 'international' })` |
|
||||
| 邮箱 | `faker.internet.email()` |
|
||||
| 标题/名称 | `faker.lorem.words(3)` |
|
||||
| 描述 | `faker.lorem.sentence()` |
|
||||
| 数字(枚举) | `faker.number.int(2)` |
|
||||
| 布尔(状态) | `faker.datatype.boolean()` |
|
||||
| 金额 | `faker.number.float({ min: 10, max: 9999, fractionDigits: 2 })` |
|
||||
| 日期 | `faker.date.recent().toISOString()` |
|
||||
|
||||
### Mock 搜索过滤模式
|
||||
|
||||
**string 模糊匹配:**
|
||||
```typescript
|
||||
list = list.filter((item) => {
|
||||
return {field} ? item.{field}.includes({field}) : true
|
||||
})
|
||||
```
|
||||
|
||||
**枚举/数字精确匹配(注意:query 参数均为字符串,`0` 是 falsy,不能直接用 `{field} ?` 判断):**
|
||||
```typescript
|
||||
list = list.filter((item) => {
|
||||
return {field} !== undefined && {field} !== '' ? item.{field} === ~~{field} : true
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## faker.js 字段类型映射
|
||||
|
||||
| 字段类型 | 推荐 faker 方法 |
|
||||
|---------|----------------|
|
||||
| string(名称类) | `faker.person.fullName()` 或 `faker.commerce.productName()` |
|
||||
| string(标题类) | `faker.lorem.words(3)` |
|
||||
| string(描述类) | `faker.lorem.sentence()` |
|
||||
| number(ID) | 自增 `i + 1` |
|
||||
| number(金额) | `faker.number.float({ min: 10, max: 9999, fractionDigits: 2 })` |
|
||||
| number(数量) | `faker.number.int({ min: 1, max: 100 })` |
|
||||
| boolean | `faker.datatype.boolean()` |
|
||||
| date | `faker.date.recent().toISOString()` |
|
||||
| status(枚举) | `faker.helpers.arrayElement([0, 1, 2])` |
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
|
||||
|
||||
// 标准模块
|
||||
const standardModuleList: any[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
standardModuleList.push({
|
||||
id: i + 1,
|
||||
title: faker.book.title(),
|
||||
})
|
||||
}
|
||||
|
||||
export default defineFakeRoute([
|
||||
{
|
||||
url: '/fake/standard_module/list',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const { title, from, limit } = query
|
||||
const list = standardModuleList.filter((item) => {
|
||||
return title ? item.title.includes(title) : true
|
||||
})
|
||||
const pageList = list.filter((_item, index) => {
|
||||
return index >= ~~from && index < (~~from + ~~limit)
|
||||
})
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
list: pageList,
|
||||
total: list.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/standard_module/detail',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const info = standardModuleList.filter(item => item.id === ~~query.id)
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: info[0],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/standard_module/create',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/standard_module/edit',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/standard_module/delete',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import api from '../index'
|
||||
|
||||
export default {
|
||||
list: (data: {
|
||||
title?: string
|
||||
from: number
|
||||
limit: number
|
||||
}) => api.get('standard_module/list', {
|
||||
params: data,
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
detail: (id: number | string) => api.get('standard_module/detail', {
|
||||
params: {
|
||||
id,
|
||||
},
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
create: (data: any) => api.post('standard_module/create', data, {
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
edit: (data: any) => api.post('standard_module/edit', data, {
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
delete: (id: number | string) => api.post('standard_module/delete', {
|
||||
id,
|
||||
}, {
|
||||
fake: true,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
function Layout() {
|
||||
return import('@/layouts/index.vue')
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
path: '/standard_module_example',
|
||||
component: Layout,
|
||||
name: 'standardModuleExample',
|
||||
meta: {
|
||||
title: '标准模块',
|
||||
icon: 'i-ic:round-view-module',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'standardModuleExampleList',
|
||||
component: () => import('@/views/standard_module_example/list.vue'),
|
||||
meta: {
|
||||
title: '列表',
|
||||
menu: false,
|
||||
breadcrumb: false,
|
||||
keepAlive: 'standardModuleExampleDetail',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'detail/:id?',
|
||||
name: 'standardModuleExampleDetail',
|
||||
component: () => import('@/views/standard_module_example/detail.vue'),
|
||||
meta: {
|
||||
title: '...',
|
||||
menu: false,
|
||||
activeMenu: '/standard_module_example',
|
||||
keepAlive: true,
|
||||
noKeepAlive: 'standardModuleExampleList',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default routes
|
||||
@@ -14,6 +14,7 @@ import JsxExample from './modules/jsx.example'
|
||||
import KeepAliveExample from './modules/keep.alive.example'
|
||||
import MultilevelMenuExample from './modules/multilevel.menu.example'
|
||||
import PluginExample from './modules/plugin.example'
|
||||
import StandardModuleExample from './modules/standard.module.example'
|
||||
import TabbarExample from './modules/tabbar.example'
|
||||
|
||||
// 固定路由(默认路由)
|
||||
@@ -87,6 +88,7 @@ const asyncRoutes: RouteRecordMainRaw[] = [
|
||||
FakeExample,
|
||||
JsxExample,
|
||||
ExternalLinkExample,
|
||||
StandardModuleExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import apiStandardModule from '@/api/modules/standardModule'
|
||||
|
||||
export interface Props {
|
||||
id?: number | string
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<Props>(),
|
||||
{
|
||||
id: '',
|
||||
},
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||
const form = ref({
|
||||
id: props.id,
|
||||
title: '',
|
||||
})
|
||||
const formRules = ref<FormRules>({
|
||||
title: [
|
||||
{ required: true, message: '请输入标题', trigger: 'blur' },
|
||||
],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (form.value.id !== '') {
|
||||
getInfo()
|
||||
}
|
||||
})
|
||||
|
||||
function getInfo() {
|
||||
loading.value = true
|
||||
apiStandardModule.detail(form.value.id).then((res: any) => {
|
||||
loading.value = false
|
||||
form.value.title = res.data.title
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit() {
|
||||
return new Promise<void>((resolve) => {
|
||||
formRef.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
if (form.value.id === '') {
|
||||
apiStandardModule.create(form.value).then(() => {
|
||||
faToast.success('新增成功')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
else {
|
||||
apiStandardModule.edit(form.value).then(() => {
|
||||
faToast.success('编辑成功')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px" label-suffix=":">
|
||||
<ElFormItem label="标题" prop="title">
|
||||
<FaInput v-model="form.title" placeholder="请输入标题" class="w-full" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import DetailForm from './components/DetailForm/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'PagesExampleFormModeDetail',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
|
||||
function onSubmit() {
|
||||
formRef.value?.submit().then(() => {
|
||||
eventBus.emit('get-data-list')
|
||||
onCancel()
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back({ name: 'standardModuleExampleList' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FaFixedBar position="top" class="p-0">
|
||||
<FaPageHeader :title="route.params.id ? '编辑标准模块' : '新增标准模块'" class="mb-0 border-b-none">
|
||||
<FaButton variant="outline" size="sm" class="rounded-full" @click="onCancel">
|
||||
<FaIcon name="i-ep:arrow-left" />
|
||||
返回
|
||||
</FaButton>
|
||||
</FaPageHeader>
|
||||
</FaFixedBar>
|
||||
<FaPageMain>
|
||||
<ElRow>
|
||||
<ElCol :md="24" :lg="16">
|
||||
<DetailForm :id="route.params.id as string" ref="formRef" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</FaPageMain>
|
||||
<FaFixedBar position="bottom" class="flex-center gap-4">
|
||||
<FaButton @click="onSubmit">
|
||||
提交
|
||||
</FaButton>
|
||||
<FaButton variant="outline" @click="onCancel">
|
||||
取消
|
||||
</FaButton>
|
||||
</FaFixedBar>
|
||||
</div>
|
||||
</template>
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import apiStandardModule from '@/api/modules/standardModule'
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import DetailForm from './components/DetailForm/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'PagesExampleFormModeList',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { pagination, getParams, onSizeChange, onCurrentChange, onSortChange } = usePagination()
|
||||
|
||||
// 表格是否自适应高度
|
||||
const tableAutoHeight = ref(false)
|
||||
|
||||
/**
|
||||
* 详情展示模式
|
||||
* router 路由跳转
|
||||
* modal 模态框
|
||||
* drawer 抽屉
|
||||
*/
|
||||
const formMode = ref<'router' | 'modal' | 'drawer'>('router')
|
||||
|
||||
// 详情
|
||||
const formModeProps = ref({
|
||||
id: '',
|
||||
})
|
||||
|
||||
// 搜索
|
||||
const searchDefault = {
|
||||
title: '',
|
||||
title2: '',
|
||||
title3: '',
|
||||
title4: '',
|
||||
}
|
||||
const search = ref({ ...searchDefault })
|
||||
function searchReset() {
|
||||
Object.assign(search.value, searchDefault)
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
const batch = ref({
|
||||
enable: true,
|
||||
selectionDataList: [],
|
||||
})
|
||||
|
||||
// 列表
|
||||
const loading = ref(false)
|
||||
const dataList = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
if (formMode.value === 'router') {
|
||||
eventBus.on('get-data-list', () => {
|
||||
getDataList()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (formMode.value === 'router') {
|
||||
eventBus.off('get-data-list')
|
||||
}
|
||||
})
|
||||
|
||||
function getDataList() {
|
||||
loading.value = true
|
||||
const params = {
|
||||
...getParams(),
|
||||
...(search.value.title && { title: search.value.title }),
|
||||
}
|
||||
apiStandardModule.list(params).then((res: any) => {
|
||||
loading.value = false
|
||||
dataList.value = res.data.list
|
||||
pagination.value.total = res.data.total
|
||||
})
|
||||
}
|
||||
|
||||
// 每页数量切换
|
||||
function sizeChange(size: number) {
|
||||
onSizeChange(size).then(() => getDataList())
|
||||
}
|
||||
|
||||
// 当前页码切换(翻页)
|
||||
function currentChange(page = 1) {
|
||||
onCurrentChange(page).then(() => getDataList())
|
||||
}
|
||||
|
||||
// 字段排序
|
||||
function sortChange({ prop, order }: { prop: string, order: string }) {
|
||||
onSortChange(prop, order).then(() => getDataList())
|
||||
}
|
||||
|
||||
const formRef = ref<InstanceType<typeof DetailForm>>()
|
||||
|
||||
const { open: openModal, update: updateModal } = useFaModal().create({
|
||||
destroyOnClose: true,
|
||||
closeOnClickOverlay: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, done) => {
|
||||
if (action === 'confirm') {
|
||||
// 调用 DetailForm 组件内部 submit 方法
|
||||
formRef.value?.submit().then(() => {
|
||||
getDataList()
|
||||
done()
|
||||
})
|
||||
}
|
||||
else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
content: () => h(DetailForm, {
|
||||
ref: formRef,
|
||||
id: formModeProps.value.id,
|
||||
}),
|
||||
})
|
||||
|
||||
const { open: openDrawer, update: updateDrawer } = useFaDrawer().create({
|
||||
destroyOnClose: true,
|
||||
closeOnClickOverlay: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, done) => {
|
||||
if (action === 'confirm') {
|
||||
// 调用 DetailForm 组件内部 submit 方法
|
||||
formRef.value?.submit().then(() => {
|
||||
getDataList()
|
||||
done()
|
||||
})
|
||||
}
|
||||
else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
content: () => h(DetailForm, {
|
||||
ref: formRef,
|
||||
id: formModeProps.value.id,
|
||||
}),
|
||||
})
|
||||
|
||||
function onCreate() {
|
||||
if (formMode.value === 'router') {
|
||||
router.push({
|
||||
name: 'standardModuleExampleDetail',
|
||||
})
|
||||
}
|
||||
else {
|
||||
formModeProps.value.id = ''
|
||||
if (formMode.value === 'modal') {
|
||||
updateModal({
|
||||
title: '新增标准模块',
|
||||
})
|
||||
openModal()
|
||||
}
|
||||
else {
|
||||
updateDrawer({
|
||||
title: '新增标准模块',
|
||||
})
|
||||
openDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: any) {
|
||||
if (formMode.value === 'router') {
|
||||
router.push({
|
||||
name: 'standardModuleExampleDetail',
|
||||
params: {
|
||||
id: row.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
else {
|
||||
formModeProps.value.id = row.id
|
||||
if (formMode.value === 'modal') {
|
||||
updateModal({
|
||||
title: '编辑标准模块',
|
||||
})
|
||||
openModal()
|
||||
}
|
||||
else {
|
||||
updateDrawer({
|
||||
title: '编辑标准模块',
|
||||
})
|
||||
openDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDel(row: any) {
|
||||
useFaModal().confirm({
|
||||
title: '确认信息',
|
||||
content: `确认删除「${row.title}」吗?`,
|
||||
onConfirm: () => {
|
||||
apiStandardModule.delete(row.id).then(() => {
|
||||
getDataList()
|
||||
faToast.success('删除成功')
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'absolute flex flex-col size-full': tableAutoHeight }">
|
||||
<FaPageHeader title="标准模块管理" class="mb-0">
|
||||
<template #description>
|
||||
<div class="space-y-3">
|
||||
<p>
|
||||
标准模块即包含 CRUD(增查改删)操作的基础模块,可通过
|
||||
<FaKbd>
|
||||
fa-crud-page-generator
|
||||
</FaKbd>
|
||||
skill 或直接参考本模块的代码,快速生成一个标准模块,并在此基础上完善业务模块所需要的功能。
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-3 items-center">
|
||||
<span class="shrink-0">特性1:列表展示模式</span>
|
||||
<FaButtonGroup>
|
||||
<FaButton :variant="!tableAutoHeight ? 'default' : 'outline'" @click="tableAutoHeight = false">
|
||||
默认
|
||||
</FaButton>
|
||||
<FaButton :variant="tableAutoHeight ? 'default' : 'outline'" @click="tableAutoHeight = true">
|
||||
列表高度自适应
|
||||
</FaButton>
|
||||
</FaButtonGroup>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<span class="shrink-0">特性2:表单展示模式</span>
|
||||
<FaButtonGroup>
|
||||
<FaButton :variant="formMode === 'router' ? 'default' : 'outline'" @click="formMode = 'router'">
|
||||
路由跳转
|
||||
</FaButton>
|
||||
<FaButton :variant="formMode === 'modal' ? 'default' : 'outline'" @click="formMode = 'modal'">
|
||||
对话框
|
||||
</FaButton>
|
||||
<FaButton :variant="formMode === 'drawer' ? 'default' : 'outline'" @click="formMode = 'drawer'">
|
||||
抽屉
|
||||
</FaButton>
|
||||
</FaButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaPageHeader>
|
||||
<FaPageMain :class="{ 'flex-1 overflow-auto': tableAutoHeight }" :main-class="{ 'flex-1 flex flex-col overflow-auto': tableAutoHeight }">
|
||||
<FaSearchBar :show-toggle="false">
|
||||
<template #default="{ fold, toggle }">
|
||||
<div class="gap-x-8 gap-y-2 grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
|
||||
<FaLabel label="标题" class="col-span-1">
|
||||
<FaInput
|
||||
v-model="search.title"
|
||||
placeholder="请输入标题,支持模糊查询"
|
||||
clearable
|
||||
class="w-full"
|
||||
@keydown.enter="currentChange()"
|
||||
@clear="currentChange()"
|
||||
/>
|
||||
</FaLabel>
|
||||
<FaLabel v-show="!fold" label="标题2" class="col-span-1">
|
||||
<FaInput
|
||||
v-model="search.title2"
|
||||
placeholder="请输入标题,支持模糊查询"
|
||||
clearable
|
||||
class="w-full"
|
||||
@keydown.enter="currentChange()"
|
||||
@clear="currentChange()"
|
||||
/>
|
||||
</FaLabel>
|
||||
<FaLabel v-show="!fold" label="标题3" class="col-span-1">
|
||||
<FaInput
|
||||
v-model="search.title3"
|
||||
placeholder="请输入标题,支持模糊查询"
|
||||
clearable
|
||||
class="w-full"
|
||||
@keydown.enter="currentChange()"
|
||||
@clear="currentChange()"
|
||||
/>
|
||||
</FaLabel>
|
||||
<FaLabel v-show="!fold" label="标题4" class="col-span-1">
|
||||
<FaInput
|
||||
v-model="search.title4"
|
||||
placeholder="请输入标题,支持模糊查询"
|
||||
clearable
|
||||
class="w-full"
|
||||
@keydown.enter="currentChange()"
|
||||
@clear="currentChange()"
|
||||
/>
|
||||
</FaLabel>
|
||||
<div class="flex gap-2 col-end--1 justify-end">
|
||||
<FaButton variant="outline" @click="searchReset(); currentChange()">
|
||||
重置
|
||||
</FaButton>
|
||||
<FaButton type="primary" @click="currentChange()">
|
||||
<FaIcon name="i-ri:search-line" />
|
||||
筛选
|
||||
</FaButton>
|
||||
<FaButton variant="ghost" @click="toggle">
|
||||
{{ fold ? '展开' : '收起' }}
|
||||
<FaIcon :name="fold ? 'i-ep:caret-bottom' : 'i-ep:caret-top'" />
|
||||
</FaButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaSearchBar>
|
||||
<div class="mx--5 my-4 border-t border-t-dashed" />
|
||||
<div class="flex-center-between gap-2">
|
||||
<FaButton v-if="batch.enable" variant="outline" :disabled="!batch.selectionDataList.length">
|
||||
批量操作
|
||||
</FaButton>
|
||||
<FaButton @click="onCreate">
|
||||
<FaIcon name="i-ri:add-line" />
|
||||
新增
|
||||
</FaButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" class="my-4" :data="dataList" stripe highlight-current-row border height="100%" @sort-change="sortChange" @selection-change="batch.selectionDataList = $event">
|
||||
<ElTableColumn v-if="batch.enable" type="selection" align="center" fixed />
|
||||
<ElTableColumn prop="title" label="标题" />
|
||||
<ElTableColumn label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="flex-center gap-2">
|
||||
<FaButton variant="outline" size="icon-sm" @click="onEdit(scope.row)">
|
||||
<FaIcon name="i-ri:edit-line" />
|
||||
</FaButton>
|
||||
<FaDropdown
|
||||
:items="[
|
||||
[
|
||||
{ label: '删除', variant: 'destructive', handle: () => onDel(scope.row) },
|
||||
],
|
||||
]"
|
||||
>
|
||||
<FaButton variant="outline" size="icon-sm">
|
||||
<FaIcon name="i-ri:more-line" />
|
||||
</FaButton>
|
||||
</FaDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<FaPagination :page="pagination.page" :size="pagination.size" :total="pagination.total" @page-change="currentChange" @size-change="sizeChange" />
|
||||
</FaPageMain>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: fa-crud-page-generator
|
||||
description: "为 Fantastic-admin 框架生成完整的 CRUD 业务模块,包含列表页、详情/编辑页、表单组件、API 模块和 Mock 数据。当用户说:'帮我做一个商品管理页面'、'搭一个用户列表,能搜索、能分页、能新增编辑删除'、'生成一个订单模块'、'我需要一个带增删改查的页面'、'快速搭一个管理页面骨架',即使用户只是说'新建一个 XX 管理模块'也应触发此技能。"
|
||||
---
|
||||
|
||||
# CRUD 页面生成器
|
||||
|
||||
在 Fantastic-admin 框架中生成完整的 CRUD 业务模块,一次性生成所有相关文件,让开发者专注于业务逻辑而非重复代码。
|
||||
|
||||
**生成的文件清单:**
|
||||
- `apps/<app>/src/views/{path}/{name}/list.vue` — 列表页(搜索栏 + 表格 + 分页)
|
||||
- `apps/<app>/src/views/{path}/{name}/detail.vue` — 详情/编辑页(仅 router 模式需要)
|
||||
- `apps/<app>/src/views/{path}/{name}/components/DetailForm/index.vue` — 表单组件
|
||||
- `apps/<app>/src/api/modules/{fileName}.ts` — API 模块
|
||||
- `apps/<app>/src/api/modules/{fileName}.fake.ts` — Mock 数据(可选)
|
||||
|
||||
---
|
||||
|
||||
## 第一步:确认工作区(必须阻塞等待用户回复)
|
||||
|
||||
本项目是 monorepo 架构,`apps/` 目录下存放各应用。**在执行任何文件读写操作之前**,必须先确认目标应用:
|
||||
|
||||
1. 执行 `ls apps/` 列出所有可用应用
|
||||
2. **立即向用户提问**,明确询问要在哪个应用中生成 CRUD 模块,并**停止等待回复**
|
||||
3. 收到用户明确回复后,才能继续后续步骤
|
||||
|
||||
> **严格规则**:如果用户没有在请求中明确说明目标应用(例如"在 example 应用中"、"apps/core"),则必须提问,不得自行猜测或默认选择任何应用。
|
||||
|
||||
确认后,后续所有文件路径均以该应用目录为根。
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### Step 1:收集基本信息
|
||||
|
||||
向用户询问以下内容(可合并为一次提问):
|
||||
|
||||
1. **模块名**(英文,用于文件名和路由,如 `user`、`product`、`order`)
|
||||
2. **模块中文名**(用于页面标题和提示文字,如 `用户`、`商品`、`订单`)
|
||||
3. **存放路径**(在 `apps/<app>/src/views/` 下的子目录,如 `system`、`mall/product`,留空则直接放在 `src/views/` 下)
|
||||
|
||||
### Step 2:收集字段信息
|
||||
|
||||
询问用户该模块有哪些字段,每个字段需要:
|
||||
- **字段名**(英文,如 `name`、`status`、`createdAt`)
|
||||
- **中文标签**(如 `姓名`、`状态`、`创建时间`)
|
||||
- **字段类型**(`string` | `number` | `boolean` | `date`)
|
||||
- **是否在列表中显示**
|
||||
- **是否在搜索栏中显示**
|
||||
- **是否在表单中显示**(新增/编辑)
|
||||
- **是否必填**(表单验证)
|
||||
|
||||
如果用户没有提供字段信息,使用默认字段 `title`(标题,string,必填)作为示例占位,并在生成后提示用户替换。
|
||||
|
||||
### Step 3:收集功能选项
|
||||
|
||||
以下三个选项会直接影响生成的文件结构,需要在同一轮提问中一起确认,避免后续重复生成:
|
||||
|
||||
4. **详情展示模式**:
|
||||
- `router` — 跳转到独立的详情页(默认,适合复杂表单)
|
||||
- `modal` — 弹窗(适合简单表单)
|
||||
- `drawer` — 抽屉(适合中等复杂度表单)
|
||||
5. **是否生成 Mock 数据**(是/否,开发阶段推荐开启)
|
||||
6. **是否同时生成路由配置**(是/否,仅 `app.routeBaseOn` 为 `frontend` 时有效;选是时需额外询问归属的主导航分组名称)
|
||||
|
||||
### Step 4:确认并生成
|
||||
|
||||
汇总信息,展示将要生成的文件列表,确认后写入文件。
|
||||
|
||||
---
|
||||
|
||||
## 命名规范
|
||||
|
||||
给定模块名 `name` 和路径 `path`(相对于 `apps/<app>/src/views/`):
|
||||
|
||||
| 用途 | 规则 | 示例(path=system, name=user) |
|
||||
|------|------|-------------------------------|
|
||||
| 视图目录 | `apps/<app>/src/views/{path}/{snakeCase(name)}/` | `apps/<app>/src/views/system/user/` |
|
||||
| API 文件名 | path 非空时:`{camelCase(path)}_{camelCase(name)}`;path 为空时:`{camelCase(name)}` | `systemUser` |
|
||||
| 组件名 | `PascalCase({path}-{name}-list/detail)` | `SystemUserList` |
|
||||
| API URL 前缀 | `/{path}/{snakeCase(name)}/` | `/system/user/` |
|
||||
|
||||
**snakeCase 规则**:多个单词用下划线连接,如 `productCategory` → `product_category`
|
||||
|
||||
---
|
||||
|
||||
## 代码模板
|
||||
|
||||
详细的代码模板见 [references/templates.md](references/templates.md),其中也包含 faker.js 字段类型映射表。
|
||||
|
||||
生成代码时,根据用户提供的字段信息替换模板中的占位符:
|
||||
- 列表页的 `ElTableColumn` 根据"列表显示"字段生成
|
||||
- 搜索栏的 `ElFormItem` 根据"搜索显示"字段生成
|
||||
- 表单组件的 `ElFormItem` 根据"表单显示"字段生成
|
||||
- API 模块的 TypeScript 类型根据字段定义生成
|
||||
- Mock 数据根据字段类型使用合适的 faker.js 方法生成(见 templates.md 中的映射表)
|
||||
|
||||
---
|
||||
|
||||
## 路由生成(可选)
|
||||
|
||||
如果用户选择同时生成路由,先读取 `apps/<app>/src/settings.ts` 确认 `app.routeBaseOn === 'frontend'`,然后使用 `fa-route-generator` 技能生成对应路由配置:
|
||||
- **router 模式**:生成列表路由 + 详情路由(详情路由带 `menu: false` 和 `activeMenu` 指向列表)
|
||||
- **modal/drawer 模式**:只生成列表路由
|
||||
|
||||
---
|
||||
|
||||
## 生成后提示用户
|
||||
|
||||
文件生成完成后简要告知:如果未生成路由,可用 `fa-route-generator` 技能添加;生成了 `.fake.ts` 则 API 请求会自动走 mock;表单字段类型(Select、DatePicker 等)需根据业务自行替换。
|
||||
@@ -0,0 +1,614 @@
|
||||
# CRUD 代码模板
|
||||
|
||||
以下模板基于 `src/views/pages_example/manager` 真实模块提炼,使用以下占位符:
|
||||
|
||||
- `{cname}` — 模块中文名,如 `用户`
|
||||
- `{componentNameList}` — 列表页组件名(PascalCase),如 `SystemUserList`
|
||||
- `{componentNameDetail}` — 详情页组件名(PascalCase),如 `SystemUserDetail`
|
||||
- `{fileName}` — API 文件名(camelCase),如 `systemUser`
|
||||
- `{apiPrefix}` — API URL 前缀(无前导斜杠),如 `system/user`
|
||||
- `{routeListName}` — 列表路由 name,如 `systemUserList`
|
||||
- `{routeDetailName}` — 详情路由 name,如 `systemUserDetail`(仅 router 模式)
|
||||
|
||||
---
|
||||
|
||||
## list.vue 模板
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import api{FileName} from '@/api/modules/{fileName}'
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import DetailForm from './components/DetailForm/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: '{componentNameList}',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { pagination, getParams, onSizeChange, onCurrentChange, onSortChange } = usePagination()
|
||||
|
||||
// 表格是否自适应高度
|
||||
const tableAutoHeight = ref(false)
|
||||
|
||||
/**
|
||||
* 详情展示模式
|
||||
* router 路由跳转
|
||||
* modal 模态框
|
||||
* drawer 抽屉
|
||||
*/
|
||||
const formMode = ref<'router' | 'modal' | 'drawer'>('{formMode}')
|
||||
|
||||
// 详情
|
||||
const formModeProps = ref({
|
||||
id: '',
|
||||
})
|
||||
|
||||
// 搜索
|
||||
const searchDefault = {
|
||||
{searchDefaults}
|
||||
}
|
||||
const search = ref({ ...searchDefault })
|
||||
function searchReset() {
|
||||
Object.assign(search.value, searchDefault)
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
const batch = ref({
|
||||
enable: true,
|
||||
selectionDataList: [],
|
||||
})
|
||||
|
||||
// 列表
|
||||
const loading = ref(false)
|
||||
const dataList = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
if (formMode.value === 'router') {
|
||||
eventBus.on('get-data-list', () => {
|
||||
getDataList()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (formMode.value === 'router') {
|
||||
eventBus.off('get-data-list')
|
||||
}
|
||||
})
|
||||
|
||||
function getDataList() {
|
||||
loading.value = true
|
||||
const params = {
|
||||
...getParams(),
|
||||
{searchParams}
|
||||
}
|
||||
api{FileName}.list(params).then((res: any) => {
|
||||
loading.value = false
|
||||
dataList.value = res.data.list
|
||||
pagination.value.total = res.data.total
|
||||
})
|
||||
}
|
||||
|
||||
// 每页数量切换
|
||||
function sizeChange(size: number) {
|
||||
onSizeChange(size).then(() => getDataList())
|
||||
}
|
||||
|
||||
// 当前页码切换(翻页)
|
||||
function currentChange(page = 1) {
|
||||
onCurrentChange(page).then(() => getDataList())
|
||||
}
|
||||
|
||||
// 字段排序
|
||||
function sortChange({ prop, order }: { prop: string, order: string }) {
|
||||
onSortChange(prop, order).then(() => getDataList())
|
||||
}
|
||||
|
||||
const formRef = ref<InstanceType<typeof DetailForm>>()
|
||||
|
||||
const { open: openModal, update: updateModal } = useFaModal().create({
|
||||
destroyOnClose: true,
|
||||
closeOnClickOverlay: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, done) => {
|
||||
if (action === 'confirm') {
|
||||
// 调用 DetailForm 组件内部 submit 方法
|
||||
formRef.value?.submit().then(() => {
|
||||
getDataList()
|
||||
done()
|
||||
})
|
||||
}
|
||||
else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
content: () => h(DetailForm, {
|
||||
ref: formRef,
|
||||
id: formModeProps.value.id,
|
||||
}),
|
||||
})
|
||||
|
||||
const { open: openDrawer, update: updateDrawer } = useFaDrawer().create({
|
||||
destroyOnClose: true,
|
||||
closeOnClickOverlay: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, done) => {
|
||||
if (action === 'confirm') {
|
||||
// 调用 DetailForm 组件内部 submit 方法
|
||||
formRef.value?.submit().then(() => {
|
||||
getDataList()
|
||||
done()
|
||||
})
|
||||
}
|
||||
else {
|
||||
done()
|
||||
}
|
||||
},
|
||||
content: () => h(DetailForm, {
|
||||
ref: formRef,
|
||||
id: formModeProps.value.id,
|
||||
}),
|
||||
})
|
||||
|
||||
function onCreate() {
|
||||
if (formMode.value === 'router') {
|
||||
router.push({ name: '{routeDetailName}' })
|
||||
}
|
||||
else {
|
||||
formModeProps.value.id = ''
|
||||
if (formMode.value === 'modal') {
|
||||
updateModal({ title: '新增{cname}' })
|
||||
openModal()
|
||||
}
|
||||
else {
|
||||
updateDrawer({ title: '新增{cname}' })
|
||||
openDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: any) {
|
||||
if (formMode.value === 'router') {
|
||||
router.push({ name: '{routeDetailName}', params: { id: row.id } })
|
||||
}
|
||||
else {
|
||||
formModeProps.value.id = row.id
|
||||
if (formMode.value === 'modal') {
|
||||
updateModal({ title: '编辑{cname}' })
|
||||
openModal()
|
||||
}
|
||||
else {
|
||||
updateDrawer({ title: '编辑{cname}' })
|
||||
openDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDel(row: any) {
|
||||
useFaModal().confirm({
|
||||
title: '确认信息',
|
||||
content: `确认删除「${row.{firstField}}」吗?`,
|
||||
onConfirm: () => {
|
||||
api{FileName}.delete(row.id).then(() => {
|
||||
getDataList()
|
||||
faToast.success('删除成功')
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'absolute flex flex-col size-full': tableAutoHeight }">
|
||||
<FaPageHeader title="{cname}管理" class="mb-0" />
|
||||
<FaPageMain :class="{ 'flex-1 overflow-auto': tableAutoHeight }" :main-class="{ 'flex-1 flex flex-col overflow-auto': tableAutoHeight }">
|
||||
<FaSearchBar :show-toggle="false">
|
||||
<template #default="{ fold, toggle }">
|
||||
<div class="gap-x-8 gap-y-2 grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
|
||||
{fields.search}
|
||||
<div class="flex gap-2 col-end--1 justify-end">
|
||||
<FaButton variant="outline" @click="searchReset(); currentChange()">
|
||||
重置
|
||||
</FaButton>
|
||||
<FaButton type="primary" @click="currentChange()">
|
||||
<FaIcon name="i-ri:search-line" />
|
||||
筛选
|
||||
</FaButton>
|
||||
<FaButton variant="ghost" @click="toggle">
|
||||
{{ fold ? '展开' : '收起' }}
|
||||
<FaIcon :name="fold ? 'i-ep:caret-bottom' : 'i-ep:caret-top'" />
|
||||
</FaButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaSearchBar>
|
||||
<div class="mx--5 my-4 border-t border-t-dashed" />
|
||||
<div class="flex-center-between gap-2">
|
||||
<FaButton v-if="batch.enable" variant="outline" :disabled="!batch.selectionDataList.length">
|
||||
批量操作
|
||||
</FaButton>
|
||||
<FaButton @click="onCreate">
|
||||
<FaIcon name="i-ri:add-line" />
|
||||
新增
|
||||
</FaButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" class="my-4" :data="dataList" stripe highlight-current-row border height="100%" @sort-change="sortChange" @selection-change="batch.selectionDataList = $event">
|
||||
<ElTableColumn v-if="batch.enable" type="selection" align="center" fixed />
|
||||
{fields.list}
|
||||
<ElTableColumn label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="flex-center gap-2">
|
||||
<FaButton variant="outline" size="icon-sm" @click="onEdit(scope.row)">
|
||||
<FaIcon name="i-ri:edit-line" />
|
||||
</FaButton>
|
||||
<FaDropdown
|
||||
:items="[
|
||||
[
|
||||
{ label: '删除', variant: 'destructive', handle: () => onDel(scope.row) },
|
||||
],
|
||||
]"
|
||||
>
|
||||
<FaButton variant="outline" size="icon-sm">
|
||||
<FaIcon name="i-ri:more-line" />
|
||||
</FaButton>
|
||||
</FaDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<FaPagination :page="pagination.page" :size="pagination.size" :total="pagination.total" @page-change="currentChange" @size-change="sizeChange" />
|
||||
</FaPageMain>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> 注意:`list.vue` 不需要 `<style scoped>` 块,所有样式均通过 UnoCSS 工具类实现。
|
||||
|
||||
---
|
||||
|
||||
## detail.vue 模板(仅 router 模式)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import DetailForm from './components/DetailForm/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: '{componentNameDetail}',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
|
||||
function onSubmit() {
|
||||
formRef.value?.submit().then(() => {
|
||||
eventBus.emit('get-data-list')
|
||||
onCancel()
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back({ name: '{routeListName}' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FaFixedBar position="top" class="p-0">
|
||||
<FaPageHeader :title="route.params.id ? '编辑{cname}' : '新增{cname}'" class="mb-0 border-b-none">
|
||||
<FaButton variant="outline" size="sm" class="rounded-full" @click="onCancel">
|
||||
<FaIcon name="i-ep:arrow-left" />
|
||||
返回
|
||||
</FaButton>
|
||||
</FaPageHeader>
|
||||
</FaFixedBar>
|
||||
<FaPageMain>
|
||||
<ElRow>
|
||||
<ElCol :md="24" :lg="16">
|
||||
<DetailForm :id="route.params.id as string" ref="formRef" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</FaPageMain>
|
||||
<FaFixedBar position="bottom" class="flex-center gap-4">
|
||||
<FaButton @click="onSubmit">
|
||||
提交
|
||||
</FaButton>
|
||||
<FaButton variant="outline" @click="onCancel">
|
||||
取消
|
||||
</FaButton>
|
||||
</FaFixedBar>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## components/DetailForm/index.vue 模板
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import api{FileName} from '@/api/modules/{fileName}'
|
||||
|
||||
export interface Props {
|
||||
id?: number | string
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<Props>(),
|
||||
{
|
||||
id: '',
|
||||
},
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||
const form = ref({
|
||||
id: props.id,
|
||||
{fields.formInit}
|
||||
})
|
||||
const formRules = ref<FormRules>({
|
||||
{fields.rules}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (form.value.id !== '') {
|
||||
getInfo()
|
||||
}
|
||||
})
|
||||
|
||||
function getInfo() {
|
||||
loading.value = true
|
||||
api{FileName}.detail(form.value.id).then((res: any) => {
|
||||
loading.value = false
|
||||
{fields.formAssign}
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit() {
|
||||
return new Promise<void>((resolve) => {
|
||||
formRef.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
if (form.value.id === '') {
|
||||
api{FileName}.create(form.value).then(() => {
|
||||
faToast.success('新增成功')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
else {
|
||||
api{FileName}.edit(form.value).then(() => {
|
||||
faToast.success('编辑成功')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px" label-suffix=":">
|
||||
{fields.form}
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## api.ts 模板
|
||||
|
||||
```typescript
|
||||
import api from '../index'
|
||||
|
||||
export default {
|
||||
list: (data: {
|
||||
{searchTypes}
|
||||
from: number
|
||||
limit: number
|
||||
}) => api.get('{apiPrefix}/list', {
|
||||
params: data,
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
detail: (id: number | string) => api.get('{apiPrefix}/detail', {
|
||||
params: { id },
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
create: (data: any) => api.post('{apiPrefix}/create', data, {
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
edit: (data: any) => api.post('{apiPrefix}/edit', data, {
|
||||
fake: true,
|
||||
}),
|
||||
|
||||
delete: (id: number | string) => api.post('{apiPrefix}/delete', { id }, {
|
||||
fake: true,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
> 不需要 Mock 数据时,去掉所有 `fake: true` 选项即可。
|
||||
|
||||
---
|
||||
|
||||
## fake.ts 模板
|
||||
|
||||
```typescript
|
||||
import { faker } from '@faker-js/faker/locale/zh_CN'
|
||||
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
|
||||
|
||||
const {moduleName}List: any[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
{moduleName}List.push({
|
||||
id: i + 1,
|
||||
{fields.mock}
|
||||
})
|
||||
}
|
||||
|
||||
export default defineFakeRoute([
|
||||
{
|
||||
url: '/fake/{apiPrefix}/list',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const { {searchQueryFields}, from, limit } = query
|
||||
let list = {moduleName}List
|
||||
{searchFilters}
|
||||
const pageList = list.filter((_item, index) => {
|
||||
return index >= ~~from && index < (~~from + ~~limit)
|
||||
})
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
list: pageList,
|
||||
total: list.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/detail',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const info = {moduleName}List.filter(item => item.id === ~~query.id)
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: info[0],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/create',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return { error: '', status: 1, data: { isSuccess: true } }
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/edit',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return { error: '', status: 1, data: { isSuccess: true } }
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/fake/{apiPrefix}/delete',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return { error: '', status: 1, data: { isSuccess: true } }
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段生成规则
|
||||
|
||||
### 搜索栏字段
|
||||
|
||||
每个搜索字段用 `FaLabel` 包裹,内部控件根据字段类型自行选择合适的 Fa* 或 El* 组件。次要搜索条件加 `v-show="!fold"` 实现折叠。
|
||||
|
||||
```vue
|
||||
<FaLabel label="{label}" class="col-span-1">
|
||||
<!-- 内部放对应的输入控件,string 用 FaInput,枚举用 FaSelect,日期用 ElDatePicker 等 -->
|
||||
</FaLabel>
|
||||
```
|
||||
|
||||
### 列表列
|
||||
|
||||
普通列直接用 `prop` 渲染,需要自定义展示时使用 `#default` slot:
|
||||
|
||||
```vue
|
||||
<!-- 普通列 -->
|
||||
<ElTableColumn prop="{field}" label="{label}" />
|
||||
|
||||
<!-- 需要自定义渲染时 -->
|
||||
<ElTableColumn prop="{field}" label="{label}" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<!-- 根据字段含义选择合适的展示方式,如 ElTag、FaSwitch 等 -->
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
```
|
||||
|
||||
### 表单字段
|
||||
|
||||
每个表单字段用 `ElFormItem` 包裹,内部控件根据字段类型自行选择合适的 Fa* 或 El* 组件,注意加 `class="w-full"`。
|
||||
|
||||
```vue
|
||||
<ElFormItem label="{label}" prop="{field}">
|
||||
<!-- 内部放对应的输入控件 -->
|
||||
</ElFormItem>
|
||||
```
|
||||
|
||||
### 表单验证规则
|
||||
|
||||
```typescript
|
||||
// 必填文本
|
||||
{field}: [
|
||||
{ required: true, message: '请输入{label}', trigger: 'blur' },
|
||||
],
|
||||
// 必填选择
|
||||
{field}: [
|
||||
{ required: true, message: '请选择{label}', trigger: 'change' },
|
||||
],
|
||||
```
|
||||
|
||||
### Mock 数据字段映射
|
||||
|
||||
| 字段类型 | faker 方法 |
|
||||
|---------|-----------|
|
||||
| 姓名 | `faker.person.fullName()` |
|
||||
| 账号/用户名 | `faker.person.firstName()` |
|
||||
| 手机号 | `faker.phone.number({ style: 'international' })` |
|
||||
| 邮箱 | `faker.internet.email()` |
|
||||
| 标题/名称 | `faker.lorem.words(3)` |
|
||||
| 描述 | `faker.lorem.sentence()` |
|
||||
| 数字(枚举) | `faker.number.int(2)` |
|
||||
| 布尔(状态) | `faker.datatype.boolean()` |
|
||||
| 金额 | `faker.number.float({ min: 10, max: 9999, fractionDigits: 2 })` |
|
||||
| 日期 | `faker.date.recent().toISOString()` |
|
||||
|
||||
### Mock 搜索过滤模式
|
||||
|
||||
**string 模糊匹配:**
|
||||
```typescript
|
||||
list = list.filter((item) => {
|
||||
return {field} ? item.{field}.includes({field}) : true
|
||||
})
|
||||
```
|
||||
|
||||
**枚举/数字精确匹配(注意:query 参数均为字符串,`0` 是 falsy,不能直接用 `{field} ?` 判断):**
|
||||
```typescript
|
||||
list = list.filter((item) => {
|
||||
return {field} !== undefined && {field} !== '' ? item.{field} === ~~{field} : true
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## faker.js 字段类型映射
|
||||
|
||||
| 字段类型 | 推荐 faker 方法 |
|
||||
|---------|----------------|
|
||||
| string(名称类) | `faker.person.fullName()` 或 `faker.commerce.productName()` |
|
||||
| string(标题类) | `faker.lorem.words(3)` |
|
||||
| string(描述类) | `faker.lorem.sentence()` |
|
||||
| number(ID) | 自增 `i + 1` |
|
||||
| number(金额) | `faker.number.float({ min: 10, max: 9999, fractionDigits: 2 })` |
|
||||
| number(数量) | `faker.number.int({ min: 1, max: 100 })` |
|
||||
| boolean | `faker.datatype.boolean()` |
|
||||
| date | `faker.date.recent().toISOString()` |
|
||||
| status(枚举) | `faker.helpers.arrayElement([0, 1, 2])` |
|
||||
Reference in New Issue
Block a user