| | |
| | | (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), |
| | |
| | | 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), |
| | |
| | | ) 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` ( |
| New file |
| | |
| | | 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); |
| | |
| | | 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", |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | <?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> |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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', |
| | |
| | | !!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, |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | import { request } from '@umijs/max'; |
| | | |
| | | export async function getUserFeedbackPage(params?: Record<string, unknown>) { |
| | | return request('/user-feedbacks', { |
| | | method: 'GET', |
| | | params, |
| | | }); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |