Files
2026-08-31 11:33:59 +08:00

16 KiB
Raw Permalink Blame History

name, description
name description
frontend-admin-gen Generate Vue frontend pages for madong module. Supports both app (main project) and plugin targets. Creates index.vue with BasicCrud, schemas, and API service files following madong frontend conventions.

Step 7: Generate Frontend Pages

Create Vue frontend pages and API service for module.

File Locations

App (主项目):

frontend/admin/src/apps/{module}/
├── views/{model}/index.vue
├── views/{model}/schemas/index.tsx
├── api/{module}/index.ts
└── locales/lang/
    ├── zh-cn/{module}.json
    └── en/{module}.json

Plugin (插件):

backend/plugin/{plugin}/resource/template/admin/
├── api/{module}/index.ts
├── lang/
│   ├── zh-cn/{module}.json
│   └── en/{module}.json
└── views/{plugin_key}/{model}/
    ├── index.vue
    └── schemas/index.tsx

安装插件时会由 `core\plugin\traits\TemplateTrait` 将上述 `resource/template/admin/*`
整体复制到:

frontend/admin/src/apps/{plugin}/
├── api/{module}/index.ts
├── lang/
│   ├── zh-cn/{module}.json
│   └── en/{module}.json
└── views/{plugin_key}/{model}/
    ├── index.vue
    └── schemas/index.tsx

插件菜单必须通过 backend/plugin/{plugin}/resource/menu/admin.php 在安装时导入系统菜单, 不要在插件前端模板中生成 routes/index.ts 作为侧边栏菜单入口,避免未安装插件也被前端自动扫描并显示导航。 插件菜单中的 component 应包含插件 key 目录段,确保与生成器输出路径一致, 例如页面为 views/{plugin_key}/index/index.vue 时填写 /{plugin_key}/index/index。 插件 API 文件应从 @/utils/http 导入默认请求对象,并使用 request.get()、request.post() 等方法; 不要从 @/api/request 默认导入,因为该文件只转发命名导出,不提供 default export。 如果插件页面直接使用 $t('{plugin}.title')、$t('{plugin}.description') 等根级 key, 语言文件应生成到 lang/zh-cn/zh.json 和 lang/en/en.json,文件内容包含 {plugin} 根对象; 不要只生成 index.json,否则 apps 语言加载器会把 key 包装为 {plugin}.index.*。 后端插件必须生成 config/translation.php,因为插件请求上下文中 __() 会读取 plugin.{plugin}.translation, 缺少该配置会导致 Undefined array key "locale"。

i18n Key Pattern

Target Pattern Example
App {module}.{model}.* ask.question.*
Plugin {plugin}.{module}.{model}.* official.ask.question.*

index.vue Template

<template>
  <div class="{model}-page art-full-height">
    <BasicCrud />
  </div>
</template>

<script setup lang="tsx">
import { useCrud } from "@/components/crud";
import { crudSchema } from "./schemas";
import { $t } from "@/locales";

const [BasicCrud, crudApi] = useCrud({
  ...crudSchema(),
  beforeFetch: (params: any) => {
    return params;
  },
  // Custom table actions
  tableActions: [
    {
      label: $t('{i18n_prefix}.{model}.actions.detail'),
      type: "primary",
      sort: -1,
      link: true,
      icon: "ant-design:eye-outlined",
      auth: "{permission}:read",
      onClick(e: Event, record: any) {},
    },
  ],
  // Custom dropdown actions
  dropDownActions: [
    {
      label: $t('{i18n_prefix}.{model}.actions.edit'),
      type: "primary",
      sort: -1,
      link: true,
      icon: "ant-design:edit-outlined",
      auth: "{permission}:update",
      onClick(e: Event, record: any) {},
    },
    {
      label: $t('{i18n_prefix}.{model}.actions.delete'),
      type: "danger",
      sort: -1,
      link: true,
      icon: "ant-design:delete-outlined",
      auth: "{permission}:delete",
      onClick(e: Event, record: any) {},
    },
  ],
});
</script>

schemas/index.tsx Template

