Files
server/docs/DEVELOPMENT_GUIDE.md
T
2026-01-16 15:49:34 +08:00

36 KiB
Raw Blame History

开发指南:留言板模块 CRUD 完整示例

本文档以留言板模块为例,详细说明如何开发一个完整的增删改查功能,包括后端接口和前端页面。

目录


概述

留言板模块功能需求:

  • 留言列表(支持分页、搜索、筛选)
  • 创建留言
  • 编辑留言
  • 删除留言
  • 回复留言(可选)

数据库表结构:

  • guestbooks 表:留言主表
  • 字段:id, name, email, content, reply, status, ip, user_agent, created_at, updated_at, deleted_at

后端开发

1. 数据库迁移

创建迁移文件:database/migrations/20250101000029_create_guestbooks_table.go

package migrations

import (
	"github.com/goravel/framework/contracts/database/schema"
	"github.com/goravel/framework/facades"
)

type M20250101000029CreateGuestbooksTable struct{}

func (r *M20250101000029CreateGuestbooksTable) Signature() string {
	return "20250101000029_create_guestbooks_table"
}

func (r *M20250101000029CreateGuestbooksTable) Up() error {
	if !facades.Schema().HasTable("guestbooks") {
		return facades.Schema().Create("guestbooks", func(table schema.Blueprint) {
			table.BigIncrements("id")
			table.String("name", 50).Comment("留言人姓名")
			table.String("email", 100).Comment("留言人邮箱")
			table.Text("content").Comment("留言内容")
			table.Text("reply").Nullable().Comment("回复内容")
			table.UnsignedTinyInteger("status").Default(1).Comment("状态:1-已审核,0-待审核")
			table.String("ip", 50).Nullable().Comment("IP地址")
			table.String("user_agent", 500).Nullable().Comment("用户代理")
			table.Timestamps()
			table.SoftDeletes()
			table.Comment("留言板表")
			
			// 索引
			table.Index("status")
			table.Index("created_at")
		})
	}
	return nil
}

func (r *M20250101000029CreateGuestbooksTable) Down() error {
	return facades.Schema().DropIfExists("guestbooks")
}

2. 创建模型

创建模型文件:app/models/guestbook.go

package models

import (
	"github.com/goravel/framework/database/orm"
)

type Guestbook struct {
	orm.Model
	Name      string `gorm:"size:50;comment:留言人姓名" json:"name"`
	Email     string `gorm:"size:100;comment:留言人邮箱" json:"email"`
	Content   string `gorm:"type:text;comment:留言内容" json:"content"`
	Reply     string `gorm:"type:text;comment:回复内容" json:"reply"`
	Status    uint8  `gorm:"default:1;comment:状态 1:已审核 0:待审核" json:"status"`
	IP        string `gorm:"size:50;comment:IP地址" json:"ip"`
	UserAgent string `gorm:"size:500;comment:用户代理" json:"user_agent"`
	orm.SoftDeletes
}

3. 创建服务层

创建服务接口和实现:app/services/guestbook_service.go

package services

import (
	"github.com/goravel/framework/contracts/database/orm"
	"github.com/goravel/framework/facades"
	"github.com/spf13/cast"

	apperrors "goravel/app/errors"
	"goravel/app/http/helpers"
	"goravel/app/models"
)

type GuestbookService interface {
	GetByID(id uint) (*models.Guestbook, error)
	GetList(filters GuestbookFilters, page, pageSize int) ([]models.Guestbook, int64, error)
	Create(data map[string]any) (*models.Guestbook, error)
	Update(id uint, data map[string]any) error
	Delete(id uint) error
	Reply(id uint, replyContent string) error
}

type GuestbookFilters struct {
	Name      string
	Email     string
	Status    string
	StartTime string
	EndTime   string
	OrderBy   string
}

type GuestbookServiceImpl struct{}

func NewGuestbookServiceImpl() *GuestbookServiceImpl {
	return &GuestbookServiceImpl{}
}

func (s *GuestbookServiceImpl) GetByID(id uint) (*models.Guestbook, error) {
	var guestbook models.Guestbook
	if err := facades.Orm().Query().Where("id", id).First(&guestbook); err != nil {
		return nil, apperrors.ErrNotFound.WithError(err)
	}
	return &guestbook, nil
}

func (s *GuestbookServiceImpl) buildQuery(filters GuestbookFilters) orm.Query {
	query := facades.Orm().Query().Model(&models.Guestbook{})

	if filters.Name != "" {
		query = query.Where("name LIKE ?", "%"+filters.Name+"%")
	}
	if filters.Email != "" {
		query = query.Where("email LIKE ?", "%"+filters.Email+"%")
	}
	if filters.Status != "" {
		query = query.Where("status", filters.Status)
	}
	if filters.StartTime != "" {
		query = query.Where("created_at >= ?", filters.StartTime)
	}
	if filters.EndTime != "" {
		query = query.Where("created_at <= ?", filters.EndTime)
	}

	return query
}

