16 KiB
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: falsein schemas - Set
formSchema: {}in schemas - Create form.vue with
useForm+useDialog - Import custom Form in index.vue
- Pass
dialogRef: formRefin toolbarActions/tableActions - Add required i18n keys:
add_title,edit_title,detail_title,validate.*,tips.*