1370 lines
36 KiB
Markdown
1370 lines
36 KiB
Markdown
# 开发指南:留言板模块 CRUD 完整示例
|
||
|
||
本文档以**留言板模块**为例,详细说明如何开发一个完整的增删改查功能,包括后端接口和前端页面。
|
||
|
||
## 目录
|
||
|
||
- [概述](#概述)
|
||
- [后端开发](#后端开发)
|
||
- [1. 数据库迁移](#1-数据库迁移)
|
||
- [2. 创建模型](#2-创建模型)
|
||
- [3. 创建服务层](#3-创建服务层)
|
||
- [4. 创建请求验证](#4-创建请求验证)
|
||
- [5. 创建控制器](#5-创建控制器)
|
||
- [6. 注册路由](#6-注册路由)
|
||
- [7. 运行迁移](#7-运行迁移)
|
||
- [前端开发](#前端开发)
|
||
- [1. 创建 API 客户端](#1-创建-api-客户端)
|
||
- [2. 创建列表页面](#2-创建列表页面)
|
||
- [3. 创建表单组件](#3-创建表单组件)
|
||
- [4. 注册路由](#4-注册路由)
|
||
- [测试验证](#测试验证)
|
||
- [总结](#总结)
|
||
|
||
---
|
||
|
||
## 概述
|
||
|
||
留言板模块功能需求:
|
||
- 留言列表(支持分页、搜索、筛选)
|
||
- 创建留言
|
||
- 编辑留言
|
||
- 删除留言
|
||
- 回复留言(可选)
|
||
|
||
数据库表结构:
|
||
- `guestbooks` 表:留言主表
|
||
- 字段:id, name, email, content, reply, status, ip, user_agent, created_at, updated_at, deleted_at
|
||
|
||
---
|
||
|
||
## 后端开发
|
||
|
||
### 1. 数据库迁移
|
||
|
||
创建迁移文件:`database/migrations/20250101000029_create_guestbooks_table.go`
|
||
|
||
```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`
|
||
|
||
```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`
|
||
|
||
```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`
|
||
|
||
```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`
|
||
|
||
```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`
|
||
|
||
```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`
|
||
|
||
```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` 文件中添加路由:
|
||
|
||
```go
|
||
// 在 Admin() 函数中添加控制器实例
|
||
guestbookController := admin.NewGuestbookController()
|
||
|
||
// 在需要认证、权限验证的路由组中添加(约第73行)
|
||
router.Resource("guestbooks", guestbookController)
|
||
router.Post("guestbooks/{id}/reply", guestbookController.Reply) // 回复留言接口
|
||
```
|
||
|
||
### 7. 运行迁移
|
||
|
||
```bash
|
||
# 运行迁移
|
||
go run . migrate
|
||
|
||
# 或者使用 artisan
|
||
./artisan migrate
|
||
```
|
||
|
||
---
|
||
|
||
## 前端开发
|
||
|
||
### 1. 创建 API 客户端
|
||
|
||
创建 API 文件:`html/src/api/guestbook.js`
|
||
|
||
```javascript
|
||
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`
|
||
|
||
```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`
|
||
|
||
```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` 中添加路由:
|
||
|
||
```javascript
|
||
{
|
||
path: '/guestbook',
|
||
name: 'GuestbookList',
|
||
component: () => import('../views/guestbook/GuestbookList.vue'),
|
||
meta: {
|
||
title: 'menu.guestbook',
|
||
permission: 'guestbook.index'
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. 添加国际化文本
|
||
|
||
在 `lang/cn.json` 和 `lang/en.json` 中添加相关翻译:
|
||
|
||
**cn.json:**
|
||
```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. **测试列表接口**
|
||
```bash
|
||
curl -X GET "http://localhost:3000/api/admin/guestbooks?page=1&page_size=20" \
|
||
-H "Authorization: Bearer YOUR_TOKEN"
|
||
```
|
||
|
||
2. **测试创建接口**
|
||
```bash
|
||
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
|
||
}'
|
||
```
|
||
|
||
3. **测试回复接口**
|
||
```bash
|
||
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 模块。
|