func (s *GuestbookServiceImpl) GetList(filters GuestbookFilters, page, pageSize int) ([]models.Guestbook, int64, error) {
	query := s.buildQuery(filters)

	orderBy := filters.OrderBy
	if orderBy == "" {
		orderBy = "created_at:desc"
	}
	query = helpers.ApplySort(query, orderBy, "created_at:desc")

	total, err := query.Count()
	if err != nil {
		return nil, 0, err
	}

	var guestbooks []models.Guestbook
	err = query.Offset((page-1)*pageSize).Limit(pageSize).Find(&guestbooks)
	if err != nil {
		return nil, 0, err
	}

	return guestbooks, total, nil
}

func (s *GuestbookServiceImpl) Create(data map[string]any) (*models.Guestbook, error) {
	var guestbook models.Guestbook
	if err := facades.Orm().Query().Create(&guestbook, data); err != nil {
		return nil, err
	}
	return &guestbook, nil
}

func (s *GuestbookServiceImpl) Update(id uint, data map[string]any) error {
	var guestbook models.Guestbook
	if err := facades.Orm().Query().Where("id", id).First(&guestbook); err != nil {
		return apperrors.ErrNotFound.WithError(err)
	}
	return facades.Orm().Query().Save(&guestbook, data)
}

func (s *GuestbookServiceImpl) Delete(id uint) error {
	var guestbook models.Guestbook
	if err := facades.Orm().Query().Where("id", id).First(&guestbook); err != nil {
		return apperrors.ErrNotFound.WithError(err)
	}
	_, err := facades.Orm().Query().Delete(&guestbook)
	return err
}

func (s *GuestbookServiceImpl) Reply(id uint, replyContent string) error {
	var guestbook models.Guestbook
	if err := facades.Orm().Query().Where("id", id).First(&guestbook); err != nil {
		return apperrors.ErrNotFound.WithError(err)
	}
	guestbook.Reply = replyContent
	guestbook.Status = 1 // 回复后自动审核通过
	return facades.Orm().Query().Save(&guestbook)
}

4. 创建请求验证

创建创建请求验证:app/http/requests/admin/guestbook_create.go

package admin

import (
	"goravel/app/http/trans"

	"github.com/goravel/framework/contracts/http"
)

type GuestbookCreate struct {
	Name    string `form:"name" json:"name"`
	Email   string `form:"email" json:"email"`
	Content string `form:"content" json:"content"`
	Status  uint8  `form:"status" json:"status"`
}

func (r *GuestbookCreate) Authorize(ctx http.Context) error {
	return nil
}

func (r *GuestbookCreate) Rules(ctx http.Context) map[string]string {
	return map[string]string{
		"name":    "required|max_len:50",
		"email":   "required|email|max_len:100",
		"content": "required|min_len:5|max_len:2000",
		"status":  "in:0,1",
	}
}

func (r *GuestbookCreate) Messages(ctx http.Context) map[string]string {
	return map[string]string{
		"name.required":    trans.Get(ctx, "validation_name_required"),
		"name.max_len":     trans.Get(ctx, "validation_name_max"),
		"email.required":   trans.Get(ctx, "validation_email_required"),
		"email.email":       trans.Get(ctx, "validation_email_format"),
		"content.required": trans.Get(ctx, "validation_content_required"),
		"content.min_len":  trans.Get(ctx, "validation_content_min"),
		"content.max_len":  trans.Get(ctx, "validation_content_max"),
		"status.in":         trans.Get(ctx, "validation_status_in"),
	}
}

func (r *GuestbookCreate) Attributes(ctx http.Context) map[string]string {
	return map[string]string{
		"name":    trans.Get(ctx, "validation_name"),
		"email":   trans.Get(ctx, "validation_email"),
		"content": trans.Get(ctx, "validation_content"),
		"status":  trans.Get(ctx, "validation_status"),
	}
}

创建更新请求验证:app/http/requests/admin/guestbook_update.go

package admin

import (
	"goravel/app/http/trans"

	"github.com/goravel/framework/contracts/http"
)

type GuestbookUpdate struct {
	Name    string `form:"name" json:"name"`
	Email   string `form:"email" json:"email"`
	Content string `form:"content" json:"content"`
	Reply   string `form:"reply" json:"reply"`
	Status  uint8  `form:"status" json:"status"`
}

