From d3a8db99998e5e3d8fbcafa7a8f3cf0915063bd9 Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期四, 26 三月 2026 20:47:09 +0800
Subject: [PATCH] feat:系统与安全-系统设置-用户意见反馈

---
 backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackQueryMapper.java     |   13 +
 backend/src/main/java/com/tcm/training/admin/system/service/UserFeedbackAppService.java     |   89 ++++++
 backend/sql/init.sql                                                                        |   27 ++
 tcm-training-admin/src/pages/system/settings-redirect/index.tsx                             |    4 
 tcm-training-admin/src/access.js                                                            |    1 
 backend/sql/update_user_feedback_schema.sql                                                 |   24 +
 tcm-training-admin/config/routes.ts                                                         |    6 
 backend/src/main/java/com/tcm/training/admin/system/controller/UserFeedbackController.java  |   43 +++
 tcm-training-admin/src/pages/system/user-feedback/service.ts                                |    8 
 backend/src/main/resources/mapper/UserFeedbackQueryMapper.xml                               |   24 +
 backend/src/main/java/com/tcm/training/admin/system/dto/UserFeedbackSubmitRequest.java      |   18 +
 backend/src/main/java/com/tcm/training/admin/system/entity/UserFeedback.java                |   47 +++
 backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackMapper.java          |    9 
 backend/src/main/java/com/tcm/training/admin/system/vo/UserFeedbackListItemVO.java          |   21 +
 backend/src/test/java/com/tcm/training/admin/system/service/UserFeedbackAppServiceTest.java |  133 ++++++++++
 tcm-training-admin/src/pages/system/user-feedback/index.tsx                                 |  154 +++++++++++
 tcm-training-admin/src/pages/system/user-feedback/style.less                                |  116 ++++++++
 backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java                 |    2 
 18 files changed, 738 insertions(+), 1 deletions(-)

diff --git a/backend/sql/init.sql b/backend/sql/init.sql
index 7132062..9cc8bda 100644
--- a/backend/sql/init.sql
+++ b/backend/sql/init.sql
@@ -130,6 +130,7 @@
 (1333, 1332, '新增常见问题', 'system_common_question_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:common-question:add', '常见问题新增按钮', 0),
 (1334, 1332, '编辑常见问题', 'system_common_question_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:common-question:edit', '常见问题编辑按钮', 0),
 (1335, 1332, '删除常见问题', 'system_common_question_delete', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:common-question:delete', '常见问题删除按钮', 0),
+(1336, 1300, '用户意见反馈', 'system_user_feedback', 'C', '/system/settings/user-feedback', 'System/UserFeedback', NULL, 18, 1, 1, 'system:user-feedback:view', '用户意见反馈菜单', 0),
 (1310, 1300, '积分设置', 'system_points_settings', 'C', '/system/settings/points', 'System/PointsSettings', NULL, 10, 1, 1, 'system:points-settings:view', '积分设置菜单', 0),
 (1311, 1310, '编辑积分设置', 'system_points_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:points-settings:edit', '积分设置编辑按钮', 0),
 (2000, 0, '用户与会员', 'member_root', 'M', '/member', NULL, 'team', 200, 1, 1, NULL, '用户与会员模块', 0),
@@ -215,7 +216,7 @@
 INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
 VALUES
 (1, 1000), (1, 1100), (1, 1101), (1, 1102), (1, 1103), (1, 1104), (1, 1105),
-(1, 1200), (1, 1201), (1, 1202), (1, 1203), (1, 1204), (1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309), (1, 1310), (1, 1311), (1, 1312), (1, 1313), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320), (1, 1321), (1, 1322), (1, 1323), (1, 1324), (1, 1325), (1, 1326), (1, 1327), (1, 1328), (1, 1329), (1, 1330), (1, 1331), (1, 1332), (1, 1333), (1, 1334), (1, 1335),
+(1, 1200), (1, 1201), (1, 1202), (1, 1203), (1, 1204), (1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309), (1, 1310), (1, 1311), (1, 1312), (1, 1313), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320), (1, 1321), (1, 1322), (1, 1323), (1, 1324), (1, 1325), (1, 1326), (1, 1327), (1, 1328), (1, 1329), (1, 1330), (1, 1331), (1, 1332), (1, 1333), (1, 1334), (1, 1335), (1, 1336),
 (1, 2000), (1, 2100), (1, 2110), (1, 2111), (1, 2112), (1, 2113), (1, 2114), (1, 2115), (1, 2116), (1, 2117), (1, 2118), (1, 2119),
 (1, 2200), (1, 2210), (1, 2211), (1, 2220), (1, 2221),
 (1, 3000), (1, 3050), (1, 3060), (1, 3061), (1, 3062), (1, 3063),
