第一个业务模块
本教程以"公告管理"为例,带你走完从建表到前后端功能完成的全流程。完成后你将理解元点Admin 的分层架构和开发工作流。
前置要求
请确保你已按照 开发环境搭建 完成环境配置,且项目能正常运行。
需求分析
我们要实现一个简单的公告管理功能:
| 功能 | 说明 |
|---|---|
| 公告列表 | 分页展示,支持按标题搜索、状态筛选 |
| 新增公告 | 标题、内容、状态 |
| 编辑公告 | 修改公告信息 |
| 删除公告 | 软删除 |
数据表设计
CREATE TABLE `announcements` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(200) NOT NULL COMMENT '公告标题',
`content` text NOT NULL COMMENT '公告内容',
`status` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用',
`sort` int unsigned NOT NULL DEFAULT 0 COMMENT '排序',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告表';字段命名约定:
- 时间戳统一使用
created_at、updated_at、deleted_at - 状态字段使用
status(1 启用,0 禁用) - 软删除使用
deleted_at字段
使用代码生成器
代码生成器可以快速生成完整的 CRUD 骨架代码:
cd server
php think make:crud按照交互提示选择 announcements 表,生成器会自动创建以下文件:
| 文件 | 路径 |
|---|---|
| Model | app/model/announcement/Announcement.php |
| Repository | app/repository/announcement/AnnouncementRepository.php |
| Service | app/service/announcement/AnnouncementService.php |
| Controller | app/adminapi/controller/v1/announcement/AnnouncementController.php |
| Validate | app/adminapi/validate/v1/announcement/AnnouncementValidate.php |
| Route | app/adminapi/route/announcement.php |
| API (TS) | admin/src/api/announcement.ts |
| List Page | admin/src/pages/announcement/index.vue |
| Form | admin/src/pages/announcement/components/AnnouncementForm.vue |
后端开发
代码生成器生成的是基础骨架,下面逐层讲解如何在此基础上添加业务逻辑。
什么是分层架构?为什么要这样分?
元点Admin 采用 Controller → Service → Repository → Model 四层架构:
- Controller:接收 HTTP 请求,校验参数,调用 Service,返回响应。不包含业务逻辑。
- Service:编排业务逻辑,管理事务,触发事件。不直接操作数据库。
- Repository:封装所有数据库查询,是唯一与 Model 交互的层。
- Model:ORM 映射,定义关联关系和数据转换。
这样分的好处:每层职责单一,便于测试和维护。当业务逻辑变化时,只需修改 Service;当查询优化时,只需修改 Repository。
Model
Model 负责定义数据表映射和字段转换:
<?php
declare(strict_types=1);
namespace app\model\announcement;
use core\base\Model;
class Announcement extends Model
{
protected $table = 'announcements';
// 状态常量,避免硬编码数字
public const STATUS_DISABLED = 0;
public const STATUS_ENABLED = 1;
// 访问器:将 status 数字转为文字
public function getStatusTextAttr($value, $data): string
{
return $data['status'] == self::STATUS_ENABLED ? '启用' : '禁用';
}
// 声明 append,访问器字段才会出现在 toArray() 输出中
protected $append = ['status_text'];
}重要
定义 getXxxAttr() 访问器时,必须同时声明 protected $append = ['xxx'],否则该字段不会出现在 API 响应中。这是新手最常犯的错误。
Repository
Repository 封装所有数据库查询逻辑:
<?php
declare(strict_types=1);
namespace app\repository\announcement;
use app\model\announcement\Announcement;
use core\base\Repository;
class AnnouncementRepository extends Repository
{
protected string $modelClass = Announcement::class;
/**
* 搜索列表(分页)
*/
public function getSearchList(array $params, int $page, int $limit): array
{
$query = $this->model->where('deleted_at', null);
// 按标题搜索
if (!empty($params['title'])) {
$query->whereLike('title', '%' . $params['title'] . '%');
}
// 按状态筛选
if (isset($params['status']) && $params['status'] !== '') {
$query->where('status', (int) $params['status']);
}
// 排序:sort 升序,创建时间降序
$query->order('sort', 'asc')->order('created_at', 'desc');
return $this->paginate($query, $page, $limit);
}
}什么是 Repository 模式?
Repository 模式将数据访问逻辑从业务逻辑中分离出来。所有的数据库查询(where、find、create 等)都集中在 Repository 中,Service 层不直接调用 Model 的静态方法或 Db::table()。
这样做的好处:
- 查询逻辑集中管理,避免散落在各处
- Service 层只关心业务编排,不关心数据如何存取
- 如果将来更换 ORM 或数据源,只需修改 Repository
Service
Service 编排业务逻辑:
<?php
declare(strict_types=1);
namespace app\service\announcement;
use app\repository\announcement\AnnouncementRepository;
use core\base\Service;
class AnnouncementService extends Service
{
// 自动注入,无需手动实例化
protected AnnouncementRepository $announcementRepository;
public function getList(array $params): array
{
$page = (int) ($params['page_no'] ?? 1);
$limit = (int) ($params['page_size'] ?? 20);
return $this->announcementRepository->getSearchList($params, $page, $limit);
}
public function detail(int $id): ?array
{
return $this->announcementRepository->find($id);
}
public function create(array $data): array
{
return $this->announcementRepository->create($data);
}
public function update(int $id, array $data): bool
{
return $this->announcementRepository->update($id, $data);
}
public function delete(int $id): bool
{
return $this->announcementRepository->delete($id);
}
}依赖注入是怎么工作的?
基类 Service 会自动扫描子类中声明的 protected 属性,如果属性类型是一个类(如 AnnouncementRepository),就会自动从容器中获取实例并赋值。你只需要声明属性,不需要写构造函数或手动 new。
Controller 也有同样的机制。
Controller
Controller 接收请求,校验参数,调用 Service:
<?php
declare(strict_types=1);
namespace app\adminapi\controller\v1\announcement;
use app\service\announcement\AnnouncementService;
use app\adminapi\validate\v1\announcement\AnnouncementValidate;
use core\base\Controller;
class AnnouncementController extends Controller
{
protected AnnouncementService $announcementService;
public function list(): \think\Response
{
$params = $this->request->get();
$result = $this->announcementService->getList($params);
return $this->success('success', $result);
}
public function detail(int $id): \think\Response
{
$result = $this->announcementService->detail($id);
return $this->success('success', $result);
}
public function create(): \think\Response
{
$data = $this->request->post();
$this->validate($data, AnnouncementValidate::class, [], false, 'create');
$result = $this->announcementService->create($data);
return $this->success('创建成功', $result);
}
public function update(int $id): \think\Response
{
$data = $this->request->post();
$this->validate($data, AnnouncementValidate::class, [], false, 'update');
$this->announcementService->update($id, $data);
return $this->success('更新成功');
}
public function delete(int $id): \think\Response
{
$this->announcementService->delete($id);
return $this->success('删除成功');
}
}Validate
定义验证规则和场景:
<?php
declare(strict_types=1);
namespace app\adminapi\validate\v1\announcement;
use think\Validate;
class AnnouncementValidate extends Validate
{
protected $rule = [
'title' => 'require|max:200',
'content' => 'require',
'status' => 'in:0,1',
'sort' => 'integer|egt:0',
];
protected $message = [
'title.require' => '公告标题不能为空',
'title.max' => '公告标题最多200个字符',
'content.require' => '公告内容不能为空',
];
protected $scene = [
'create' => ['title', 'content', 'status', 'sort'],
'update' => ['title', 'content', 'status', 'sort'],
];
}路由注册
代码生成器会自动创建路由文件 app/adminapi/route/announcement.php:
<?php
use think\facade\Route;
Route::group('announcement', function () {
Route::get('list', 'list');
Route::get('detail/:id', 'detail');
Route::post('create', 'create');
Route::put('update/:id', 'update');
Route::delete('delete/:id', 'delete');
})->prefix('v1.announcement.AnnouncementController@')
->middleware(['admin_auth', 'admin_permission', 'admin_log']);前端开发
API 文件
admin/src/api/announcement.ts:
import myRequest from '@/utils/request'
// 公告列表
export function getAnnouncementList(params: object) {
return myRequest.get('/adminapi/announcement/list', { params })
}
// 公告详情
export function getAnnouncementDetail(id: number) {
return myRequest.get(`/adminapi/announcement/detail/${id}`)
}
// 新增公告
export function createAnnouncement(data: object) {
return myRequest.post('/adminapi/announcement/create', data)
}
// 编辑公告
export function updateAnnouncement(id: number, data: object) {
return myRequest.put(`/adminapi/announcement/update/${id}`, data)
}
// 删除公告
export function deleteAnnouncement(id: number) {
return myRequest.delete(`/adminapi/announcement/delete/${id}`)
}列表页
admin/src/pages/announcement/index.vue — 展示搜索表单 + 数据表格 + 分页:
<template>
<div class="app-container">
<!-- 搜索区域 -->
<el-card shadow="never" class="mb-4">
<el-form :model="queryParams" inline>
<el-form-item label="标题">
<el-input v-model="queryParams.title" placeholder="请输入标题" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 + 表格 -->
<el-card shadow="never">
<template #header>
<el-button type="primary" v-hasPerm="'announcement.create'" @click="handleCreate">
新增公告
</el-button>
</template>
<el-table :data="tableData" v-loading="loading">
<el-table-column prop="title" label="标题" />
<el-table-column prop="status_text" label="状态" width="100" />
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" v-hasPerm="'announcement.update'" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="danger" v-hasPerm="'announcement.delete'" @click="handleDelete(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="mt-4 justify-end"
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
:total="total"
@current-change="getList"
@size-change="getList"
/>
</el-card>
<!-- 表单弹窗 -->
<AnnouncementForm ref="formRef" @success="getList" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAnnouncementList, deleteAnnouncement } from '@/api/announcement'
import AnnouncementForm from './components/AnnouncementForm.vue'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = ref({
title: '',
status: '',
page_no: 1,
page_size: 20,
})
async function getList() {
loading.value = true
try {
const res = await getAnnouncementList(queryParams.value)
tableData.value = res.data.list
total.value = res.data.total
} finally {
loading.value = false
}
}
function handleSearch() {
queryParams.value.page_no = 1
getList()
}
function handleReset() {
queryParams.value = { title: '', status: '', page_no: 1, page_size: 20 }
getList()
}
function handleCreate() {
formRef.value?.open()
}
function handleEdit(row: any) {
formRef.value?.open(row.id)
}
async function handleDelete(id: number) {
await ElMessageBox.confirm('确定删除该公告吗?', '提示', { type: 'warning' })
await deleteAnnouncement(id)
ElMessage.success('删除成功')
getList()
}
onMounted(() => getList())
</script>表单弹窗组件
admin/src/pages/announcement/components/AnnouncementForm.vue:
<template>
<el-dialog v-model="visible" :title="form.id ? '编辑公告' : '新增公告'" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入公告标题" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="请输入公告内容" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import {
getAnnouncementDetail,
createAnnouncement,
updateAnnouncement,
} from '@/api/announcement'
const emit = defineEmits(['success'])
const visible = ref(false)
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const defaultForm = { id: 0, title: '', content: '', sort: 0, status: 1 }
const form = reactive({ ...defaultForm })
const rules: FormRules = {
title: [{ required: true, message: '请输入公告标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
}
async function open(id?: number) {
Object.assign(form, { ...defaultForm })
if (id) {
const res = await getAnnouncementDetail(id)
Object.assign(form, res.data)
}
visible.value = true
}
async function handleSubmit() {
await formRef.value?.validate()
submitLoading.value = true
try {
if (form.id) {
await updateAnnouncement(form.id, form)
} else {
await createAnnouncement(form)
}
ElMessage.success(form.id ? '编辑成功' : '新增成功')
visible.value = false
emit('success')
} finally {
submitLoading.value = false
}
}
defineExpose({ open })
</script>菜单与权限配置
在管理后台「菜单管理」中添加菜单,或直接编辑 server/public/install/data/init.sql。
添加菜单 SQL
-- 一级菜单(注意 ID 不要与已有菜单冲突,使用 SELECT MAX(id) FROM menus 查看)
INSERT INTO `menus` (`id`, `pid`, `type`, `title`, `name`, `path`, `icon`, `component`, `permission`, `sort`, `status`)
VALUES
(1000, 0, 1, '公告管理', 'announcement', '/announcement', 'Notification', '', '', 5, 1),
(1001, 1000, 2, '公告列表', 'announcementList', '/announcement/index', '', 'announcement/index', 'announcement.list', 1, 1),
(1002, 1001, 3, '新增', '', '', '', '', 'announcement.create', 1, 1),
(1003, 1001, 3, '编辑', '', '', '', '', 'announcement.update', 2, 1),
(1004, 1001, 3, '删除', '', '', '', '', 'announcement.delete', 3, 1);菜单 type 说明:
1= 一级目录2= 页面菜单(对应 vue 组件路径)3= 按钮权限(对应v-hasPerm中的权限标识)
ID 分配规则: 一级菜单 ID 按百位段分配(如 1000),子菜单和按钮递增(1001、1002...)。新增前务必检查已用 ID。
分配权限
在「角色管理」中编辑角色,勾选新增的公告管理菜单和按钮权限。
添加副作用(可选)
如果需要在创建公告时触发通知,可以通过事件系统实现:
1. 创建 Listener
// app/listener/AnnouncementCreatedListener.php
<?php
declare(strict_types=1);
namespace app\listener;
class AnnouncementCreatedListener
{
public function handle(array $data): void
{
// 例如:发送通知、清除缓存、记录日志
\think\facade\Log::info('公告已创建: ' . $data['title']);
}
}2. 注册事件
在 app/event.php 的 listen 数组中添加:
'announcement.created' => [AnnouncementCreatedListener::class],3. Service 中触发事件
在 AnnouncementService::create() 中添加:
public function create(array $data): array
{
$result = $this->announcementRepository->create($data);
$this->trigger('announcement.created', $result);
return $result;
}什么时候用事件,什么时候直接写在 Service 里?
判断标准:如果该操作失败不影响主流程,就用事件(Listener);如果必须成功,就留在 Service 中。
例如:
- 创建公告后发通知 → 通知失败不影响公告创建 → 放 Listener
- 创建订单后扣余额 → 扣款失败则订单不能创建 → 放 Service(用事务包裹)
测试验证
完成开发后,逐项检查:
- [ ] 列表页:数据正常显示,分页功能正常
- [ ] 搜索:按标题搜索、状态筛选均正常
- [ ] 新增:表单验证生效,提交成功后列表刷新
- [ ] 编辑:回显数据正确,修改保存成功
- [ ] 删除:确认弹窗正常,删除后列表刷新
- [ ] 权限:不同角色看到的操作按钮不同
小结
完成本教程后,你已经掌握了:
- 建表 — 字段命名约定
- 代码生成 — 快速生成 CRUD 骨架
- 分层开发 — Model → Repository → Service → Controller 各层职责
- 前端开发 — API 封装、列表页、表单弹窗
- 菜单权限 — 菜单注册、按钮权限配置
- 事件系统 — 通过 Listener 处理副作用
下一步可以阅读 代码生成器实战 了解更多生成器高级用法。