func (r *GuestbookUpdate) Authorize(ctx http.Context) error {
	return nil
}

func (r *GuestbookUpdate) Rules(ctx http.Context) map[string]string {
	return map[string]string{
		"name":    "max_len:50",
		"email":   "email|max_len:100",
		"content": "min_len:5|max_len:2000",
		"reply":   "max_len:2000",
		"status":  "in:0,1",
	}
}

func (r *GuestbookUpdate) Messages(ctx http.Context) map[string]string {
	return map[string]string{
		"name.max_len":     trans.Get(ctx, "validation_name_max"),
		"email.email":      trans.Get(ctx, "validation_email_format"),
		"content.min_len":  trans.Get(ctx, "validation_content_min"),
		"content.max_len":  trans.Get(ctx, "validation_content_max"),
		"reply.max_len":    trans.Get(ctx, "validation_reply_max"),
		"status.in":         trans.Get(ctx, "validation_status_in"),
	}
}

func (r *GuestbookUpdate) Attributes(ctx http.Context) map[string]string {
	return map[string]string{
		"name":    trans.Get(ctx, "validation_name"),
		"email":   trans.Get(ctx, "validation_email"),
		"content": trans.Get(ctx, "validation_content"),
		"reply":   trans.Get(ctx, "validation_reply"),
		"status":  trans.Get(ctx, "validation_status"),
	}
}

创建回复请求验证:app/http/requests/admin/guestbook_reply.go

package admin

import (
	"goravel/app/http/trans"

	"github.com/goravel/framework/contracts/http"
)

type GuestbookReply struct {
	Reply string `form:"reply" json:"reply"`
}

func (r *GuestbookReply) Authorize(ctx http.Context) error {
	return nil
}

func (r *GuestbookReply) Rules(ctx http.Context) map[string]string {
	return map[string]string{
		"reply": "required|min_len:5|max_len:2000",
	}
}

func (r *GuestbookReply) Messages(ctx http.Context) map[string]string {
	return map[string]string{
		"reply.required": trans.Get(ctx, "validation_reply_required"),
		"reply.min_len":  trans.Get(ctx, "validation_reply_min"),
		"reply.max_len":  trans.Get(ctx, "validation_reply_max"),
	}
}

func (r *GuestbookReply) Attributes(ctx http.Context) map[string]string {
	return map[string]string{
		"reply": trans.Get(ctx, "validation_reply"),
	}
}

5. 创建控制器

创建控制器文件:app/http/controllers/admin/guestbook_controller.go

package admin

import (
	"github.com/goravel/framework/contracts/http"
	"github.com/spf13/cast"

	apperrors "goravel/app/errors"
	adminrequests "goravel/app/http/requests/admin"
	"goravel/app/http/helpers"
	"goravel/app/http/response"
	"goravel/app/services"
)

type GuestbookController struct {
	guestbookService services.GuestbookService
}

func NewGuestbookController() *GuestbookController {
	return &GuestbookController{
		guestbookService: services.NewGuestbookServiceImpl(),
	}
}

// Index 留言列表
// @Summary      获取留言列表
// @Description  分页获取留言列表,支持按姓名、邮箱、状态等条件筛选
// @Tags         留言板管理
// @Accept       json
// @Produce      json
// @Param        page       query     int     false  "页码" default(1)
// @Param        page_size  query     int     false  "每页数量" default(20)
// @Param        name       query     string  false  "姓名(模糊搜索)"
// @Param        email      query     string  false  "邮箱(模糊搜索)"
// @Param        status     query     string  false  "状态:1-已审核,0-待审核"
// @Param        start_time query     string  false  "开始时间(格式:YYYY-MM-DD HH:mm:ss"
// @Param        end_time   query     string  false  "结束时间(格式:YYYY-MM-DD HH:mm:ss"
// @Param        order_by   query     string  false  "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success      200        {object}  map[string]any
// @Failure      400        {object}  map[string]any "参数错误"
// @Failure      401        {object}  map[string]any "未登录"
// @Failure      403        {object}  map[string]any "无权限"
// @Failure      500        {object}  map[string]any "服务器错误"
// @Router       /api/admin/guestbooks [get]
// @Security     BearerAuth
func (r *GuestbookController) Index(ctx http.Context) http.Response {
	page := cast.ToInt(ctx.Request().Query("page", "1"))
	pageSize := cast.ToInt(ctx.Request().Query("page_size", "20"))

	startTimeStr := ctx.Request().Query("start_time", "")
	endTimeStr := ctx.Request().Query("end_time", "")
	startTime := ""
	endTime := ""
	if startTimeStr != "" {
		startTime = helpers.ConvertTimeToUTC(ctx, startTimeStr)
	}
	if endTimeStr != "" {
		endTime = helpers.ConvertTimeToUTC(ctx, endTimeStr)
	}

	filters := services.GuestbookFilters{
		Name:      ctx.Request().Query("name", ""),
		Email:     ctx.Request().Query("email", ""),
		Status:    ctx.Request().Query("status", ""),
		StartTime: startTime,
		EndTime:   endTime,
		OrderBy:   ctx.Request().Query("order_by", ""),
	}

	guestbooks, total, err := r.guestbookService.GetList(filters, page, pageSize)
	if err != nil {
		return response.Error(ctx, http.StatusInternalServerError, err.Error())
	}

	return response.Success(ctx, http.Json{
		"list":      guestbooks,
		"total":     total,
		"page":      page,
		"page_size": pageSize,
	})
}