@@ -983,6 +984,30 @@
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='常见问题表';
 
 -- ----------------------------
+-- 用户意见反馈
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_user_feedback` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  `app_user_id` BIGINT UNSIGNED NOT NULL COMMENT '反馈用户ID',
+  `content` TEXT NOT NULL COMMENT '问题和意见',
+  `contact` VARCHAR(64) DEFAULT NULL COMMENT '联系方式:手机号或邮箱',
+  `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+  `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
+  `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_feedback_app_user_id` (`app_user_id`),
+  KEY `idx_user_feedback_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈表';
+
+INSERT IGNORE INTO `tcm_user_feedback`
+(`id`, `app_user_id`, `content`, `contact`, `deleted`)
+VALUES
+(1, 1, '收不到验证码,已经重试很多次了,请帮忙排查。', '13800138000', 0),
+(2, 2, '课程播放时偶尔会卡住,希望增加清晰度切换。', 'user2@example.com', 0);
+
+-- ----------------------------
 -- 积分设置
 -- ----------------------------
 CREATE TABLE IF NOT EXISTS `tcm_points_config` (
diff --git a/backend/sql/update_user_feedback_schema.sql b/backend/sql/update_user_feedback_schema.sql
new file mode 100644
index 0000000..897ce1c
--- /dev/null
+++ b/backend/sql/update_user_feedback_schema.sql
@@ -0,0 +1,24 @@
+CREATE TABLE IF NOT EXISTS `tcm_user_feedback` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  `app_user_id` BIGINT UNSIGNED NOT NULL COMMENT '反馈用户ID',
+  `content` TEXT NOT NULL COMMENT '问题和意见',
+  `contact` VARCHAR(64) DEFAULT NULL COMMENT '联系方式:手机号或邮箱',
+  `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+  `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
+  `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_feedback_app_user_id` (`app_user_id`),
+  KEY `idx_user_feedback_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈表';
+
+INSERT IGNORE INTO `sys_menu`
+(`id`, `parent_id`, `name`, `menu_code`, `type`, `path`, `component`, `icon`, `sort`, `status`, `visible`, `permission_key`, `remark`, `deleted`)
+VALUES
+(1300, 1000, '系统设置', 'system_setting_group', 'M', '/system/settings', NULL, NULL, 30, 1, 1, NULL, '系统设置目录', 0),
+(1336, 1300, '用户意见反馈', 'system_user_feedback', 'C', '/system/settings/user-feedback', 'System/UserFeedback', NULL, 18, 1, 1, 'system:user-feedback:view', '用户意见反馈菜单', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1336);
diff --git a/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java b/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
index d4fa5d7..7644e7f 100644
--- a/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
+++ b/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -177,6 +177,8 @@
                 null, null, null, 2, "system:common-question:edit", "常见问题编辑按钮"));
         menuIds.add(saveMenu(1335L, 1332L, "删除常见问题", "system_common_question_delete", "B",
                 null, null, null, 3, "system:common-question:delete", "常见问题删除按钮"));
+        menuIds.add(saveMenu(1336L, 1300L, "用户意见反馈", "system_user_feedback", "C",
+                "/system/settings/user-feedback", "System/UserFeedback", null, 18, "system:user-feedback:view", "用户意见反馈菜单"));
         menuIds.add(saveMenu(1310L, 1300L, "积分设置", "system_points_settings", "C",
                 "/system/settings/points", "System/PointsSettings", null, 10, "system:points-settings:view", "积分设置菜单"));
         menuIds.add(saveMenu(1311L, 1310L, "编辑积分设置", "system_points_settings_edit", "B",
diff --git a/backend/src/main/java/com/tcm/training/admin/system/controller/UserFeedbackController.java b/backend/src/main/java/com/tcm/training/admin/system/controller/UserFeedbackController.java
new file mode 100644
index 0000000..e519d18
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/controller/UserFeedbackController.java
@@ -0,0 +1,43 @@
+package com.tcm.training.admin.system.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.common.PageResponse;
+import com.tcm.training.admin.system.dto.UserFeedbackSubmitRequest;
+import com.tcm.training.admin.system.service.UserFeedbackAppService;
+import com.tcm.training.admin.system.vo.UserFeedbackListItemVO;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class UserFeedbackController {
+
+    private final UserFeedbackAppService userFeedbackAppService;
+
+    public UserFeedbackController(UserFeedbackAppService userFeedbackAppService) {
+        this.userFeedbackAppService = userFeedbackAppService;
+    }
+
+    @GetMapping("/user-feedbacks")
+    public ApiResponse<PageResponse<UserFeedbackListItemVO>> page(
+            @RequestParam(required = false, defaultValue = "1") int page,
+            @RequestParam(required = false, defaultValue = "10") int pageSize,
+            @RequestParam(required = false) String content) {
+        return ApiResponse.ok(userFeedbackAppService.page(page, pageSize, content));
+    }
+
+    @PostMapping("/user-feedbacks")
+    public ApiResponse<Void> submit(@Valid @RequestBody UserFeedbackSubmitRequest request) {
+        userFeedbackAppService.submit(StpUtil.getLoginIdAsLong(), request);
+        return ApiResponse.ok(null);
+    }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/UserFeedbackSubmitRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/UserFeedbackSubmitRequest.java
new file mode 100644
index 0000000..589ee31
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/UserFeedbackSubmitRequest.java
@@ -0,0 +1,18 @@
+package com.tcm.training.admin.system.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserFeedbackSubmitRequest {
+
+    @NotBlank(message = "请输入问题和意见")
+    private String content;
+
+    private String contact;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/entity/UserFeedback.java b/backend/src/main/java/com/tcm/training/admin/system/entity/UserFeedback.java
new file mode 100644
index 0000000..c206d7f
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/entity/UserFeedback.java
@@ -0,0 +1,47 @@
+package com.tcm.training.admin.system.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@TableName("tcm_user_feedback")
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserFeedback {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @TableField("app_user_id")
+    private Long appUserId;
+
+    @TableField("content")
+    private String content;
+
+    @TableField("contact")
+    private String contact;
+
+    @TableLogic
+    private Integer deleted;
+
+    @TableField(value = "created_by", fill = FieldFill.INSERT)
+    private String createdBy;
+
+    @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
+    private String updatedBy;
+
+    @TableField("created_at")
+    private LocalDateTime createdAt;
+
+    @TableField("updated_at")
+    private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackMapper.java
new file mode 100644
index 0000000..7745314
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackMapper.java
@@ -0,0 +1,9 @@
+package com.tcm.training.admin.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tcm.training.admin.system.entity.UserFeedback;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface UserFeedbackMapper extends BaseMapper<UserFeedback> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackQueryMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackQueryMapper.java
new file mode 100644
index 0000000..e16714e
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackQueryMapper.java
@@ -0,0 +1,13 @@
+package com.tcm.training.admin.system.mapper;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.tcm.training.admin.system.vo.UserFeedbackListItemVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface UserFeedbackQueryMapper {
+
+    IPage<UserFeedbackListItemVO> selectUserFeedbackPage(IPage<UserFeedbackListItemVO> page,
+                                                         @Param("content") String content);
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/service/UserFeedbackAppService.java b/backend/src/main/java/com/tcm/training/admin/system/service/UserFeedbackAppService.java
new file mode 100644
index 0000000..00e034d
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/service/UserFeedbackAppService.java
@@ -0,0 +1,89 @@
+package com.tcm.training.admin.system.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.tcm.training.admin.common.PageResponse;
+import com.tcm.training.admin.member.entity.AppUser;
+import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.system.dto.UserFeedbackSubmitRequest;
+import com.tcm.training.admin.system.entity.UserFeedback;
+import com.tcm.training.admin.system.mapper.UserFeedbackMapper;
+import com.tcm.training.admin.system.mapper.UserFeedbackQueryMapper;
+import com.tcm.training.admin.system.vo.UserFeedbackListItemVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.regex.Pattern;
+
+@Service
+public class UserFeedbackAppService {
+
+    private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
+    private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
+
+    private final UserFeedbackMapper userFeedbackMapper;
+    private final UserFeedbackQueryMapper userFeedbackQueryMapper;
+    private final UserMapper userMapper;
+
+    public UserFeedbackAppService(UserFeedbackMapper userFeedbackMapper,
+                                  UserFeedbackQueryMapper userFeedbackQueryMapper,
+                                  UserMapper userMapper) {
+        this.userFeedbackMapper = userFeedbackMapper;
+        this.userFeedbackQueryMapper = userFeedbackQueryMapper;
+        this.userMapper = userMapper;
+    }
+
+    public PageResponse<UserFeedbackListItemVO> page(int page, int pageSize, String content) {
+        IPage<UserFeedbackListItemVO> result = userFeedbackQueryMapper.selectUserFeedbackPage(
+                new Page<UserFeedbackListItemVO>(page, pageSize),
+                trimToNull(content)
+        );
+        return PageResponse.of(result.getRecords(), result.getTotal(), result.getCurrent(), result.getSize());
+    }
+
+    @Transactional
+    public void submit(Long appUserId, UserFeedbackSubmitRequest request) {
+        requireAppUser(appUserId);
+
+        UserFeedback item = new UserFeedback();
+        item.setAppUserId(appUserId);
+        item.setContent(normalizeContent(request.getContent()));
+        item.setContact(normalizeContact(request.getContact()));
+        item.setDeleted(0);
+        userFeedbackMapper.insert(item);
+    }
+
+    private void requireAppUser(Long appUserId) {
+        AppUser user = userMapper.selectById(appUserId);
+        if (user == null || (user.getDeleted() != null && user.getDeleted() == 1)) {
+            throw new IllegalArgumentException("用户不存在");
+        }
+    }
+
+    private String normalizeContent(String content) {
+        String trimmed = trimToNull(content);
+        if (trimmed == null) {
+            throw new IllegalArgumentException("请输入问题和意见");
+        }
+        return trimmed;
+    }
+
+    private String normalizeContact(String contact) {
+        String trimmed = trimToNull(contact);
+        if (trimmed == null) {
+            return null;
+        }
+        if (!PHONE_PATTERN.matcher(trimmed).matches() && !EMAIL_PATTERN.matcher(trimmed).matches()) {
+            throw new IllegalArgumentException("请输入正确的手机号或邮箱");
+        }
+        return trimmed;
+    }
+
+    private String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/UserFeedbackListItemVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/UserFeedbackListItemVO.java
new file mode 100644
index 0000000..63c9cad
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/UserFeedbackListItemVO.java
@@ -0,0 +1,21 @@
+package com.tcm.training.admin.system.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserFeedbackListItemVO {
+
+    private Long id;
+    private Long appUserId;
+    private String nickname;
+    private String avatar;
+    private String content;
+    private String contact;
+    private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/resources/mapper/UserFeedbackQueryMapper.xml b/backend/src/main/resources/mapper/UserFeedbackQueryMapper.xml
new file mode 100644
index 0000000..4567ce8
--- /dev/null
+++ b/backend/src/main/resources/mapper/UserFeedbackQueryMapper.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tcm.training.admin.system.mapper.UserFeedbackQueryMapper">
+
+    <select id="selectUserFeedbackPage" resultType="com.tcm.training.admin.system.vo.UserFeedbackListItemVO">
+        SELECT
+            f.id,
+            f.app_user_id AS appUserId,
+            u.nickname,
+            u.avatar,
+            f.content,
+            f.contact,
+            f.created_at AS createdAt
+        FROM tcm_user_feedback f
+        LEFT JOIN tcm_app_user u ON u.id = f.app_user_id AND u.deleted = 0
+        WHERE f.deleted = 0
+        <if test="content != null and content.trim() != ''">
+            AND f.content LIKE CONCAT('%', #{content}, '%')
+        </if>
+        ORDER BY f.created_at DESC, f.id DESC
+    </select>
+
+</mapper>
diff --git a/backend/src/test/java/com/tcm/training/admin/system/service/UserFeedbackAppServiceTest.java b/backend/src/test/java/com/tcm/training/admin/system/service/UserFeedbackAppServiceTest.java
new file mode 100644
index 0000000..d589e94
--- /dev/null
+++ b/backend/src/test/java/com/tcm/training/admin/system/service/UserFeedbackAppServiceTest.java
@@ -0,0 +1,133 @@
+package com.tcm.training.admin.system.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.tcm.training.admin.member.entity.AppUser;
+import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.system.dto.UserFeedbackSubmitRequest;
+import com.tcm.training.admin.system.entity.UserFeedback;
+import com.tcm.training.admin.system.mapper.UserFeedbackMapper;
+import com.tcm.training.admin.system.mapper.UserFeedbackQueryMapper;
+import com.tcm.training.admin.system.vo.UserFeedbackListItemVO;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class UserFeedbackAppServiceTest {
+
+    @Mock
+    private UserFeedbackMapper userFeedbackMapper;
+
+    @Mock
+    private UserFeedbackQueryMapper userFeedbackQueryMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @InjectMocks
+    private UserFeedbackAppService userFeedbackAppService;
+
+    @Captor
+    private ArgumentCaptor<UserFeedback> userFeedbackCaptor;
+
+    @Test
+    void submitShouldPersistTrimmedFeedback() {
+        AppUser user = new AppUser();
+        user.setId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+        doAnswer(invocation -> {
+            UserFeedback item = invocation.getArgument(0);
+            item.setId(10L);
+            return 1;
+        }).when(userFeedbackMapper).insert(any(UserFeedback.class));
+
+        UserFeedbackSubmitRequest request = new UserFeedbackSubmitRequest();
+        request.setContent("  收不到验证码  ");
+        request.setContact(" 13800138000 ");
+
+        userFeedbackAppService.submit(1L, request);
+
+        verify(userFeedbackMapper).insert(userFeedbackCaptor.capture());
+        assertEquals(1L, userFeedbackCaptor.getValue().getAppUserId());
+        assertEquals("收不到验证码", userFeedbackCaptor.getValue().getContent());
+        assertEquals("13800138000", userFeedbackCaptor.getValue().getContact());
+    }
+
+    @Test
+    void submitShouldAllowBlankContact() {
+        AppUser user = new AppUser();
+        user.setId(2L);
+        when(userMapper.selectById(2L)).thenReturn(user);
+
+        UserFeedbackSubmitRequest request = new UserFeedbackSubmitRequest();
+        request.setContent("课程卡顿");
+        request.setContact("   ");
+
+        userFeedbackAppService.submit(2L, request);
+
+        verify(userFeedbackMapper).insert(userFeedbackCaptor.capture());
+        assertNull(userFeedbackCaptor.getValue().getContact());
+    }
+
+    @Test
+    void submitShouldRejectInvalidContact() {
+        AppUser user = new AppUser();
+        user.setId(3L);
+        when(userMapper.selectById(3L)).thenReturn(user);
+
+        UserFeedbackSubmitRequest request = new UserFeedbackSubmitRequest();
+        request.setContent("问题");
+        request.setContact("abc");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> userFeedbackAppService.submit(3L, request));
+
+        assertEquals("请输入正确的手机号或邮箱", exception.getMessage());
+    }
+
+    @Test
+    void submitShouldRejectMissingUser() {
+        when(userMapper.selectById(100L)).thenReturn(null);
+
+        UserFeedbackSubmitRequest request = new UserFeedbackSubmitRequest();
+        request.setContent("问题");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> userFeedbackAppService.submit(100L, request));
+
+        assertEquals("用户不存在", exception.getMessage());
+    }
+
+    @Test
+    void pageShouldTrimContentKeyword() {
+        UserFeedbackListItemVO item = new UserFeedbackListItemVO();
+        item.setId(1L);
+        item.setNickname("王志强");
+        item.setContact("14725836904");
+        item.setContent("反馈内容");
+        item.setCreatedAt(LocalDateTime.of(2026, 3, 26, 10, 0, 0));
+        IPage<UserFeedbackListItemVO> result = new Page<UserFeedbackListItemVO>(1, 10);
+        result.setRecords(Collections.singletonList(item));
+        result.setTotal(1);
+        when(userFeedbackQueryMapper.selectUserFeedbackPage(any(Page.class), eq("验证码"))).thenReturn(result);
+
+        assertEquals(1, userFeedbackAppService.page(1, 10, "  验证码 ").getList().size());
+    }
+}
diff --git a/tcm-training-admin/config/routes.ts b/tcm-training-admin/config/routes.ts
index fa48ef5..ba98a07 100644
--- a/tcm-training-admin/config/routes.ts
+++ b/tcm-training-admin/config/routes.ts
@@ -117,6 +117,12 @@
             component: './system/common-question',
           },
           {
+            name: '用户意见反馈',
+            path: '/system/settings/user-feedback',
+            access: 'system:user-feedback:view',
+            component: './system/user-feedback',
+          },
+          {
             name: '消息推送配置',
             path: '/system/settings/message-push',
             access: 'system:message-push:view',
diff --git a/tcm-training-admin/src/access.js b/tcm-training-admin/src/access.js
index 85bdc84..5d9dd97 100644
--- a/tcm-training-admin/src/access.js
+++ b/tcm-training-admin/src/access.js
@@ -21,6 +21,7 @@
       !!permission?.['system:platform-qualification:view'] ||
       !!permission?.['system:agreement-content:view'] ||
       !!permission?.['system:common-question:view'] ||
+      !!permission?.['system:user-feedback:view'] ||
       !!permission?.['system:message-push:view'] ||
       !!permission?.['system:points-settings:view'],
     canViewMemberModule: canViewMemberUserGroup || canViewMemberVipGroup,
diff --git a/tcm-training-admin/src/pages/system/settings-redirect/index.tsx b/tcm-training-admin/src/pages/system/settings-redirect/index.tsx
index c6b3fb7..1d0f2cd 100644
--- a/tcm-training-admin/src/pages/system/settings-redirect/index.tsx
+++ b/tcm-training-admin/src/pages/system/settings-redirect/index.tsx
@@ -33,6 +33,10 @@
       history.replace('/system/settings/common-question');
       return;
     }
+    if (access['system:user-feedback:view']) {
+      history.replace('/system/settings/user-feedback');
+      return;
+    }
     if (access['system:message-push:view']) {
       history.replace('/system/settings/message-push');
       return;
diff --git a/tcm-training-admin/src/pages/system/user-feedback/index.tsx b/tcm-training-admin/src/pages/system/user-feedback/index.tsx
new file mode 100644
index 0000000..42512cb
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/user-feedback/index.tsx
@@ -0,0 +1,154 @@
+import { history, useAccess } from '@umijs/max';
+import { Avatar, Button, Form, Input, Pagination, Table, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import React, { useEffect, useState } from 'react';
+import { getUserFeedbackPage } from './service';
+import './style.less';
+
+type SearchValues = {
+  content?: string;
+};
+
+type UserFeedbackItem = {
+  id: number;
+  appUserId?: number;
+  nickname?: string;
+  avatar?: string;
+  content?: string;
+  contact?: string;
+  createdAt?: string;
+};
+
+const trimSearchValues = (values: SearchValues) => ({
+  content: values.content?.trim() || undefined,
+});
+
+const UserFeedbackPage: React.FC = () => {
+  const access = useAccess();
+  const [searchForm] = Form.useForm<SearchValues>();
+  const [list, setList] = useState<UserFeedbackItem[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
+
+  const canView = access['system:user-feedback:view'];
+
+  const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
+    setLoading(true);
+    try {
+      const res = await getUserFeedbackPage({
+        page,
+        pageSize,
+        ...trimSearchValues(searchForm.getFieldsValue()),
+      });
+      setList(res.data?.list || []);
+      setPagination({
+        page: Number(res.data?.page || page),
+        pageSize: Number(res.data?.pageSize || pageSize),
+        total: Number(res.data?.total || 0),
+      });
+    } catch (error) {
+      message.error((error as Error)?.message || '用户意见反馈列表加载失败');
+      setList([]);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (!canView) {
+      history.replace('/Welcome');
+      return;
+    }
+    loadList(1, 10);
+  }, [canView]);
+
+  const columns: ColumnsType<UserFeedbackItem> = [
+    {
+      title: '序号',
+      width: 80,
+      render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
+    },
+    {
+      title: '反馈人',
+      dataIndex: 'nickname',
+      width: 180,
+      render: (_, record) => (
+        <div className="user-feedback-user">
+          <Avatar src={record.avatar} size={34}>
+            {record.nickname?.slice(0, 1) || '匿'}
+          </Avatar>
+          <span className="user-feedback-user__name">{record.nickname || '-'}</span>
+        </div>
+      ),
+    },
+    {
+      title: '反馈内容',
+      dataIndex: 'content',
+      render: (value) => <div className="user-feedback-content">{value || '-'}</div>,
+    },
+    {
+      title: '联系方式',
+      dataIndex: 'contact',
+      width: 180,
+      render: (value) => value || '-',
+    },
+    {
+      title: '反馈时间',
+      dataIndex: 'createdAt',
+      width: 180,
+      render: (value) => value || '-',
+    },
+  ];
+
+  return (
+    <div className="user-feedback-page">
+      <div className="user-feedback-card">
+        <div className="user-feedback-card__title">用户意见反馈</div>
+        <div className="user-feedback-search-card">
+          <Form form={searchForm} layout="horizontal" className="user-feedback-search-form">
+            <Form.Item label="反馈内容" name="content">
+              <Input placeholder="请输入" allowClear />
+            </Form.Item>
+            <div className="user-feedback-search-form__actions">
+              <Button
+                onClick={async () => {
+                  searchForm.resetFields();
+                  await loadList(1, pagination.pageSize);
+                }}
+              >
+                重置
+              </Button>
+              <Button type="primary" onClick={() => loadList(1, pagination.pageSize)}>
+                搜索
+              </Button>
+            </div>
+          </Form>
+        </div>
+
+        <div className="user-feedback-table-card">
+          <Table<UserFeedbackItem>
+            rowKey="id"
+            columns={columns}
+            dataSource={list}
+            loading={loading}
+            pagination={false}
+            scroll={{ x: 980 }}
+          />
+          <div className="user-feedback-pagination">
+            <Pagination
+              current={pagination.page}
+              pageSize={pagination.pageSize}
+              total={pagination.total}
+              showSizeChanger
+              showQuickJumper
+              showTotal={(total) => `共 ${total} 条`}
+              onChange={(page, pageSize) => loadList(page, pageSize)}
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default UserFeedbackPage;
diff --git a/tcm-training-admin/src/pages/system/user-feedback/service.ts b/tcm-training-admin/src/pages/system/user-feedback/service.ts
new file mode 100644
index 0000000..76294e4
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/user-feedback/service.ts
@@ -0,0 +1,8 @@
+import { request } from '@umijs/max';
+
+export async function getUserFeedbackPage(params?: Record<string, unknown>) {
+  return request('/user-feedbacks', {
+    method: 'GET',
+    params,
+  });
+}
diff --git a/tcm-training-admin/src/pages/system/user-feedback/style.less b/tcm-training-admin/src/pages/system/user-feedback/style.less
new file mode 100644
index 0000000..9aa47b8
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/user-feedback/style.less
@@ -0,0 +1,116 @@
+.user-feedback-page {
+  min-height: calc(100vh - 48px);
+  padding: 18px 20px 24px;
+  background: #f2f5f9;
+}
+
+.user-feedback-card {
+  min-height: 700px;
+  padding: 8px 14px 18px;
+  background: #fff;
+  border-radius: 10px;
+  box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.user-feedback-card__title {
+  margin: 0 0 22px;
+  color: #303133;
+  font-weight: 500;
+  font-size: 18px;
+}
+
+.user-feedback-search-card {
+  margin-bottom: 20px;
+}
+
+.user-feedback-search-form {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0 18px;
+  align-items: center;
+}
+
+.user-feedback-search-form .ant-form-item {
+  margin-bottom: 0;
+}
+
+.user-feedback-search-form .ant-input {
+  width: 170px;
+}
+
+.user-feedback-search-form__actions {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.user-feedback-table-card {
+  overflow: hidden;
+  border: 1px solid #e6ebf2;
+  border-radius: 4px;
+}
+
+.user-feedback-table-card .ant-table-thead > tr > th {
+  color: #303133;
+  font-weight: 500;
+  background: #f5f7fa;
+}
+
+.user-feedback-table-card .ant-table-tbody > tr > td {
+  color: #606266;
+  vertical-align: middle;
+}
+
+.user-feedback-user {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+}
+
+.user-feedback-user__name {
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.user-feedback-content {
+  display: -webkit-box;
+  overflow: hidden;
+  color: #606266;
+  line-height: 22px;
+  word-break: break-all;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.user-feedback-pagination {
+  display: flex;
+  justify-content: flex-end;
+  padding: 16px 18px;
+  background: #fff;
+}
+
+@media (max-width: 900px) {
+  .user-feedback-page {
+    padding: 12px;
+  }
+
+  .user-feedback-card {
+    min-height: auto;
+    padding: 16px;
+  }
+
+  .user-feedback-search-form {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .user-feedback-search-form .ant-input {
+    width: 100%;
+  }
+
+  .user-feedback-search-form__actions {
+    justify-content: flex-end;
+  }
+}

--
Gitblit v1.7.1