import { {Model}Service } from '@/apps/{app_name}/api/{module}'
import { DictEnum } from '@/apps/{app_name}/enums/dict-enum'
import { CrudSchema } from '@/components/crud'
import { formatDateTime } from '@/utils'
import { h } from 'vue'
import { ElTag } from 'element-plus'
import { $t } from '@/locales'

// CRUD配置
export const crudSchema = (): CrudSchema => {
  return {
    // 接口地址
    api: {Model}Service.list,
    dialogTitle: $t('{i18n_prefix}.{model}.dialog_title'),
    crudApi: {
      remove: {Model}Service.remove,
      view: {Model}Service.get,
      add: {Model}Service.create,
      edit: {Model}Service.update,
    },
    useCrud: true,
    hasAdd: true,
    hasRemove: true,
    hasEdit: true,
    hasView: true,
    addAuth: '{permission}:create',
    removeAuth: '{permission}:delete',
    editAuth: '{permission}:update',
    viewAuth: '{permission}:read',
    rowKey: 'id',
    columns: [
      {
        type: 'selection'
      },
      {
        prop: 'id',
        label: $t('{i18n_prefix}.{model}.table.columns.id'),
        align: 'center',
        width: 80,
        visible: false
      },
      {
        prop: 'name',
        label: $t('{i18n_prefix}.{model}.table.columns.name'),
        align: 'left',
        minWidth: 150,
        showOverflowTooltip: true
      },
      {
        prop: 'sort',
        label: $t('{i18n_prefix}.{model}.table.columns.sort'),
        align: 'center',
        width: 100,
      },
      {
        prop: 'status',
        label: $t('{i18n_prefix}.{model}.table.columns.status'),
        align: 'center',
        width: 100,
        component: 'ApiDict',
        componentProps: {
          code: DictEnum.SYS_NORMAL_DISABLE
        }
      },
      {
        prop: 'created_at',
        label: $t('{i18n_prefix}.{model}.table.columns.created_at'),
        minWidth: 170,
        align: 'center',
        formatter: (row: any) => {
          return formatDateTime(row.created_at)
        }
      },
    ],
    // 当前项目 CrudSchema 列类型不支持 `type: 'operate'`,操作列由 BasicCrud 根据 tableActions/dropDownActions 自动生成。
    dialogProps: {
      width: '50%',
    },
    searchFormSchema: {
      labelWidth: '100px',
      schema: [
        {
          label: $t('{i18n_prefix}.{model}.search_form.name'),
          prop: 'name',
          component: 'Input',
          colSpan: 6,
          componentProps: {
            placeholder: $t('{i18n_prefix}.{model}.search_form.name_placeholder')
          }
        },
        {
          label: $t('{i18n_prefix}.{model}.search_form.status'),
          prop: 'status',
          component: 'ApiDict',
          colSpan: 6,
          componentProps: {
            code: DictEnum.SYS_NORMAL_DISABLE,
            placeholder: $t('{i18n_prefix}.{model}.search_form.status_placeholder')
          }
        }
      ]
    },
    formSchema: {
      labelWidth: 120,
      gutter: 20,
      schema: [
        {
          label: $t('{i18n_prefix}.{model}.form.name'),
          prop: 'name',
          component: 'Input',
          componentProps: {
            placeholder: $t('{i18n_prefix}.{model}.form.name_placeholder'),
            maxlength: 50
          },
          colSpan: 24,
          rules: [{ required: true, message: $t('{i18n_prefix}.{model}.validate.name_required') }]
        },
        {
          label: $t('{i18n_prefix}.{model}.form.sort'),
          prop: 'sort',
          component: 'InputNumber',
          componentProps: {
            placeholder: $t('{i18n_prefix}.{model}.form.sort_placeholder'),
            min: 0,
            max: 9999
          },
          colSpan: 24,
        },
        {
          label: $t('{i18n_prefix}.{model}.form.status'),
          prop: 'status',
          component: 'ApiDict',
          componentProps: {
            code: DictEnum.SYS_NORMAL_DISABLE
          },
          colSpan: 24,
          rules: [{ required: true, message: $t('{i18n_prefix}.{model}.validate.status_required') }]
        }
      ]
    }
  }
}

API Service Template