// Show 留言详情
// @Summary      获取留言详情
// @Description  根据ID获取留言详细信息
// @Tags         留言板管理
// @Accept       json
// @Produce      json
// @Param        id   path     int  true  "留言ID"
// @Success      200  {object} map[string]any
// @Failure      404  {object} map[string]any "留言不存在"
// @Router       /api/admin/guestbooks/{id} [get]
// @Security     BearerAuth
func (r *GuestbookController) Show(ctx http.Context) http.Response {
	id := cast.ToUint(ctx.Request().Route("id"))
	guestbook, err := r.guestbookService.GetByID(id)
	if err != nil {
		return response.Error(ctx, http.StatusNotFound, apperrors.ErrNotFound.Code)
	}

	return response.Success(ctx, http.Json{
		"guestbook": guestbook,
	})
}

// Store 创建留言
// @Summary      创建留言
// @Description  创建新的留言
// @Tags         留言板管理
// @Accept       json
// @Produce      json
// @Param        name     body     string  true  "留言人姓名"
// @Param        email    body     string  true  "留言人邮箱"
// @Param        content  body     string  true  "留言内容"
// @Param        status   body     int     false "状态:1-已审核,0-待审核" default(0)
// @Success      200      {object} map[string]any
// @Failure      400      {object} map[string]any "参数错误"
// @Router       /api/admin/guestbooks [post]
// @Security     BearerAuth
func (r *GuestbookController) Store(ctx http.Context) http.Response {
	var req adminrequests.GuestbookCreate
	errors, err := ctx.Request().ValidateRequest(&req)
	if err != nil {
		return response.Error(ctx, http.StatusBadRequest, err.Error())
	}
	if errors != nil {
		return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
	}

	// 获取客户端IP和User-Agent
	ip := ctx.Request().Ip()
	userAgent := ctx.Request().Header("User-Agent", "")

	data := map[string]any{
		"name":       req.Name,
		"email":      req.Email,
		"content":    req.Content,
		"status":     req.Status,
		"ip":         ip,
		"user_agent": userAgent,
	}

	guestbook, err := r.guestbookService.Create(data)
	if err != nil {
		return response.Error(ctx, http.StatusInternalServerError, err.Error())
	}

	return response.Success(ctx, http.Json{
		"guestbook": guestbook,
	})
}

// Update 更新留言
// @Summary      更新留言
// @Description  更新留言信息,包括回复内容
// @Tags         留言板管理
// @Accept       json
// @Produce      json
// @Param        id       path     int     true  "留言ID"
// @Param        name     body     string  false "留言人姓名"
// @Param        email    body     string  false "留言人邮箱"
// @Param        content  body     string  false "留言内容"
// @Param        reply    body     string  false "回复内容"
// @Param        status   body     int     false "状态:1-已审核,0-待审核"
// @Success      200      {object} map[string]any
// @Failure      404      {object} map[string]any "留言不存在"
// @Router       /api/admin/guestbooks/{id} [put]
// @Security     BearerAuth
func (r *GuestbookController) Update(ctx http.Context) http.Response {
	id := cast.ToUint(ctx.Request().Route("id"))

	var req adminrequests.GuestbookUpdate
	errors, err := ctx.Request().ValidateRequest(&req)
	if err != nil {
		return response.Error(ctx, http.StatusBadRequest, err.Error())
	}
	if errors != nil {
		return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
	}

	data := map[string]any{}
	if req.Name != "" {
		data["name"] = req.Name
	}
	if req.Email != "" {
		data["email"] = req.Email
	}
	if req.Content != "" {
		data["content"] = req.Content
	}
	if req.Reply != "" {
		data["reply"] = req.Reply
	}
	if ctx.Request().Input("status") != "" {
		data["status"] = req.Status
	}

	if err := r.guestbookService.Update(id, data); err != nil {
		return response.Error(ctx, http.StatusInternalServerError, err.Error())
	}

	guestbook, _ := r.guestbookService.GetByID(id)
	return response.Success(ctx, http.Json{
		"guestbook": guestbook,
	})
}

