Files

626 lines
16 KiB
Markdown
Raw Permalink Normal View History

2026-08-31 11:33:59 +08:00
---
name: frontend-admin-gen
description: 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
```vue
<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
```typescript
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.
```typescript
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
```typescript
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
```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
```typescript
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
```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
```typescript
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)
```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
```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.*`