Current project convention: prefer BaseService from @/api/core/base for standard CRUD endpoints and request from @/utils/http for custom methods. Do not use the old @/support/http string-only service style when extending the current admin project.

import BaseService from '@/api/core/base'
import request from '@/utils/http'

/**
 * {Model} API 服务
 */
const baseUrl = '/{api_prefix}'

export const {Model}Service = {
  ...BaseService<any>({ baseUrl }),

  /**
   * 自定义方法 - 设置状态
   */
  setStatus: (id: number, status: number) => {
    return request.post({ url: `${baseUrl}/${id}/status`, data: { status } })
  },
}

export default {Model}Service

Complete Example (App)

File: frontend/admin/src/apps/ask/api/question/index.ts

import http from "@/support/http";

export const QuestionService = {
  list: '/ask/question',
  get: '/ask/question/{id}',
  create: '/ask/question',
  update: '/ask/question/{id}',
  remove: '/ask/question/{id}',
  batchRemove: '/ask/question',
  select: '/ask/question',
};

i18n: frontend/admin/src/apps/ask/locales/lang/zh-cn/question.json

{
  "ask": {
    "question": {
      "dialog_title": "问题管理",
      "table": {
        "columns": {
          "id": "ID",
          "title": "标题",
          "category_id": "分类",
          "price": "价格",
          "status": "状态",
          "created_at": "创建时间"
        }
      },
      "search_form": {
        "title": "标题",
        "title_placeholder": "请输入标题",
        "status": "状态",
        "status_placeholder": "请选择状态"
      },
      "form": {
        "title": "标题",
        "title_placeholder": "请输入标题",
        "category_id": "分类",
        "price": "价格",
        "status": "状态"
      },
      "actions": {
        "detail": "详情",
        "edit": "编辑",
        "delete": "删除"
      },
      "validate": {
        "title_required": "请输入标题"
      }
    }
  }
}

Complete Example (Plugin)

File: frontend/admin/src/apps/official/api/question/index.ts

import http from "@/support/http";

export const QuestionService = {
  list: '/ask/question',
  get: '/ask/question/{id}',
  create: '/ask/question',
  update: '/ask/question/{id}',
  remove: '/ask/question/{id}',
  batchRemove: '/ask/question',
  select: '/ask/question',
};

i18n: frontend/admin/src/apps/official/locales/lang/zh-cn/question.json

{
  "official": {
    "ask": {
      "question": {
        "dialog_title": "问题管理",
        "table": {
          "columns": {
            "id": "ID",
            "title": "标题",
            "category_id": "分类",
            "price": "价格",
            "status": "状态",
            "created_at": "创建时间"
          }
        },
        "search_form": {
          "title": "标题",
          "title_placeholder": "请输入标题",
          "status": "状态",
          "status_placeholder": "请选择状态"
        },
        "form": {
          "title": "标题",
          "title_placeholder": "请输入标题",
          "category_id": "分类",
          "price": "价格",
          "status": "状态"
        },
        "actions": {
          "detail": "详情",
          "edit": "编辑",
          "delete": "删除"
        },
        "validate": {
          "title_required": "请输入标题"
        }
      }
    }
  }
}

Custom Form Component (自定义表单)

When you need custom form logic (e.g., rich text editor with upload, conditional fields), disable built-in CRUD forms and use custom form component:

1. Disable Built-in Forms in schemas/index.tsx

export const crudSchema = (): CrudSchema => {
  return {
    // ... other config
    useCrud: true,
    hasAdd: false,      // Disable built-in add
    hasEdit: false,     // Disable built-in edit
    hasView: false,     // Disable built-in view
    hasRemove: true,    // Keep built-in delete
    formSchema: {},     // Empty form schema
    // ...
  }
}

2. Create Custom Form Component (form.vue)

<template>
  <Dialog>
    <BasicForm />
  </Dialog>
</template>