// Destroy 删除留言
// @Summary      删除留言
// @Description  删除指定的留言
// @Tags         留言板管理
// @Accept       json
// @Produce      json
// @Param        id   path     int  true  "留言ID"
// @Success      200  {object} map[string]any
// @Failure      404  {object} map[string]any "留言不存在"
// @Router       /api/admin/guestbooks/{id} [delete]
// @Security     BearerAuth
func (r *GuestbookController) Destroy(ctx http.Context) http.Response {
	id := cast.ToUint(ctx.Request().Route("id"))

	if err := r.guestbookService.Delete(id); err != nil {
		return response.Error(ctx, http.StatusInternalServerError, err.Error())
	}

	return response.Success(ctx)
}

// Reply 回复留言
// @Summary      回复留言
// @Description  对留言进行回复,回复后自动审核通过
// @Tags         留言板管理
// @Accept       json
// @Produce      json
// @Param        id     path     string  true  "留言ID"
// @Param        reply  body     string  true  "回复内容"
// @Success      200    {object} map[string]any
// @Failure      404    {object} map[string]any "留言不存在"
// @Router       /api/admin/guestbooks/{id}/reply [post]
// @Security     BearerAuth
func (r *GuestbookController) Reply(ctx http.Context) http.Response {
	id := cast.ToUint(ctx.Request().Route("id"))

	var req adminrequests.GuestbookReply
	errors, err := ctx.Request().ValidateRequest(&req)
	if err != nil {
		return response.Error(ctx, http.StatusBadRequest, err.Error())
	}
	if errors != nil {
		return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
	}

	if err := r.guestbookService.Reply(id, req.Reply); err != nil {
		return response.Error(ctx, http.StatusInternalServerError, err.Error())
	}

	guestbook, _ := r.guestbookService.GetByID(id)
	return response.Success(ctx, http.Json{
		"guestbook": guestbook,
	})
}

6. 注册路由

routes/admin.go 文件中添加路由:

// 在 Admin() 函数中添加控制器实例
guestbookController := admin.NewGuestbookController()

// 在需要认证、权限验证的路由组中添加(约第73行)
router.Resource("guestbooks", guestbookController)
router.Post("guestbooks/{id}/reply", guestbookController.Reply) // 回复留言接口

7. 运行迁移

# 运行迁移
go run . migrate

# 或者使用 artisan
./artisan migrate

前端开发

1. 创建 API 客户端

创建 API 文件:html/src/api/guestbook.js

import request from '../utils/request'
import { createCRUDApi, extendApi } from '../utils/apiFactory'

// 创建基础 CRUD API
const baseGuestbookApi = createCRUDApi('guestbooks')

// 扩展 API,添加自定义方法
const guestbookApi = extendApi(baseGuestbookApi, {
  // 回复留言
  reply: (id, data) => {
    return request({
      url: `/guestbooks/${id}/reply`,
      method: 'post',
      data
    })
  }
})

// 导出所有方法
export const {
  list: getGuestbookList,
  detail: getGuestbookDetail,
  create: createGuestbook,
  update: updateGuestbook,
  delete: deleteGuestbook,
  reply: replyGuestbook
} = guestbookApi

2. 创建列表页面

创建列表页面:html/src/views/guestbook/GuestbookList.vue

<template>
  <div class="list-page">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>{{ $t('menu.guestbook') }}</span>
          <el-button 
            type="primary" 
            :disabled="getButtonState('guestbook.store').disabled"
            @click="handleAdd"
          >
            <el-icon><PlusIcon /></el-icon>
            {{ $t('guestbook.add_guestbook') }}
          </el-button>
        </div>
      </template>

      <!-- 搜索表单 -->
      <SearchForm
        :model="searchForm"
        :fields="searchFields"
        :initial-values="initialSearchForm"
        i18n-prefix="guestbook"
        @search="handleSearch"
        @reset="handleReset"
      />

      <!-- 数据表格 -->
      <VxeTable
        ref="tableRef"
        :data="tableData"
        :loading="loading"
        :columns="tableColumns"
        :height="600"
        @sort-change="handleSortChange"
      >
        <template #status="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'warning'">
            {{ row.status === 1 ? $t('guestbook.status_approved') : $t('guestbook.status_pending') }}
          </el-tag>
        </template>

        <template #content="{ row }">
          <div class="content-preview">
            {{ row.content && row.content.length > 50 ? row.content.substring(0, 50) + '...' : row.content }}
          </div>
        </template>

        <template #reply="{ row }">
          <div v-if="row.reply" class="reply-preview">
            {{ row.reply.length > 50 ? row.reply.substring(0, 50) + '...' : row.reply }}
          </div>
          <span v-else class="text-gray-400">-</span>
        </template>

        <template #operation="{ row }">
          <TableActionButtons
            :row="row"
            :primary-actions="getPrimaryActions(row)"
            :more-actions="getMoreActions(row)"
            :get-button-state="getButtonState"
            @action="handleAction"
          />
        </template>
      </VxeTable>

      <!-- 分页 -->
      <Pagination
        v-model="pagination"
        :auto-load="true"
        :on-page-change="loadData"
      />
    </el-card>

    <!-- 添加/编辑对话框 -->
    <GuestbookForm
      ref="guestbookFormRef"
      v-model="dialogVisible"
      :edit-id="editId"
      @success="handleFormSuccess"
    />
  </div>
