| | |
| | | (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), |
| | |
| | | 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), |
| | |
| | | ) 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` ( |
| New file |
| | |
| | | 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); |
| | |
| | | "/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", |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| 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_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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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(" ", "") |
| | | .replace(" ", "") |
| | | .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; |
| | | } |
| | | } |
| 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 CommonQuestionDetailVO { |
| | | |
| | | private Long id; |
| | | private String title; |
| | | private String contentHtml; |
| | | private LocalDateTime createdAt; |
| | | private LocalDateTime updatedAt; |
| | | } |
| 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 CommonQuestionListItemVO { |
| | | |
| | | private Long id; |
| | | private String title; |
| | | private LocalDateTime createdAt; |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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', |
| | |
| | | !!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, |
| New file |
| | |
| | | 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(/ /gi, '') |
| | | .replace(/ /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; |
| New file |
| | |
| | | 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', |
| | | }); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| | |
| | | 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; |