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