626 lines
16 KiB
Markdown
626 lines
16 KiB
Markdown
---
|
||
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.*`
|