Skip to content

第一个业务模块

本教程以"公告管理"为例,带你走完从建表到前后端功能完成的全流程。完成后你将理解元点Admin 的分层架构和开发工作流。

前置要求

请确保你已按照 开发环境搭建 完成环境配置,且项目能正常运行。

需求分析

我们要实现一个简单的公告管理功能:

功能说明
公告列表分页展示,支持按标题搜索、状态筛选
新增公告标题、内容、状态
编辑公告修改公告信息
删除公告软删除

数据表设计

sql
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_atupdated_atdeleted_at
  • 状态字段使用 status(1 启用,0 禁用)
  • 软删除使用 deleted_at 字段

使用代码生成器

代码生成器可以快速生成完整的 CRUD 骨架代码:

bash
cd server
php think make:crud

按照交互提示选择 announcements 表,生成器会自动创建以下文件:

文件路径
Modelapp/model/announcement/Announcement.php
Repositoryapp/repository/announcement/AnnouncementRepository.php
Serviceapp/service/announcement/AnnouncementService.php
Controllerapp/adminapi/controller/v1/announcement/AnnouncementController.php
Validateapp/adminapi/validate/v1/announcement/AnnouncementValidate.php
Routeapp/adminapi/route/announcement.php
API (TS)admin/src/api/announcement.ts
List Pageadmin/src/pages/announcement/index.vue
Formadmin/src/pages/announcement/components/AnnouncementForm.vue

后端开发

代码生成器生成的是基础骨架,下面逐层讲解如何在此基础上添加业务逻辑。

什么是分层架构?为什么要这样分?

元点Admin 采用 Controller → Service → Repository → Model 四层架构:

  • Controller:接收 HTTP 请求,校验参数,调用 Service,返回响应。不包含业务逻辑。
  • Service:编排业务逻辑,管理事务,触发事件。不直接操作数据库。
  • Repository:封装所有数据库查询,是唯一与 Model 交互的层。
  • Model:ORM 映射,定义关联关系和数据转换。

这样分的好处:每层职责单一,便于测试和维护。当业务逻辑变化时,只需修改 Service;当查询优化时,只需修改 Repository。

Model

Model 负责定义数据表映射和字段转换:

php
<?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
<?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 模式将数据访问逻辑从业务逻辑中分离出来。所有的数据库查询(wherefindcreate 等)都集中在 Repository 中,Service 层不直接调用 Model 的静态方法或 Db::table()

这样做的好处:

  1. 查询逻辑集中管理,避免散落在各处
  2. Service 层只关心业务编排,不关心数据如何存取
  3. 如果将来更换 ORM 或数据源,只需修改 Repository

Service

Service 编排业务逻辑:

php
<?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
<?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
<?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
<?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

typescript
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 — 展示搜索表单 + 数据表格 + 分页:

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

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

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

php
// 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.phplisten 数组中添加:

php
'announcement.created' => [AnnouncementCreatedListener::class],

3. Service 中触发事件

AnnouncementService::create() 中添加:

php
public function create(array $data): array
{
    $result = $this->announcementRepository->create($data);
    $this->trigger('announcement.created', $result);
    return $result;
}
什么时候用事件,什么时候直接写在 Service 里?

判断标准:如果该操作失败不影响主流程,就用事件(Listener);如果必须成功,就留在 Service 中。

例如:

  • 创建公告后发通知 → 通知失败不影响公告创建 → 放 Listener
  • 创建订单后扣余额 → 扣款失败则订单不能创建 → 放 Service(用事务包裹)

测试验证

完成开发后,逐项检查:

  • [ ] 列表页:数据正常显示,分页功能正常
  • [ ] 搜索:按标题搜索、状态筛选均正常
  • [ ] 新增:表单验证生效,提交成功后列表刷新
  • [ ] 编辑:回显数据正确,修改保存成功
  • [ ] 删除:确认弹窗正常,删除后列表刷新
  • [ ] 权限:不同角色看到的操作按钮不同

小结

完成本教程后,你已经掌握了:

  1. 建表 — 字段命名约定
  2. 代码生成 — 快速生成 CRUD 骨架
  3. 分层开发 — Model → Repository → Service → Controller 各层职责
  4. 前端开发 — API 封装、列表页、表单弹窗
  5. 菜单权限 — 菜单注册、按钮权限配置
  6. 事件系统 — 通过 Listener 处理副作用

下一步可以阅读 代码生成器实战 了解更多生成器高级用法。

基于 MIT 许可发布