tansci/.claude/skills/fa-crud-page-generator/references/templates.md
xuewuerduo f468d532b1 feat: 初始化tansci资产管理项目
包含以下模块:
- 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>
2026-05-17 21:41:22 +08:00

15 KiB
Raw Blame History

CRUD 代码模板

以下模板基于 src/views/pages_example/manager 真实模块提炼,使用以下占位符:

  • {cname} — 模块中文名,如 用户
  • {componentNameList} — 列表页组件名PascalCaseSystemUserList
  • {componentNameDetail} — 详情页组件名PascalCaseSystemUserDetail
  • {fileName} — API 文件名camelCasesystemUser
  • {apiPrefix} — API URL 前缀(无前导斜杠),如 system/user
  • {routeListName} — 列表路由 namesystemUserList
  • {routeDetailName} — 详情路由 namesystemUserDetail(仅 router 模式)

list.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 模式)

<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 模板

<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 模板

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 模板

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" 实现折叠。

<FaLabel label="{label}" class="col-span-1">
  <!-- 内部放对应的输入控件string  FaInput枚举用 FaSelect日期用 ElDatePicker  -->
</FaLabel>

列表列

普通列直接用 prop 渲染,需要自定义展示时使用 #default slot

<!-- 普通列 -->
<ElTableColumn prop="{field}" label="{label}" />

<!-- 需要自定义渲染时 -->
<ElTableColumn prop="{field}" label="{label}" width="100" align="center">
  <template #default="scope">
    <!-- 根据字段含义选择合适的展示方式 ElTagFaSwitch  -->
  </template>
</ElTableColumn>

表单字段

每个表单字段用 ElFormItem 包裹,内部控件根据字段类型自行选择合适的 Fa* 或 El* 组件,注意加 class="w-full"

<ElFormItem label="{label}" prop="{field}">
  <!-- 内部放对应的输入控件 -->
</ElFormItem>

表单验证规则

// 必填文本
{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 模糊匹配:

list = list.filter((item) => {
  return {field} ? item.{field}.includes({field}) : true
})

枚举/数字精确匹配注意query 参数均为字符串,0 是 falsy不能直接用 {field} ? 判断):

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()
numberID 自增 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])