Generates complete CRUD module from database table definition. Orchestrates 9 sub-skills. Use when user says "create CRUD", "generate module from table", or provides a table structure.
一键生成完整 CRUD 模块 - 从数据表定义到前后端完整实现。
| Step | Skill | 生成文件 |
|---|---|---|
| 1 | 01-parse-table |
- (解析输入) |
| 2 | 02-migration |
database/migrations/*.php |
| 3 | 03-model |
Models/{Model}.php |
| 4 | 04-controller |
Http/Controllers/{Model}Controller.php |
| 5 | 05-request |
Http/Requests/{Model}Request.php |
| 6 | 06-routes |
routes/route.php |
| 7 | 07-export |
Export/{Model}.php |
| 8 | 08-import |
Import/{Model}.php |
| 9 | 09-vue-pages |
index.vue + create.vue |
Table: products
Fields:
- id (primary key)
- name (string, 100, required)
- category_id (foreign key -> categories)
- description (text, nullable)
- price (decimal 10,2, required)
- stock (integer, default 0)
- status (tinyint, default 1)
modules/{Module}/
├── Http/
│ ├── Controllers/{Model}Controller.php
│ └── Requests/{Model}Request.php
├── Models/{Model}.php
├── Export/{Model}.php
├── Import/{Model}.php
├── database/migrations/*.php
└── routes/route.php
web/src/views/{module}/
├── index.vue
└── create.vue
modules/{Module} 不存在,先询问是否创建新模块。modules/{Module}/routes/route.php 是否已有相同资源前缀。config/ 前必须先询问。{Module}/{Model}/{table} 一致。catch-table 的 api 与后端路由前缀保持一致。creator_id/created_at/updated_at/deleted_at + 索引。api/permission 前缀一致。01-parse-tableExtract from user input:
02-migration<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('{table}', function (Blueprint $table) {
$table->id();
// Generated from field definitions
$table->string('name', 100);
$table->unsignedBigInteger('category_id')->default(0)->index();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->unsignedInteger('stock')->default(0);
$table->string('image', 255)->nullable();
$table->tinyInteger('status')->default(1);
$table->unsignedInteger('sort')->default(0);
// CatchAdmin standard fields
$table->unsignedInteger('creator_id')->default(0);
$table->unsignedInteger('created_at')->default(0);
$table->unsignedInteger('updated_at')->default(0);
$table->unsignedInteger('deleted_at')->default(0);
$table->index(['status', 'deleted_at']);
});
}
public function down(): void
{
Schema::dropIfExists('{table}');
}
};
03-model<?php
namespace Modules\{Module}\Models;
use Catch\Base\CatchModel as Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class {Model} extends Model
{
protected $table = '{table}';
protected $fillable = [
'id', 'name', 'category_id', 'description', 'price', 'stock',
'image', 'status', 'sort', 'creator_id', 'created_at', 'updated_at', 'deleted_at',
];
public array $searchable = [
'name' => 'like',
'category_id' => '=',
'status' => '=',
];
protected array $fields = [
'id', 'name', 'category_id', 'price', 'stock', 'status', 'created_at'
];
protected array $form = [
'name', 'category_id', 'description', 'price', 'stock', 'image', 'status', 'sort'
];
// Relationships (auto-detected from foreign keys)
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'category_id');
}
}
04-controller<?php
namespace Modules\{Module}\Http\Controllers;
use Catch\Base\CatchController as Controller;
use Modules\{Module}\Http\Requests\{Model}Request;
use Modules\{Module}\Models\{Model};
use Modules\{Module}\Import\{Model} as {Model}Import;
use Illuminate\Http\Request;
class {Model}Controller extends Controller
{
public function __construct(
protected readonly {Model} $model
) {}
public function index()
{
return $this->model->getList();
}
public function store({Model}Request $request)
{
return $this->model->storeBy($request->all());
}
public function show($id): mixed
{
return $this->model->firstBy($id);
}
public function update($id, {Model}Request $request): mixed
{
return $this->model->updateBy($id, $request->all());
}
public function destroy($id)
{
return $this->model->deleteBy($id);
}
public function enable($id)
{
return $this->model->toggleBy($id);
}
public function export(): mixed
{
return {Model}::query()
->select('id', 'name', 'price', 'stock', 'created_at')
->get()
->download(['ID', 'Name', 'Price', 'Stock', 'Created At']);
}
public function import(Request $request, {Model}Import $import)
{
return $import->import($request->file('file'));
}
}
05-request<?php
namespace Modules\{Module}\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class {Model}Request extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// Auto-generated from field definitions
'name' => 'required|string|max:100',
'category_id' => 'required|integer|exists:categories,id',
'description' => 'nullable|string',
'price' => 'required|numeric|min:0',
'stock' => 'sometimes|integer|min:0',
'image' => 'nullable|string|max:255',
'status' => 'sometimes|integer|in:1,2',
'sort' => 'sometimes|integer|min:0',
];
}
}
06-routes<?php
use Illuminate\Support\Facades\Route;
use Modules\{Module}\Http\Controllers\{Model}Controller;
Route::apiResource('{resources}', {Model}Controller::class);
Route::put('{resources}/enable/{id}', [{Model}Controller::class, 'enable']);
Route::get('{resource}/export', [{Model}Controller::class, 'export']);
Route::post('{resource}/import', [{Model}Controller::class, 'import']);
07-export<?php
namespace Modules\{Module}\Export;
use Catch\Support\Excel\Export;
class {Model} extends Export
{
protected array $header = ['ID', 'Name', 'Price', 'Stock', 'Created At'];
public function array(): array
{
return \Modules\{Module}\Models\{Model}::query()
->select('id', 'name', 'price', 'stock', 'created_at')
->get()
->toArray();
}
}
08-import<?php
namespace Modules\{Module}\Import;
use Catch\Support\Excel\Import;
use Illuminate\Support\Collection;
use Modules\{Module}\Models\{Model};
class {Model}Import extends Import
{
public function collection(Collection $rows): void
{
$rows->skip(1)->each(function ($row) {
{Model}::create([
'name' => $row[0],
'price' => $row[1],
'stock' => $row[2],
]);
});
}
}
09-vue-pagesindex.vue:
<template>
<div>
<catch-table
:columns="columns"
api="{module}/{resources}"
:exports="true"
:trash="true"
permission="{module}.{model}"
exportUrl="/{module}/export"
importUrl="/{module}/import"
:search-form="searchForm"
>
<template #dialog="row">
<Create :primary="row?.id" api="{module}/{resources}" />
</template>
</catch-table>
</div>
</template>
<script lang="ts" setup>
import Create from './create.vue'
const columns = [
{ type: 'selection' },
{ label: 'ID', prop: 'id', width: 80 },
{ label: 'Name', prop: 'name' },
{ label: 'Category', prop: 'category.name' },
{ label: 'Price', prop: 'price', width: 100 },
{ label: 'Stock', prop: 'stock', width: 80 },
{ label: 'Status', prop: 'status', switch: true, width: 80 },
{ label: 'Created At', prop: 'created_at', width: 180 },
{ type: 'operate', label: 'Actions', width: 150, update: true, destroy: true }
]
const searchForm = [
{ type: 'input', label: 'Name', name: 'name' },
{ type: 'select', label: 'Category', name: 'category_id', api: 'categories' },
{ type: 'select', label: 'Status', name: 'status', api: 'status' }
]
</script>
create.vue:
<template>
<el-form :model="formData" label-width="100px" ref="form" v-loading="loading">
<el-form-item label="Name" prop="name" :rules="[{ required: true, message: 'Required' }]">
<el-input v-model="formData.name" maxlength="100" />
</el-form-item>
<el-form-item label="Category" prop="category_id" :rules="[{ required: true, message: 'Required' }]">
<el-select v-model="formData.category_id" class="w-full">
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="Price" prop="price" :rules="[{ required: true, message: 'Required' }]">
<el-input-number v-model="formData.price" :precision="2" :min="0" class="w-full" />
</el-form-item>
<el-form-item label="Stock" prop="stock">
<el-input-number v-model="formData.stock" :min="0" class="w-full" />
</el-form-item>
<el-form-item label="Description" prop="description">
<el-input v-model="formData.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="Status" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">Active</el-radio>
<el-radio :value="2">Inactive</el-radio>
</el-radio-group>
</el-form-item>
<div class="flex justify-end">
<el-button type="primary" @click="submitForm(form)">Submit</el-button>
</div>
</el-form>
</template>
<script lang="ts" setup>
import { inject, onMounted, ref } from 'vue'
import { useCreate } from '@/composables/curd/useCreate'
import { useShow } from '@/composables/curd/useShow'
import http from '@/support/http'
const props = defineProps<{ primary?: string | number; api: string }>()
const { formData, form, loading, submitForm, close } = useCreate(props.api, props.primary)
if (props.primary) useShow(props.api, props.primary, formData)
const closeDialog = inject('closeDialog')
const categories = ref([])
onMounted(async () => {
close(() => closeDialog?.())
const res = await http.get('categories')
categories.value = res.data.data
})
</script>
| Input Type | Migration | Model | Validation | Vue Component |
|---|---|---|---|---|
| string(n) | string('x', n) |
fillable | `string | max:n` |
| text | text('x') |
fillable | string |
el-input type="textarea" |
| integer | unsignedInteger('x') |
fillable | `integer | min:0` |
| decimal(m,n) | decimal('x', m, n) |
fillable | numeric |
el-input-number :precision="n" |
| boolean | tinyInteger('x') |
fillable | `integer | in:1,2` |
| foreign_key | unsignedBigInteger('x') |
belongsTo | exists:table,id |
el-select |
| date | date('x') |
fillable | date |
el-date-picker |
| datetime | dateTime('x') |
fillable | date |
el-date-picker type="datetime" |
| json | json('x') |
$casts['x'] = 'array' |
array |
Custom |
When generating a complete CRUD:
php artisan migrate