</template>

<script setup>
import { ref, reactive, onMounted, computed, markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import SearchForm from '../../components/SearchForm.vue'
import Pagination from '../../components/Pagination.vue'
import VxeTable from '../../components/VxeTable.vue'
import TableActionButtons from '../../components/TableActionButtons.vue'
import GuestbookForm from './GuestbookForm.vue'
import { useListPage } from '../../composables/useListPage'
import { usePermission } from '../../composables/usePermission'
import { useCrud } from '../../composables/useCrud'
import {
  getGuestbookList,
  deleteGuestbook,
  updateGuestbook,
  replyGuestbook
} from '../../api/guestbook'
import logger from '../../utils/logger'
import ErrorHandler from '../../utils/errorHandler'

const PlusIcon = markRaw(Plus)

// 权限控制
const { getButtonState } = usePermission()

const { t } = useI18n()
const tableRef = ref(null)
const guestbookFormRef = ref(null)

// 使用组合式函数
const {
  dialogVisible,
  editId,
  handleAdd,
  handleEdit,
  handleDelete,
  handleFormSuccess
} = useCrud(guestbookFormRef)

const {
  loading,
  tableData,
  pagination,
  searchForm,
  initialSearchForm,
  searchFields,
  tableColumns,
  loadData,
  handleSearch,
  handleReset,
  handleSortChange
} = useListPage({
  api: getGuestbookList,
  searchFields: [
    {
      key: 'name',
      label: 'guestbook.name',
      type: 'input',
      placeholder: 'guestbook.name_placeholder'
    },
    {
      key: 'email',
      label: 'guestbook.email',
      type: 'input',
      placeholder: 'guestbook.email_placeholder'
    },
    {
      key: 'status',
      label: 'guestbook.status',
      type: 'select',
      options: [
        { label: 'guestbook.status_all', value: '' },
        { label: 'guestbook.status_approved', value: '1' },
        { label: 'guestbook.status_pending', value: '0' }
      ]
    },
    {
      key: 'start_time',
      label: 'common.start_time',
      type: 'datetime'
    },
    {
      key: 'end_time',
      label: 'common.end_time',
      type: 'datetime'
    }
  ],
  tableColumns: [
    { field: 'id', title: 'ID', width: 80 },
    { field: 'name', title: 'guestbook.name', width: 120 },
    { field: 'email', title: 'guestbook.email', width: 180 },
    { 
      field: 'content', 
      title: 'guestbook.content', 
      width: 300,
      slots: { default: 'content' }
    },
    { 
      field: 'reply', 
      title: 'guestbook.reply', 
      width: 200,
      slots: { default: 'reply' }
    },
    { 
      field: 'status', 
      title: 'guestbook.status', 
      width: 100,
      slots: { default: 'status' }
    },
    { field: 'ip', title: 'guestbook.ip', width: 130 },
    { field: 'created_at', title: 'common.created_at', width: 180 },
    { 
      field: 'operation', 
      title: 'common.operation', 
      width: 200,
      fixed: 'right',
      slots: { default: 'operation' }
    }
  ]
})

// 主要操作按钮
const getPrimaryActions = (row) => {
  return [
    {
      label: t('common.edit'),
      action: 'edit',
      permission: 'guestbook.update',
      icon: 'Edit'
    },
    {
      label: t('common.delete'),
      action: 'delete',
      permission: 'guestbook.destroy',
      icon: 'Delete',
      type: 'danger'
    }
  ]
}

// 更多操作按钮
const getMoreActions = (row) => {
  return [
    {
      label: t('guestbook.reply'),
      action: 'reply',
      permission: 'guestbook.reply',
      icon: 'ChatLineRound',
      show: !row.reply // 已有回复则不显示
    }
  ]
}

// 处理操作
const handleAction = async (action, row) => {
  switch (action) {
    case 'edit':
      handleEdit(row.id)
      break
    case 'delete':
      await handleDelete(row.id, deleteGuestbook, t('guestbook.guestbook'))
      break
    case 'reply':
      handleReply(row)
      break
  }
}

// 处理回复
const handleReply = async (row) => {
  try {
    const { value } = await ElMessageBox.prompt(
      t('guestbook.reply_content'),
      t('guestbook.reply'),
      {
        confirmButtonText: t('common.confirm'),
        cancelButtonText: t('common.cancel'),
        inputType: 'textarea',
        inputPlaceholder: t('guestbook.reply_placeholder'),
        inputValidator: (value) => {
          if (!value || value.trim().length < 5) {
            return t('validation_reply_min')
          }
          if (value.length > 2000) {
            return t('validation_reply_max')
          }
          return true
        }
      }
    )

    await replyGuestbook(row.id, { reply: value })
    ElMessage.success(t('guestbook.reply_success'))
    loadData()
  } catch (error) {
    if (error !== 'cancel') {
      ErrorHandler.handle(error)
    }
  }
}

onMounted(() => {
  loadData()
})
</script>

<style scoped>
.content-preview,
.reply-preview {
  word-break: break-word;
  line-height: 1.5;
}
</style>

3. 创建表单组件

创建表单组件:html/src/views/guestbook/GuestbookForm.vue

<template>
  <el-dialog
    v-model="dialogVisible"
    :title="editId ? $t('guestbook.edit_guestbook') : $t('guestbook.add_guestbook')"
    width="800px"
    @close="handleClose"
  >
    <el-form
      ref="formRef"
      :model="formData"
      :rules="formRules"
      label-width="100px"
    >
      <el-form-item :label="$t('guestbook.name')" prop="name">
        <el-input
          v-model="formData.name"
          :placeholder="$t('guestbook.name_placeholder')"
          maxlength="50"
          show-word-limit
        />
      </el-form-item>

      <el-form-item :label="$t('guestbook.email')" prop="email">
        <el-input
          v-model="formData.email"
          :placeholder="$t('guestbook.email_placeholder')"
          maxlength="100"
        />
      </el-form-item>

      <el-form-item :label="$t('guestbook.content')" prop="content">
        <el-input
          v-model="formData.content"
          type="textarea"
          :rows="6"
          :placeholder="$t('guestbook.content_placeholder')"
          maxlength="2000"
          show-word-limit
        />
      </el-form-item>

      <el-form-item v-if="editId" :label="$t('guestbook.reply')" prop="reply">
        <el-input
          v-model="formData.reply"
          type="textarea"
          :rows="4"
          :placeholder="$t('guestbook.reply_placeholder')"
          maxlength="2000"
          show-word-limit
        />
      </el-form-item>

      <el-form-item :label="$t('guestbook.status')" prop="status">
        <el-radio-group v-model="formData.status">
          <el-radio :label="1">{{ $t('guestbook.status_approved') }}</el-radio>
          <el-radio :label="0">{{ $t('guestbook.status_pending') }}</el-radio>
        </el-radio-group>
      </el-form-item>
    </el-form>

    <template #footer>
      <el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
      <el-button type="primary" :loading="submitting" @click="handleSubmit">
        {{ $t('common.confirm') }}
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { getGuestbookDetail, createGuestbook, updateGuestbook } from '../../api/guestbook'
import ErrorHandler from '../../utils/errorHandler'

const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  editId: {
    type: [Number, String],
    default: null
  }
})

