“mitao”
2026-03-26 d3a8db99998e5e3d8fbcafa7a8f3cf0915063bd9
feat:系统与安全-系统设置-用户意见反馈
13个文件已添加
5个文件已修改
739 ■■■■■ 已修改文件
backend/sql/init.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/update_user_feedback_schema.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/controller/UserFeedbackController.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/dto/UserFeedbackSubmitRequest.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/entity/UserFeedback.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackQueryMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/service/UserFeedbackAppService.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/vo/UserFeedbackListItemVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/mapper/UserFeedbackQueryMapper.xml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tcm/training/admin/system/service/UserFeedbackAppServiceTest.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/settings-redirect/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/user-feedback/index.tsx 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/user-feedback/service.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/user-feedback/style.less 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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` (
backend/sql/update_user_feedback_schema.sql
New file
@@ -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);
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",
backend/src/main/java/com/tcm/training/admin/system/controller/UserFeedbackController.java
New file
@@ -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);
    }
}
backend/src/main/java/com/tcm/training/admin/system/dto/UserFeedbackSubmitRequest.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/system/entity/UserFeedback.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackMapper.java
New file
@@ -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> {
}
backend/src/main/java/com/tcm/training/admin/system/mapper/UserFeedbackQueryMapper.java
New file
@@ -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);
}
backend/src/main/java/com/tcm/training/admin/system/service/UserFeedbackAppService.java
New file
@@ -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;
    }
}
backend/src/main/java/com/tcm/training/admin/system/vo/UserFeedbackListItemVO.java
New file
@@ -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;
}
backend/src/main/resources/mapper/UserFeedbackQueryMapper.xml
New file
@@ -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>
backend/src/test/java/com/tcm/training/admin/system/service/UserFeedbackAppServiceTest.java
New file
@@ -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());
    }
}
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',
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,
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;
tcm-training-admin/src/pages/system/user-feedback/index.tsx
New file
@@ -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;
tcm-training-admin/src/pages/system/user-feedback/service.ts
New file
@@ -0,0 +1,8 @@
import { request } from '@umijs/max';
export async function getUserFeedbackPage(params?: Record<string, unknown>) {
  return request('/user-feedbacks', {
    method: 'GET',
    params,
  });
}
tcm-training-admin/src/pages/system/user-feedback/style.less
New file
@@ -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;
  }
}