AI代码生成工具

This commit is contained in:
李琦
2026-08-11 09:39:41 +08:00
parent cab69b4193
commit 21f39eed85
8 changed files with 431 additions and 4 deletions

View File

@@ -0,0 +1,31 @@
---
description: 代码生成双端产出与 uniapp C 端三页模板约定
alwaysApply: true
---
# 代码生成(双端前端)
`CodeGenerationService::generate` 一次生成必须包含:
1. **后端**Controller / Service / Model / 路由 / 建表 / PC 菜单
2. **PC 前端 zip**`view/<dash-case>/` → `nl-admin-view/.../views/my-gen/`
3. **uniapp 前端 zip**`uniapp/<dash-case>/` → `nl-admin-uniapp/src/pages-sub/my-gen/<dash-case>/`
模板目录:
- `app/template/view/` — Vben Admin
- `app/template/uniapp/` — 移动超管C 端)
# uniapp 生成物强制
- 兼容 **微信小程序 + App + H5**
- **交互必须是三页**`pages/list.vue` → `pages/detail.vue` → `pages/form.vue`
- 列表点击进详情;新增/编辑用全屏表单;**禁止**底部抽屉做主 CRUD
- **C 端 UI**大留白卡片、DetailHero、InfoGroup、BottomBar、PageForm禁止 PC 宽表
- 复用:`AppCardList` / `AppFilterBar` / `AppSearchBar` / `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`
- `AppFormDrawer` 不得作为生成主路径
- zip 内附带 `PAGES_JSON_SNIPPET.md`(注册 list/detail/form 三页 + features.ts
# 与移动端规则对齐
以 `nl-admin-uniapp` 的 `Code-Standards.mdc` 为准;本仓库生成模板不得与之冲突。

View File

@@ -99,5 +99,11 @@ alwaysApply: true
## 代码生成
- 模板在 `app/template/`服务在 `app/Service/core/CodeGenerationService`
- 生成物需仍遵守本文件的分层、基类、路由注册与命名约定;生成后记得在 `routes/api.php` `autoRouteRegister` 中挂上控制器
- 模板在 `app/template/``view/`PC Vben与 `uniapp/`(移动端)两套并存;服务在 `app/Service/core/CodeGenerationService`
- **`generate` 必须同时产出** zip 内的 `view/<dash-case>/` `uniapp/<dash-case>/`,缺一不可
- 生成物需仍遵守本文件的分层、基类、路由注册与命名约定;生成后记得在 `routes/api.php` 的 `autoRouteRegister` 中挂上控制器(当前 `genRoute` 会自动写入)
- **uniapp 模板强制约定**(与移动端规则同口径):
- 必须兼容 **微信小程序 + App + H5**
- 交互必须是 **list → detail → form 三页**;禁止底部抽屉做主 CRUD禁止 PC 宽表
- 复用:`AppCardList` / `AppFilterBar` / `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`
- 粘贴落点:`nl-admin-uniapp/src/pages-sub/my-gen/<dash-case>/`,并同步 `pages.json`(三页)与 `features.ts`

View File

@@ -67,6 +67,19 @@ class CodeGenerationService
private string $viewSearch = '';
private string $searchField = '';
private string $viewData = '';
/** uniapp C 端表单字段 HTML 片段 */
private string $uniFormFields = '';
/** uniapp 表单默认值片段 */
private string $uniFormDefaults = '';
/** uniapp 表单编辑回填片段 */
private string $uniFormAssign = '';
/** uniapp 详情信息行片段 */
private string $uniDetailRows = '';
/** uniapp 卡片标题字段名 */
private string $uniTitleField = 'id';
/** uniapp 卡片副标题字段名 */
private string $uniSubtitleField = '';
private bool $uniTitlePicked = false;
private array $tmpFile = [];
public function __construct()
@@ -228,6 +241,23 @@ class CodeGenerationService
// 生成唯一的临时目录名
$this->tempDir = 'temp/templates/' . $this->upperCamelCase . '-' . $this->uniqid;
Storage::makeDirectory($this->tempDir);
// 重置动态拼接字段,避免批量生成串数据
$this->selectField = '"id"';
$this->insertField = '';
$this->updateField = '';
$this->viewForm = '';
$this->viewSearch = '';
$this->searchField = '';
$this->viewData = '';
$this->uniFormFields = '';
$this->uniFormDefaults = '';
$this->uniFormAssign = '';
$this->uniDetailRows = '';
$this->uniTitleField = 'id';
$this->uniSubtitleField = '';
$this->uniTitlePicked = false;
$this->sql = '';
$this->tmpFile = [];
}
/**
@@ -256,8 +286,10 @@ class CodeGenerationService
$this->genRoute();
// 执行sql
$this->querySql();
// 生成视图
// 生成 PC 视图Vben
$this->genViews();
// 生成 uniapp 视图(微信小程序 + App + H5C 端风格)
$this->genUniappViews();
// 生成菜单
$this->genMenu();
DB::commit();
@@ -389,7 +421,7 @@ class CodeGenerationService
}
/**
* 生成前端部分文件
* 生成 PC 前端文件Vben写入 zip view/ 目录
* @return void
*/
private function genViews(): void
@@ -413,6 +445,31 @@ class CodeGenerationService
}
}
/**
* 生成 uniapp 前端文件C list/detail/form 三页),写入 zip uniapp/ 目录
* 禁止抽屉主路径;必须兼容微信小程序+App+H5
* @return void
*/
private function genUniappViews(): void
{
$appPath = app_path();
$uniList = [
'uni-api' => 'api/index.ts',
'uni-list' => 'pages/list.vue',
'uni-detail' => 'pages/detail.vue',
'uni-form' => 'pages/form.vue',
'uni-readme' => 'PAGES_JSON_SNIPPET.md',
];
foreach ($uniList as $k => $v) {
$this->strReplaceTmp(
$appPath . '/template/uniapp/' . $v,
$this->tempDir . "/uniapp/{$this->dashCase}/" . $v,
'uniapp',
$k
);
}
}
/**
* 生成菜单/授权
*
@@ -477,6 +534,20 @@ class CodeGenerationService
case 'view-table':
$newFile = str_replace("// 生成的列表字段", "// 生成的列表字段 \r ". $this->viewData, $newFile);
break;
case 'uni-list':
$newFile = str_replace('UNI_TITLE_FIELD', $this->uniTitleField, $newFile);
$newFile = str_replace('UNI_SUBTITLE_FIELD', $this->uniSubtitleField ?: $this->uniTitleField, $newFile);
break;
case 'uni-detail':
$newFile = str_replace('// UNI_DETAIL_ROWS', $this->uniDetailRows ?: '', $newFile);
$newFile = str_replace('UNI_TITLE_FIELD', $this->uniTitleField, $newFile);
$newFile = str_replace('UNI_SUBTITLE_FIELD', $this->uniSubtitleField ?: $this->uniTitleField, $newFile);
break;
case 'uni-form':
$newFile = str_replace('<!-- UNI_FORM_FIELDS -->', $this->uniFormFields ?: '<!-- 无表单字段 -->', $newFile);
$newFile = str_replace('// UNI_FORM_DEFAULTS', $this->uniFormDefaults ?: '', $newFile);
$newFile = str_replace('// UNI_FORM_ASSIGN', $this->uniFormAssign ?: '', $newFile);
break;
}
$this->tmpFile[$type][$fileKey] = $newFile;
@@ -510,6 +581,22 @@ class CodeGenerationService
$this->insertField .= !empty($this->insertField) ? '","' . $item['name'] : $item['name'];
$this->updateField .= !empty($this->updateField) ? '","' . $item['name'] : 'id","' . $item['name'];
$this->viewForm .= "{\n fieldName: '" . $item['name'] . "',\n label: '" . $item['comment'] . "',\n component: '" . $item['formType'] . "',\n rules: " . $isNull . ",\n },\n";
// uniapp C 端表单:用 input 承载,避免生成 Vben 组件名
$name = $item['name'];
$label = $item['comment'] ?: $name;
$this->uniFormDefaults .= " {$name}: '',\n";
$this->uniFormAssign .= " {$name}: info?.{$name} ?? '',\n";
$this->uniDetailRows .= " { label: '{$label}', value: info.value.{$name} },\n";
$this->uniFormFields .= "<view class=\"form-item\">\n"
. " <text class=\"label\">{$label}</text>\n"
. " <input v-model=\"form.{$name}\" class=\"input\" placeholder=\"{$label}\" />\n"
. " </view>\n";
if (!$this->uniTitlePicked) {
$this->uniTitleField = $name;
$this->uniTitlePicked = true;
} elseif ($this->uniSubtitleField === '') {
$this->uniSubtitleField = $name;
}
}
// 设置搜索字段
if ($item['search']) {

View File

@@ -0,0 +1,35 @@
# 模板名称 — uniapp 粘贴说明C 端三页)
## 落点
`nl-admin-uniapp/src/pages-sub/my-gen/dashCase/`
## pages.json 片段
`subPackages``root: pages-sub/my-gen``pages` 追加:
```json
{ "path": "dashCase/pages/list", "style": { "navigationBarTitleText": "模板名称" } },
{ "path": "dashCase/pages/detail", "style": { "navigationBarTitleText": "模板名称详情" } },
{ "path": "dashCase/pages/form", "style": { "navigationBarTitleText": "编辑模板名称" } }
```
## features.ts 片段
```ts
{
key: 'dashCase',
title: '模板名称',
desc: '代码生成模块',
icon: '码',
category: 'gen',
path: '/pages-sub/my-gen/dashCase/pages/list',
keywords: ['模板名称'],
}
```
## 强制约定
- 兼容微信小程序 + App + H5
- 交互:列表 → 详情 → 全屏表单;**禁止**底部抽屉做主 CRUD
- 复用 `AppCardList` / `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`

View File

@@ -0,0 +1,33 @@
/**
* 模板名称 APIuniapp
* 粘贴后路径pages-sub/my-gen/dashCase/api/index.ts
* 必须兼容微信小程序 + App + H5
*/
import { get, post } from '@/utils/request'
const prefix = 'dashCase/'
/** 分页列表 */
export function getupperCamelCaseList(params: Record<string, any> = {}) {
return get<any>(`${prefix}list`, params)
}
/** 详情 */
export function getupperCamelCaseInfo(id: number) {
return get<any>(`${prefix}detail`, { id })
}
/** 新增 */
export function createupperCamelCase(data: Record<string, any>) {
return post<any>(`${prefix}create`, data)
}
/** 更新 */
export function updateupperCamelCase(data: Record<string, any>) {
return post<any>(`${prefix}update`, data)
}
/** 删除 */
export function deleteupperCamelCase(data: Record<string, any>) {
return post<any>(`${prefix}delete`, data)
}

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/**
* 模板名称详情:信息展示 + 底栏编辑/删除(禁止抽屉主路径)
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { deleteupperCamelCase, getupperCamelCaseInfo } from '../api'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(async () => {
if (id.value) info.value = (await getupperCamelCaseInfo(id.value)) || {}
})
const title = computed(() => String(info.value.UNI_TITLE_FIELD || `模板名称#${id.value}`))
const rows = computed<AppInfoRow[]>(() => [
// UNI_DETAIL_ROWS
{ label: '状态', value: Number(info.value.status) === 0 ? '正常' : '禁用' },
{ label: '创建时间', value: info.value.created_at },
])
function goEdit() {
uni.navigateTo({ url: `/pages-sub/my-gen/dashCase/pages/form?id=${id.value}` })
}
function onDelete() {
uni.showModal({
title: '删除确认',
content: '确定删除该记录吗?',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteupperCamelCase({ ids: [id.value] })
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<view class="nl-page">
<AppDetailHero
:title="title"
:subtitle="String(info.UNI_SUBTITLE_FIELD || '')"
:status-text="Number(info.status) === 0 ? '正常' : '禁用'"
:status-type="Number(info.status) === 0 ? 'success' : 'danger'"
/>
<AppInfoGroup title="详细信息" :items="rows" />
<AppBottomBar primary-text="编辑" danger-text="删除" @primary="goEdit" @danger="onDelete" />
</view>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
/**
* 模板名称新建/编辑全屏表单(替代底部抽屉)
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { createupperCamelCase, getupperCamelCaseInfo, updateupperCamelCase } from '../api'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
const form = ref<Record<string, any>>({
// UNI_FORM_DEFAULTS
})
const isEdit = computed(() => !!id.value)
onLoad(async (q) => {
id.value = Number(q?.id || 0)
if (id.value) {
const info = await getupperCamelCaseInfo(id.value)
form.value = {
// UNI_FORM_ASSIGN
}
// 回填兜底
Object.assign(form.value, info || {})
}
})
async function onSubmit() {
submitting.value = true
try {
if (isEdit.value) await updateupperCamelCase({ id: id.value, ...form.value })
else await createupperCamelCase({ ...form.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm tip="请填写模板名称信息后保存。" :loading="submitting" @submit="onSubmit">
<!-- UNI_FORM_FIELDS -->
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.label { width: 160rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input { flex: 1; text-align: right; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
/**
* 模板名称列表C 端):点击进详情,新增进全屏表单
* 兼容微信小程序 / App / H5
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getupperCamelCaseList } from '../api'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppFilterBar from '@/components/AppFilterBar.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const keyword = ref('')
const draftKeyword = ref('')
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const selectedCount = computed(() => (keyword.value ? 1 : 0))
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
title: String(item.UNI_TITLE_FIELD || `模板名称#${item.id}`),
subtitle: String(item.UNI_SUBTITLE_FIELD || ''),
iconText: String(item.UNI_TITLE_FIELD || '模').slice(0, 1),
statusText: Number(item.status) === 0 ? '正常' : '禁用',
statusType: Number(item.status) === 0 ? 'success' : 'danger',
})),
)
onShow(() => reload())
// pages.json 该页需 enablePullDownRefresh: true
usePullRefresh(reload)
async function reload() {
page.value = 1
finished.value = false
list.value = []
await loadMore()
}
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
const res = await getupperCamelCaseList({
page: page.value,
pageSize: 20,
keyword: keyword.value || undefined,
})
const items = res?.items || res?.data || []
list.value = page.value === 1 ? items : list.value.concat(items)
const total = Number(res?.total || 0)
if (!items.length || list.value.length >= total) finished.value = true
else page.value += 1
} finally {
loading.value = false
}
}
function onFilterReset() {
draftKeyword.value = ''
}
function onFilterConfirm() {
keyword.value = draftKeyword.value
reload()
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/my-gen/dashCase/pages/detail?id=${item.id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/my-gen/dashCase/pages/form' })
}
</script>
<template>
<view class="nl-page page">
<AppSearchBar
v-model="keyword"
placeholder="搜索模板名称"
@search="(v) => { keyword = v; reload() }"
@clear="() => { keyword = ''; reload() }"
/>
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
<view class="filter-row">
<text>关键词</text>
<input v-model="draftKeyword" class="filter-input" placeholder="关键词" />
</view>
</AppFilterBar>
<AppCardList v-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无数据" />
<view v-if="cards.length" class="more" @tap="loadMore">
{{ finished ? '没有更多了' : loading ? '加载中' : '加载更多' }}
</view>
<view class="fab" @tap="goCreate">新增</view>
</view>
</template>
<style scoped lang="scss">
.page { padding-bottom: 160rpx; }
.filter-row {
display: flex; align-items: center; justify-content: space-between; min-height: 72rpx; font-size: 26rpx;
}
.filter-input { flex: 1; text-align: right; margin-left: 24rpx; }
.more { text-align: center; color: $nl-color-text-muted; padding: 16rpx; font-size: 24rpx; }
.fab {
position: fixed; right: 32rpx; bottom: calc(48rpx + env(safe-area-inset-bottom));
min-width: 120rpx; height: 88rpx; padding: 0 36rpx; border-radius: 999rpx;
background: $nl-color-primary; color: #fff; display: flex; align-items: center; justify-content: center;
font-weight: 700; box-shadow: 0 16rpx 40rpx rgba(255, 107, 0, 0.35);
}
</style>