const emit = defineEmits(['update:modelValue', 'success'])

const { t } = useI18n()
const formRef = ref(null)
const submitting = ref(false)

const dialogVisible = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})

const formData = reactive({
  name: '',
  email: '',
  content: '',
  reply: '',
  status: 0
})

const formRules = {
  name: [
    { required: true, message: t('validation_name_required'), trigger: 'blur' },
    { max: 50, message: t('validation_name_max'), trigger: 'blur' }
  ],
  email: [
    { required: true, message: t('validation_email_required'), trigger: 'blur' },
    { type: 'email', message: t('validation_email_format'), trigger: 'blur' },
    { max: 100, message: t('validation_email_max'), trigger: 'blur' }
  ],
  content: [
    { required: true, message: t('validation_content_required'), trigger: 'blur' },
    { min: 5, message: t('validation_content_min'), trigger: 'blur' },
    { max: 2000, message: t('validation_content_max'), trigger: 'blur' }
  ],
  reply: [
    { max: 2000, message: t('validation_reply_max'), trigger: 'blur' }
  ]
}

// 加载详情数据
const loadDetail = async () => {
  if (!props.editId) {
    resetForm()
    return
  }

  try {
    const res = await getGuestbookDetail(props.editId)
    if (res.data && res.data.guestbook) {
      Object.assign(formData, {
        name: res.data.guestbook.name || '',
        email: res.data.guestbook.email || '',
        content: res.data.guestbook.content || '',
        reply: res.data.guestbook.reply || '',
        status: res.data.guestbook.status ?? 0
      })
    }
  } catch (error) {
    ErrorHandler.handle(error)
  }
}

