“mitao”
2026-03-26 cd8d9525b42164bb39221023a8455fb35be79c02
feat:系统与安全-系统设置-常见问题
12个文件已添加
5个文件已修改
1060 ■■■■■ 已修改文件
backend/sql/init.sql 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/update_common_question_schema.sql 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/controller/CommonQuestionController.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/dto/CommonQuestionSaveRequest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/entity/CommonQuestion.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/mapper/CommonQuestionMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/service/CommonQuestionAppService.java 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/vo/CommonQuestionDetailVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/vo/CommonQuestionListItemVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tcm/training/admin/system/service/CommonQuestionAppServiceTest.java 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/common-question/index.tsx 362 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/common-question/service.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/common-question/style.less 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/settings-redirect/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/init.sql
@@ -126,6 +126,10 @@
(1329, 1324, '切换平台资质上架状态', 'system_platform_qualification_publish_status', 'B', NULL, NULL, NULL, 5, 1, 1, 'system:platform-qualification:publish-status', '平台资质上架状态按钮', 0),
(1330, 1300, '协议内容', 'system_agreement_content', 'C', '/system/settings/agreement-content', 'System/AgreementContent', NULL, 16, 1, 1, 'system:agreement-content:view', '协议内容菜单', 0),
(1331, 1330, '编辑协议内容', 'system_agreement_content_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:agreement-content:edit', '协议内容编辑按钮', 0),
(1332, 1300, '常见问题', 'system_common_question', 'C', '/system/settings/common-question', 'System/CommonQuestion', NULL, 17, 1, 1, 'system:common-question:view', '常见问题菜单', 0),
(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),
(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),
@@ -211,7 +215,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, 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, 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),
@@ -963,6 +967,22 @@
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协议内容表';
-- ----------------------------
-- 常见问题
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_common_question` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title` VARCHAR(15) NOT NULL COMMENT '问题标题',
  `content_html` LONGTEXT 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_common_question_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='常见问题表';
-- ----------------------------
-- 积分设置
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_points_config` (
backend/sql/update_common_question_schema.sql
New file
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS `tcm_common_question` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title` VARCHAR(15) NOT NULL COMMENT '问题标题',
  `content_html` LONGTEXT 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_common_question_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),