<script setup lang="tsx">
import { FormItemSchema, useForm } from "@/components/form";
import { useDialog } from "@/components/dialog";
import { CrudMethods } from "@/components/crud";
import { ref, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { $t } from "@/locales";

const record = ref<any>({});
const crudApi = ref<CrudMethods>();
const formType = ref("add");

// Define form schema with JSON
const schema: FormItemSchema[] = [
  {
    label: $t('{i18n_prefix}.{model}.form.title'),
    prop: "title",
    component: "Input",
    colSpan: 24,
    rules: [{ required: true, message: "请输入标题" }],
  },
  {
    label: $t('{i18n_prefix}.{model}.form.content'),
    prop: "content",
    component: "Editor",  // Rich text editor
    colSpan: 24,
    componentProps: {
      uploadConfig: {
        server: `${import.meta.env.VITE_API_URL}/files/common/wangeditor`,
        meta: { sub_dir: "content" },
      },
    },
  },
];

const [BasicForm, formApi] = useForm({
  labelPosition: "right",
  labelWidth: "100px",
  schema: schema,
});

const [Dialog, dialogApi] = useDialog({
  title: $t('{i18n_prefix}.{model}.dialog_title'),
  width: "80%",
  dialogType: "drawer",
  async onConfirm() {
    formApi.validate().then(async () => {
      const values = await formApi.getValues();
      if (formType.value === "add") {
        await {Model}Service.create(values);
        crudApi?.value?.refreshCreate();
        ElMessage.success("新增成功");
        dialogApi.close();
      } else if (formType.value === "edit") {
        await {Model}Service.update(record.value.id, values);
        crudApi?.value?.refreshCreate();
        ElMessage.success("更新成功");
        dialogApi.close();
      }
    });
  },
});

defineExpose({
  async show({ data, getCrudApi, type }: any) {
    record.value = data;
    formType.value = type || "add";
    crudApi.value = getCrudApi();
    dialogApi.open();
    nextTick(() => {
      formApi.setValues(record.value);
    });
  },
});
</script>

3. Use Custom Form in index.vue

<template>
  <div class="{model}-page art-full-height">
    <BasicCrud />
    <Form ref="formRef" />
  </div>
</template>

<script setup lang="tsx">
import { useCrud } from "@/components/crud";
import { crudSchema } from "./schemas";
import Form from "./form.vue";

const formRef = ref<any>(null);

const [BasicCrud, crudApi] = useCrud({
  ...crudSchema(),
  toolbarActions: [
    {
      label: "新增",
      type: "primary",
      auth: "{permission}:create",
      dialogRef: formRef,  // Reference to custom form
      dialogParams() {
        return {
          data: {},
          getCrudApi() { return crudApi; },
          type: "add",
        };
      },
    },
  ],
  tableActions: [
    {
      label: "编辑",
      type: "primary",
      link: true,
      auth: "{permission}:update",
      dialogRef: formRef,  // Reference to custom form
      dialogParams(record: any) {
        return {
          data: record,
          getCrudApi() { return crudApi; },
          type: "edit",
        };
      },
    },
  ],
});
</script>

Column Component Types

Component Description
ApiDict Dictionary select (uses DictEnum codes)
ApiSelect API-based select dropdown
Input Text input
InputNumber Number input
DatePicker Date picker
DatePicker + componentProps.type = 'datetime' Date time picker
Switch Toggle switch
RadioGroup Radio group
CheckboxGroup Checkbox group
Upload File upload
ImageUpload Image upload
Editor Rich text editor

DictEnum Common Codes

Code Description
DictEnum.SYS_NORMAL_DISABLE Status: Normal/Disable
DictEnum.YES_NO Yes/No
DictEnum.USER_SEX User gender

Auto-generation Checklist

When generating frontend:

  • Create directory structure based on target (app/plugin)
  • Generate index.vue with BasicCrud
  • Generate schemas/index.tsx with CRUD config
  • Generate API service file
  • Generate i18n JSON files (zh-cn and en)
  • Use $t() for all text
  • Use proper i18n key pattern based on target
  • Use proper DictEnum codes
  • Configure proper permissions

Custom Form Checklist (when using custom form)

  • Set hasAdd: false, hasEdit: false, hasView: false in schemas
  • Set formSchema: {} in schemas
  • Create form.vue with useForm + useDialog
  • Import custom Form in index.vue
  • Pass dialogRef: formRef in toolbarActions/tableActions
  • Add required i18n keys: add_title, edit_title, detail_title, validate.*, tips.*