// 重置表单
const resetForm = () => {
  Object.assign(formData, {
    name: '',
    email: '',
    content: '',
    reply: '',
    status: 0
  })
  formRef.value?.clearValidate()
}

// 提交表单
const handleSubmit = async () => {
  if (!formRef.value) return

  await formRef.value.validate(async (valid) => {
    if (!valid) return

    submitting.value = true
    try {
      const data = { ...formData }
      if (!props.editId) {
        // 创建时不需要回复字段
        delete data.reply
        await createGuestbook(data)
        ElMessage.success(t('guestbook.create_success'))
      } else {
        await updateGuestbook(props.editId, data)
        ElMessage.success(t('guestbook.update_success'))
      }
      emit('success')
      handleClose()
    } catch (error) {
      ErrorHandler.handle(error)
    } finally {
      submitting.value = false
    }
  })
}

// 关闭对话框
const handleClose = () => {
  dialogVisible.value = false
  resetForm()
}

// 监听 editId 变化
watch(() => props.editId, () => {
  if (dialogVisible.value) {
    loadDetail()
  }
})

// 监听对话框显示
watch(dialogVisible, (val) => {
  if (val) {
    loadDetail()
  }
})
</script>

4. 注册路由

html/src/router/index.js 中添加路由:

{
  path: '/guestbook',
  name: 'GuestbookList',
  component: () => import('../views/guestbook/GuestbookList.vue'),
  meta: {
    title: 'menu.guestbook',
    permission: 'guestbook.index'
  }
}

5. 添加国际化文本

lang/cn.jsonlang/en.json 中添加相关翻译:

cn.json:

{
  "menu": {
    "guestbook": "留言板"
  },
  "guestbook": {
    "add_guestbook": "添加留言",
    "edit_guestbook": "编辑留言",
    "name": "姓名",
    "name_placeholder": "请输入姓名",
    "email": "邮箱",
    "email_placeholder": "请输入邮箱",
    "content": "留言内容",
    "content_placeholder": "请输入留言内容(5-2000字符)",
    "reply": "回复",
    "reply_placeholder": "请输入回复内容(5-2000字符)",
    "status": "状态",
    "status_all": "全部",
    "status_approved": "已审核",
    "status_pending": "待审核",
    "ip": "IP地址",
    "create_success": "留言创建成功",
    "update_success": "留言更新成功",
    "reply_success": "回复成功"
  }
}

测试验证

后端接口测试

  1. 测试列表接口
curl -X GET "http://localhost:3000/api/admin/guestbooks?page=1&page_size=20" \
  -H "Authorization: Bearer YOUR_TOKEN"
  1. 测试创建接口
curl -X POST "http://localhost:3000/api/admin/guestbooks" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "张三",
    "email": "zhangsan@example.com",
    "content": "这是一条测试留言",
    "status": 0
  }'
  1. 测试回复接口
curl -X POST "http://localhost:3000/api/admin/guestbooks/1/reply" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reply": "感谢您的留言,我们会尽快处理。"
  }'

前端功能测试

  1. 访问留言列表页面,验证数据加载
  2. 测试搜索和筛选功能
  3. 测试添加留言功能
  4. 测试编辑留言功能
  5. 测试删除留言功能
  6. 测试回复留言功能

总结

通过以上步骤,我们完成了一个完整的留言板模块开发,包括:

后端部分

  • 数据库迁移文件
  • 模型定义
  • 服务层业务逻辑
  • 请求验证
  • 控制器处理
  • 路由注册

前端部分

  • API 客户端封装
  • 列表页面(搜索、分页、操作)
  • 表单组件(创建/编辑)
  • 路由配置
  • 国际化文本

开发要点

  1. 遵循项目规范:代码风格、目录结构、命名规范
  2. 统一响应格式:使用 response.Success()response.Error()
  3. 权限控制:使用中间件和前端权限检查
  4. 错误处理:统一的错误处理和提示
  5. 代码复用:使用组合式函数和工具函数
  6. 用户体验:加载状态、表单验证、操作确认

按照这个文档的步骤,你可以快速开发其他类似的 CRUD 模块。