(1332, 1300, '常见问题', 'system_common_question', 'C', '/system/settings/common-question', 'System/CommonQuestion', NULL, 17, 1, 1, 'system:common-question:view', '常见问题菜单', 0),
(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);
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
VALUES
(1, 1300), (1, 1332), (1, 1333), (1, 1334), (1, 1335);
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -169,6 +169,14 @@
                "/system/settings/agreement-content", "System/AgreementContent", null, 16, "system:agreement-content:view", "协议内容菜单"));
        menuIds.add(saveMenu(1331L, 1330L, "编辑协议内容", "system_agreement_content_edit", "B",
                null, null, null, 1, "system:agreement-content:edit", "协议内容编辑按钮"));
        menuIds.add(saveMenu(1332L, 1300L, "常见问题", "system_common_question", "C",
                "/system/settings/common-question", "System/CommonQuestion", null, 17, "system:common-question:view", "常见问题菜单"));
        menuIds.add(saveMenu(1333L, 1332L, "新增常见问题", "system_common_question_add", "B",
                null, null, null, 1, "system:common-question:add", "常见问题新增按钮"));
        menuIds.add(saveMenu(1334L, 1332L, "编辑常见问题", "system_common_question_edit", "B",
                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(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/CommonQuestionController.java
New file
@@ -0,0 +1,62 @@
package com.tcm.training.admin.system.controller;
import com.tcm.training.admin.common.ApiResponse;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.system.dto.CommonQuestionSaveRequest;
import com.tcm.training.admin.system.service.CommonQuestionAppService;
import com.tcm.training.admin.system.vo.CommonQuestionDetailVO;
import com.tcm.training.admin.system.vo.CommonQuestionListItemVO;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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 CommonQuestionController {
    private final CommonQuestionAppService commonQuestionAppService;
    public CommonQuestionController(CommonQuestionAppService commonQuestionAppService) {
        this.commonQuestionAppService = commonQuestionAppService;
    }
    @GetMapping("/common-questions")
    public ApiResponse<PageResponse<CommonQuestionListItemVO>> page(
            @RequestParam(required = false, defaultValue = "1") int page,
            @RequestParam(required = false, defaultValue = "10") int pageSize,
            @RequestParam(required = false) String title) {
        return ApiResponse.ok(commonQuestionAppService.page(page, pageSize, title));
    }
    @GetMapping("/common-questions/{id}")
    public ApiResponse<CommonQuestionDetailVO> detail(@PathVariable("id") Long id) {
        return ApiResponse.ok(commonQuestionAppService.detail(id));
    }
    @PostMapping("/common-questions")
    public ApiResponse<CommonQuestionDetailVO> create(@Valid @RequestBody CommonQuestionSaveRequest request) {
        return ApiResponse.ok(commonQuestionAppService.create(request));
    }
    @PutMapping("/common-questions/{id}")
    public ApiResponse<CommonQuestionDetailVO> update(@PathVariable("id") Long id,
                                                      @Valid @RequestBody CommonQuestionSaveRequest request) {
        return ApiResponse.ok(commonQuestionAppService.update(id, request));
    }
    @DeleteMapping("/common-questions/{id}")
    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
        commonQuestionAppService.delete(id);
        return ApiResponse.ok(null);
    }
}
backend/src/main/java/com/tcm/training/admin/system/dto/CommonQuestionSaveRequest.java
New file
@@ -0,0 +1,20 @@
package com.tcm.training.admin.system.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Getter
@Setter
@NoArgsConstructor
public class CommonQuestionSaveRequest {
    @NotBlank(message = "请输入问题")
    @Size(max = 15, message = "请输入15个字以内")
    private String title;
    private String contentHtml;
}
backend/src/main/java/com/tcm/training/admin/system/entity/CommonQuestion.java
New file
@@ -0,0 +1,44 @@
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_common_question")
@Getter
@Setter
@NoArgsConstructor
public class CommonQuestion {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("title")
    private String title;
    @TableField("content_html")
    private String contentHtml;
    @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/CommonQuestionMapper.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.CommonQuestion;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CommonQuestionMapper extends BaseMapper<CommonQuestion> {
}
backend/src/main/java/com/tcm/training/admin/system/service/CommonQuestionAppService.java
New file
@@ -0,0 +1,146 @@
package com.tcm.training.admin.system.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.system.dto.CommonQuestionSaveRequest;
import com.tcm.training.admin.system.entity.CommonQuestion;
import com.tcm.training.admin.system.mapper.CommonQuestionMapper;
import com.tcm.training.admin.system.vo.CommonQuestionDetailVO;
import com.tcm.training.admin.system.vo.CommonQuestionListItemVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class CommonQuestionAppService {
    private final CommonQuestionMapper commonQuestionMapper;
    public CommonQuestionAppService(CommonQuestionMapper commonQuestionMapper) {
        this.commonQuestionMapper = commonQuestionMapper;
    }
    public PageResponse<CommonQuestionListItemVO> page(int page, int pageSize, String title) {
        LambdaQueryWrapper<CommonQuestion> queryWrapper = new LambdaQueryWrapper<CommonQuestion>()
                .eq(CommonQuestion::getDeleted, 0)
                .like(title != null && !title.trim().isEmpty(), CommonQuestion::getTitle, title == null ? null : title.trim())
                .orderByDesc(CommonQuestion::getCreatedAt)
                .orderByDesc(CommonQuestion::getId);
        IPage<CommonQuestion> result = commonQuestionMapper.selectPage(new Page<CommonQuestion>(page, pageSize), queryWrapper);
        List<CommonQuestionListItemVO> list = new ArrayList<CommonQuestionListItemVO>();
        for (CommonQuestion item : result.getRecords()) {
            list.add(toListItem(item));
        }
        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
    }
    public CommonQuestionDetailVO detail(Long id) {
        return toDetailVO(requireItem(id));
    }
    @Transactional
    public CommonQuestionDetailVO create(CommonQuestionSaveRequest request) {
        CommonQuestion item = new CommonQuestion();
        fillEntity(item, request);
        item.setDeleted(0);
        commonQuestionMapper.insert(item);
        return detail(item.getId());
    }
    @Transactional
    public CommonQuestionDetailVO update(Long id, CommonQuestionSaveRequest request) {
        CommonQuestion item = requireItem(id);
        fillEntity(item, request);
        commonQuestionMapper.updateById(item);
        return detail(id);
    }
    @Transactional
    public void delete(Long id) {
        requireItem(id);
        commonQuestionMapper.deleteById(id);
    }
    private void fillEntity(CommonQuestion item, CommonQuestionSaveRequest request) {
        item.setTitle(normalizeTitle(request.getTitle()));
        item.setContentHtml(normalizeContent(request.getContentHtml()));
    }
    private CommonQuestion requireItem(Long id) {
        CommonQuestion item = commonQuestionMapper.selectOne(new LambdaQueryWrapper<CommonQuestion>()
                .eq(CommonQuestion::getId, id)
                .eq(CommonQuestion::getDeleted, 0)
                .last("limit 1"));
        if (item == null) {
            throw new IllegalArgumentException("常见问题不存在");
        }
        return item;
    }
    private CommonQuestionListItemVO toListItem(CommonQuestion item) {
        CommonQuestionListItemVO vo = new CommonQuestionListItemVO();
        vo.setId(item.getId());
        vo.setTitle(item.getTitle());
        vo.setCreatedAt(item.getCreatedAt());
        return vo;
    }
    private CommonQuestionDetailVO toDetailVO(CommonQuestion item) {
        CommonQuestionDetailVO vo = new CommonQuestionDetailVO();
        vo.setId(item.getId());
        vo.setTitle(item.getTitle());
        vo.setContentHtml(item.getContentHtml());
        vo.setCreatedAt(item.getCreatedAt());
        vo.setUpdatedAt(item.getUpdatedAt());
        return vo;
    }
    private String normalizeTitle(String title) {
        String trimmed = trimToNull(title);
        if (trimmed == null) {
            throw new IllegalArgumentException("请输入问题");
        }
        if (trimmed.length() > 15) {
            throw new IllegalArgumentException("请输入15个字以内");
        }
        return trimmed;
    }
    private String normalizeContent(String contentHtml) {
        String trimmed = trimToNull(contentHtml);
        if (trimmed == null || isBlankRichText(trimmed)) {
            throw new IllegalArgumentException("请输入内容");
        }
        return trimmed;
    }
    private boolean isBlankRichText(String html) {
        String normalized = html
                .replaceAll("(?i)<br\\s*/?>", "")
                .replaceAll("(?i)</p>", "")
                .replace("&nbsp;", "")
                .replace("&#160;", "")
                .replaceAll("<[^>]+>", "")
                .trim();
        if (!normalized.isEmpty()) {
            return false;
        }
        String lowerCaseHtml = html.toLowerCase();
        return !lowerCaseHtml.contains("<img")
                && !lowerCaseHtml.contains("<video")
                && !lowerCaseHtml.contains("<audio")
                && !lowerCaseHtml.contains("<iframe");
    }
    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/CommonQuestionDetailVO.java
New file
@@ -0,0 +1,19 @@
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 CommonQuestionDetailVO {
    private Long id;
    private String title;
    private String contentHtml;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
backend/src/main/java/com/tcm/training/admin/system/vo/CommonQuestionListItemVO.java
New file
@@ -0,0 +1,17 @@
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 CommonQuestionListItemVO {
    private Long id;
    private String title;
    private LocalDateTime createdAt;
}
backend/src/test/java/com/tcm/training/admin/system/service/CommonQuestionAppServiceTest.java
New file
@@ -0,0 +1,137 @@
package com.tcm.training.admin.system.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.tcm.training.admin.system.dto.CommonQuestionSaveRequest;
import com.tcm.training.admin.system.entity.CommonQuestion;
import com.tcm.training.admin.system.mapper.CommonQuestionMapper;
import com.tcm.training.admin.system.vo.CommonQuestionDetailVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CommonQuestionAppServiceTest {
    @BeforeAll
    static void initLambdaCache() {
        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
        TableInfoHelper.initTableInfo(assistant, CommonQuestion.class);
    }
    @Mock
    private CommonQuestionMapper commonQuestionMapper;
    @InjectMocks
    private CommonQuestionAppService commonQuestionAppService;
    @Captor
    private ArgumentCaptor<CommonQuestion> commonQuestionCaptor;
    @Test
    void createShouldPersistQuestion() {
        doAnswer(invocation -> {
            CommonQuestion item = invocation.getArgument(0);
            item.setId(1L);
            return 1;
        }).when(commonQuestionMapper).insert(any(CommonQuestion.class));
        CommonQuestion persisted = new CommonQuestion();
        persisted.setId(1L);
        persisted.setTitle("常见问题");
        persisted.setContentHtml("<p>内容</p>");
        persisted.setCreatedAt(LocalDateTime.of(2026, 3, 26, 21, 10, 0));
        when(commonQuestionMapper.selectOne(any())).thenReturn(persisted);
        CommonQuestionSaveRequest request = new CommonQuestionSaveRequest();
        request.setTitle("  常见问题  ");
        request.setContentHtml("<p>内容</p>");
        CommonQuestionDetailVO result = commonQuestionAppService.create(request);
        verify(commonQuestionMapper).insert(commonQuestionCaptor.capture());
        assertEquals("常见问题", commonQuestionCaptor.getValue().getTitle());
        assertEquals("<p>内容</p>", commonQuestionCaptor.getValue().getContentHtml());
        assertEquals("常见问题", result.getTitle());
    }
    @Test
    void updateShouldPersistQuestion() {
        CommonQuestion persisted = new CommonQuestion();
        persisted.setId(2L);
        persisted.setTitle("旧问题");
        persisted.setContentHtml("<p>旧内容</p>");
        when(commonQuestionMapper.selectOne(any())).thenReturn(persisted);
        CommonQuestionSaveRequest request = new CommonQuestionSaveRequest();
        request.setTitle("新问题");
        request.setContentHtml("<p>新内容</p>");
        commonQuestionAppService.update(2L, request);
        verify(commonQuestionMapper).updateById(commonQuestionCaptor.capture());
        assertEquals("新问题", commonQuestionCaptor.getValue().getTitle());
        assertEquals("<p>新内容</p>", commonQuestionCaptor.getValue().getContentHtml());
    }
    @Test
    void createShouldRejectBlankTitle() {
        CommonQuestionSaveRequest request = new CommonQuestionSaveRequest();
        request.setTitle("   ");
        request.setContentHtml("<p>内容</p>");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> commonQuestionAppService.create(request));
        assertEquals("请输入问题", exception.getMessage());
    }
    @Test
    void createShouldRejectTooLongTitle() {
        CommonQuestionSaveRequest request = new CommonQuestionSaveRequest();
        request.setTitle("这是一个超过十五个字的问题标题啊");
        request.setContentHtml("<p>内容</p>");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> commonQuestionAppService.create(request));
        assertEquals("请输入15个字以内", exception.getMessage());
    }
    @Test
    void createShouldRejectBlankContent() {
        CommonQuestionSaveRequest request = new CommonQuestionSaveRequest();
        request.setTitle("问题");
        request.setContentHtml("<p><br/></p>");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> commonQuestionAppService.create(request));
        assertEquals("请输入内容", exception.getMessage());
    }
    @Test
    void deleteShouldRejectMissingQuestion() {
        when(commonQuestionMapper.selectOne(any())).thenReturn(null);
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> commonQuestionAppService.delete(100L));
        assertEquals("常见问题不存在", exception.getMessage());
    }
}
tcm-training-admin/config/routes.ts
@@ -111,6 +111,12 @@
            component: './system/agreement-content',
          },
          {
            name: '常见问题',
            path: '/system/settings/common-question',
            access: 'system:common-question:view',
            component: './system/common-question',
          },
          {
            name: '消息推送配置',
            path: '/system/settings/message-push',
            access: 'system:message-push:view',
tcm-training-admin/src/access.js
@@ -20,6 +20,7 @@
      !!permission?.['system:activity-notice:view'] ||
      !!permission?.['system:platform-qualification:view'] ||
      !!permission?.['system:agreement-content:view'] ||
      !!permission?.['system:common-question:view'] ||
      !!permission?.['system:message-push:view'] ||
      !!permission?.['system:points-settings:view'],
    canViewMemberModule: canViewMemberUserGroup || canViewMemberVipGroup,
tcm-training-admin/src/pages/system/common-question/index.tsx
New file
@@ -0,0 +1,362 @@
import BraftEditorCom from '@/components/BraftEditor';
import { PlusOutlined } from '@ant-design/icons';
import { history, useAccess } from '@umijs/max';
import { Button, Form, Input, Modal, Pagination, Space, Table, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useEffect, useState } from 'react';
import {
  createCommonQuestion,
  deleteCommonQuestion,
  getCommonQuestionDetail,
  getCommonQuestionPage,
  updateCommonQuestion,
} from './service';
import './style.less';
type SearchValues = {
  title?: string;
};
type CommonQuestionItem = {
  id: number;
  title?: string;
  createdAt?: string;
};
type CommonQuestionDetail = CommonQuestionItem & {
  contentHtml?: string;
  updatedAt?: string;
};
type CommonQuestionFormValues = {
  title?: string;
  contentHtml?: string;
};
type ModalMode = 'create' | 'edit';
const trimSearchValues = (values: SearchValues) => ({
  title: values.title?.trim() || undefined,
});
const isBlankRichText = (html?: string) => {
  const value = (html || '').trim();
  if (!value) {
    return true;
  }
  const normalized = value
    .replace(/<br\s*\/?>/gi, '')
    .replace(/<\/p>/gi, '')
    .replace(/&nbsp;/gi, '')
    .replace(/&#160;/gi, '')
    .replace(/<[^>]+>/g, '')
    .trim();
  if (normalized) {
    return false;
  }
  const lowerCaseValue = value.toLowerCase();
  return !lowerCaseValue.includes('<img') && !lowerCaseValue.includes('<video');
};
const defaultValues: CommonQuestionFormValues = {
  title: '',
  contentHtml: '',
};
const CommonQuestionPage: React.FC = () => {
  const access = useAccess();
  const [searchForm] = Form.useForm<SearchValues>();
  const [form] = Form.useForm<CommonQuestionFormValues>();
  const [list, setList] = useState<CommonQuestionItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [modalOpen, setModalOpen] = useState(false);
  const [modalLoading, setModalLoading] = useState(false);
  const [modalMode, setModalMode] = useState<ModalMode>('create');
  const [editingId, setEditingId] = useState<number>();
  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const canView = access['system:common-question:view'];
  const canAdd = access['system:common-question:add'];
  const canEdit = access['system:common-question:edit'];
  const canDelete = access['system:common-question:delete'];
  const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
    setLoading(true);
    try {
      const res = await getCommonQuestionPage({
        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 handleCloseModal = () => {
    if (submitting) {
      return;
    }
    setModalOpen(false);
    setModalLoading(false);
    setEditingId(undefined);
    form.resetFields();
  };
  const openCreateModal = () => {
    setModalMode('create');
    setEditingId(undefined);
    setModalLoading(false);
    form.resetFields();
    form.setFieldsValue(defaultValues);
    setModalOpen(true);
  };
  const openEditModal = async (record: CommonQuestionItem) => {
    setModalMode('edit');
    setEditingId(record.id);
    setModalOpen(true);
    setModalLoading(true);
    form.resetFields();
    try {
      const res = await getCommonQuestionDetail(record.id);
      const detail = (res.data || {}) as CommonQuestionDetail;
      form.setFieldsValue({
        title: detail.title || '',
        contentHtml: detail.contentHtml || '',
      });
    } catch (error) {
      message.error((error as Error)?.message || '常见问题详情加载失败');
      setModalOpen(false);
    } finally {
      setModalLoading(false);
    }
  };
  const handleDelete = (record: CommonQuestionItem) => {
    Modal.confirm({
      title: '确定要删除该常见问题吗?',
      okText: '删除',
      cancelText: '取消',
      onOk: async () => {
        try {
          await deleteCommonQuestion(record.id);
          message.success('删除成功');
          const nextPage =
            list.length === 1 && pagination.page > 1 ? pagination.page - 1 : pagination.page;
          await loadList(nextPage, pagination.pageSize);
        } catch (error) {
          message.error((error as Error)?.message || '删除失败,请稍后重试');
          throw error;
        }
      },
    });
  };
  const handleSubmit = async () => {
    const values = await form.validateFields();
    if (isBlankRichText(values.contentHtml)) {
      form.setFields([
        {
          name: 'contentHtml',
          errors: ['请输入内容'],
        },
      ]);
      return;
    }
    setSubmitting(true);
    try {
      const payload = {
        title: values.title?.trim(),
        contentHtml: values.contentHtml?.trim(),
      };
      if (modalMode === 'create') {
        await createCommonQuestion(payload);
        message.success('新增成功');
      } else if (editingId) {
        await updateCommonQuestion(editingId, payload);
        message.success('保存成功');
      }
      setModalOpen(false);
      form.resetFields();
      await loadList(modalMode === 'create' ? 1 : pagination.page, pagination.pageSize);
    } catch (error) {
      message.error((error as Error)?.message || '保存失败,请稍后重试');
    } finally {
      setSubmitting(false);
    }
  };
  const columns: ColumnsType<CommonQuestionItem> = [
    {
      title: '排序',
      width: 80,
      render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
    },
    {
      title: '标题',
      dataIndex: 'title',
      ellipsis: true,
    },
    {
      title: '创建时间',
      dataIndex: 'createdAt',
      width: 180,
      render: (value) => value || '-',
    },
    {
      title: '操作',
      width: 160,
      render: (_, record) => (
        <Space size={0} wrap>
          {canEdit ? (
            <Button type="link" size="small" onClick={() => openEditModal(record)}>
              编辑
            </Button>
          ) : null}
          {canDelete ? (
            <Button type="link" size="small" onClick={() => handleDelete(record)}>
              删除
            </Button>
          ) : null}
        </Space>
      ),
    },
  ];
  return (
    <div className="common-question-page">
      <div className="common-question-card">
        <div className="common-question-card__title">常见问题</div>
        <div className="common-question-search-card">
          <Form form={searchForm} layout="horizontal" className="common-question-search-form">
            <Form.Item label="标题" name="title">
              <Input placeholder="请输入" allowClear />
            </Form.Item>
            <div className="common-question-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="common-question-toolbar">
          {canAdd ? (
            <Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
              添加
            </Button>
          ) : null}
        </div>
        <div className="common-question-table-card">
          <Table<CommonQuestionItem>
            rowKey="id"
            columns={columns}
            dataSource={list}
            loading={loading}
            pagination={false}
            scroll={{ x: 900 }}
          />
          <div className="common-question-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>
      <Modal
        open={modalOpen}
        className="common-question-modal"
        destroyOnClose
        centered
        width={820}
        title={modalMode === 'create' ? '新建常见问题' : '编辑常见问题'}
        onCancel={handleCloseModal}
        footer={[
          <Button key="cancel" onClick={handleCloseModal} disabled={submitting}>
            取消
          </Button>,
          <Button key="submit" type="primary" loading={submitting} onClick={handleSubmit}>
            确定
          </Button>,
        ]}
      >
        <Form
          form={form}
          layout="horizontal"
          className="common-question-form"
          labelCol={{ span: 4 }}
          disabled={modalLoading}
        >
          <Form.Item
            label="问题"
            name="title"
            rules={[
              { required: true, message: '请输入问题' },
              { max: 15, message: '请输入15个字以内' },
            ]}
          >
            <Input placeholder="请输入15个字以内" maxLength={15} />
          </Form.Item>
          <Form.Item
            label="内容"
            name="contentHtml"
            rules={[
              {
                validator: async (_, value) => {
                  if (isBlankRichText(value)) {
                    throw new Error('请输入内容');
                  }
                },
              },
            ]}
          >
            <div className="common-question-form__editor">
              <BraftEditorCom
                data={form.getFieldValue('contentHtml') || ''}
                resultCallback={(html: string) => form.setFieldsValue({ contentHtml: html })}
              />
            </div>
          </Form.Item>
        </Form>
        {modalLoading ? <div className="common-question-modal__loading">加载中...</div> : null}
      </Modal>
    </div>
  );
};
export default CommonQuestionPage;
tcm-training-admin/src/pages/system/common-question/service.ts
New file
@@ -0,0 +1,34 @@
import { request } from '@umijs/max';
export async function getCommonQuestionPage(params?: Record<string, any>) {
  return request('/common-questions', {
    method: 'GET',
    params,
  });
}
export async function getCommonQuestionDetail(id: number | string) {
  return request(`/common-questions/${id}`, {
    method: 'GET',
  });
}
export async function createCommonQuestion(data: Record<string, any>) {
  return request('/common-questions', {
    method: 'POST',
    data,
  });
}
export async function updateCommonQuestion(id: number | string, data: Record<string, any>) {
  return request(`/common-questions/${id}`, {
    method: 'PUT',
    data,
  });
}
export async function deleteCommonQuestion(id: number | string) {
  return request(`/common-questions/${id}`, {
    method: 'DELETE',
  });
}
tcm-training-admin/src/pages/system/common-question/style.less
New file
@@ -0,0 +1,144 @@
.common-question-page {
  min-height: calc(100vh - 48px);
  padding: 18px 20px 24px;
  background: #f2f5f9;
}
.common-question-card {
  min-height: 700px;
  padding: 8px 14px 18px;
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.common-question-card__title {
  margin: 0 0 22px;
  color: #303133;
  font-weight: 500;
  font-size: 18px;
}
.common-question-search-card {
  margin-bottom: 20px;
}
.common-question-search-form {
  display: flex;
  flex-wrap: wrap;
  gap: 0 18px;
  align-items: center;
}
.common-question-search-form .ant-form-item {
  margin-bottom: 0;
}
.common-question-search-form .ant-input {
  width: 170px;
}
.common-question-search-form__actions {
  display: flex;
  gap: 12px;
  align-items: center;
}
.common-question-toolbar {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 14px;
}
.common-question-table-card {
  overflow: hidden;
  border: 1px solid #e6ebf2;
  border-radius: 4px;
}
.common-question-table-card .ant-table-thead > tr > th {
  color: #303133;
  font-weight: 500;
  background: #f5f7fa;
}
.common-question-table-card .ant-table-tbody > tr > td {
  color: #606266;
  vertical-align: middle;
}
.common-question-pagination {
  display: flex;
  justify-content: flex-end;
  padding: 16px 18px;
  background: #fff;
}
.common-question-modal .ant-modal-content {
  padding: 18px 24px 20px;
  border-radius: 8px;
}
.common-question-modal .ant-modal-title {
  color: #303133;
  font-weight: 500;
  font-size: 18px;
}
.common-question-modal .ant-modal-body {
  padding-top: 12px;
}
.common-question-form .ant-form-item {
  margin-bottom: 18px;
}
.common-question-form .ant-form-item-label > label {
  color: #303133;
}
.common-question-form__editor {
  border: 1px solid #dcdfe6;
  border-radius: 4px;
}
.common-question-modal__loading {
  position: absolute;
  top: 66px;
  right: 32px;
  color: #909399;
  font-size: 12px;
}
@media (max-width: 900px) {
  .common-question-page {
    padding: 12px;
  }
  .common-question-card {
    min-height: auto;
    padding: 16px;
  }
  .common-question-search-form {
    flex-direction: column;
    align-items: stretch;
  }
  .common-question-search-form .ant-input {
    width: 100%;
  }
  .common-question-search-form__actions {
    justify-content: flex-end;
  }
  .common-question-modal {
    width: calc(100vw - 24px) !important;
    max-width: calc(100vw - 24px);
  }
  .common-question-modal .ant-modal-content {
    padding: 16px;
  }
}
tcm-training-admin/src/pages/system/settings-redirect/index.tsx
@@ -29,6 +29,10 @@
      history.replace('/system/settings/agreement-content');
      return;
    }
    if (access['system:common-question:view']) {
      history.replace('/system/settings/common-question');
      return;
    }
    if (access['system:message-push:view']) {
      history.replace('/system/settings/message-push');
      return;