包含以下模块: - antdv-next-admin: Vue 3 + TypeScript + Ant Design Vue 管理后台 - 设备/许可证/配件/耗材 CRUD 管理页面 - 基础数据管理 (分类/位置/制造商/型号/供应商) - 业务管理 (故障报修/盘点/资产分配/资产申请/交易记录) - 下拉选项改造 (ID输入框 → 搜索下拉选择) - 资产状态字典化 (接入sys_dict系统) - 界面文案优化 (设备→资产, 在库/在用/维修中/已报废) - 修复 console 警告 (popupClassName, 重复组件注册) - our-itam: Java Spring Boot + magic-api 后端服务 - fantastic-admin: 前端底层框架 (pnpm monorepo) - ciyo-itasset: CIYO 资产模块 - magic-script-skill: Claude Code skill 定义 - .claude: 对话历史记录 Co-Authored-By: Claude Code <noreply@anthropic.com>
615 lines
15 KiB
Markdown
615 lines
15 KiB
Markdown
# 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--4 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])` |
|