“mitao”
2026-03-26 180b92b9ad1bedf004f2a4f97f72ab12676c08a1
feat:系统与安全-系统设置-协议内容
16个文件已添加
7个文件已修改
816 ■■■■■ 已修改文件
backend/sql/init.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/update_agreement_content_schema.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/controller/AgreementContentController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/dto/AgreementContentSaveRequest.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/entity/AgreementContent.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/mapper/AgreementContentMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/service/AgreementContentAppService.java 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/system/vo/AgreementContentItemVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tcm/training/admin/system/service/AgreementContentAppServiceTest.java 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/app.tsx 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/components/BraftEditor/index.d.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/Login/service.d.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/agreement-content/index.tsx 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/agreement-content/service.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/agreement-content/style.less 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/settings-redirect/index.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/services/ant-design-pro/api.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/utils/utils.d.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/typings.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/init.sql
@@ -124,6 +124,8 @@
(1327, 1324, '编辑平台资质', 'system_platform_qualification_edit', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:platform-qualification:edit', '平台资质编辑按钮', 0),
(1328, 1324, '删除平台资质', 'system_platform_qualification_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:platform-qualification:delete', '平台资质删除按钮', 0),
(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),
(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),
@@ -209,7 +211,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, 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, 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),
@@ -945,6 +947,22 @@
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台资质表';
-- ----------------------------
-- 协议内容
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_agreement_content` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `agreement_type` VARCHAR(64) 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`),
  UNIQUE KEY `uk_agreement_content_type_deleted` (`agreement_type`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协议内容表';
-- ----------------------------
-- 积分设置
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_points_config` (
backend/sql/update_agreement_content_schema.sql
New file
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `tcm_agreement_content` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `agreement_type` VARCHAR(64) 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`),
  UNIQUE KEY `uk_agreement_content_type_deleted` (`agreement_type`, `deleted`)
) 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),
(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);
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
VALUES
(1, 1300), (1, 1330), (1, 1331);
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -165,6 +165,10 @@
                null, null, null, 4, "system:platform-qualification:delete", "平台资质删除按钮"));
        menuIds.add(saveMenu(1329L, 1324L, "切换平台资质上架状态", "system_platform_qualification_publish_status", "B",
                null, null, null, 5, "system:platform-qualification:publish-status", "平台资质上架状态按钮"));
        menuIds.add(saveMenu(1330L, 1300L, "协议内容", "system_agreement_content", "C",
                "/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(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/AgreementContentController.java
New file
@@ -0,0 +1,39 @@
package com.tcm.training.admin.system.controller;
import com.tcm.training.admin.common.ApiResponse;
import com.tcm.training.admin.system.dto.AgreementContentSaveRequest;
import com.tcm.training.admin.system.service.AgreementContentAppService;
import com.tcm.training.admin.system.vo.AgreementContentItemVO;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import javax.validation.Valid;
import java.util.List;
@Validated
@RestController
@RequestMapping({"/api/v1", "/api"})
public class AgreementContentController {
    private final AgreementContentAppService agreementContentAppService;
    public AgreementContentController(AgreementContentAppService agreementContentAppService) {
        this.agreementContentAppService = agreementContentAppService;
    }
    @GetMapping("/agreement-contents")
    public ApiResponse<List<AgreementContentItemVO>> list() {
        return ApiResponse.ok(agreementContentAppService.list());
    }
    @PutMapping("/agreement-contents/{agreementType}")
    public ApiResponse<AgreementContentItemVO> save(@PathVariable("agreementType") String agreementType,
                                                    @Valid @RequestBody AgreementContentSaveRequest request) {
        return ApiResponse.ok(agreementContentAppService.save(agreementType, request));
    }
}
backend/src/main/java/com/tcm/training/admin/system/dto/AgreementContentSaveRequest.java
New file
@@ -0,0 +1,13 @@
package com.tcm.training.admin.system.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class AgreementContentSaveRequest {
    private String contentHtml;
}
backend/src/main/java/com/tcm/training/admin/system/entity/AgreementContent.java
New file
@@ -0,0 +1,48 @@
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_agreement_content")
@Getter
@Setter
@NoArgsConstructor
public class AgreementContent {
    public static final String TYPE_USER_AGREEMENT = "USER_AGREEMENT";
    public static final String TYPE_PRIVACY_POLICY = "PRIVACY_POLICY";
    public static final String TYPE_INVITE_CODE_INSTRUCTIONS = "INVITE_CODE_INSTRUCTIONS";
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("agreement_type")
    private String agreementType;
    @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/AgreementContentMapper.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.AgreementContent;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgreementContentMapper extends BaseMapper<AgreementContent> {
}
backend/src/main/java/com/tcm/training/admin/system/service/AgreementContentAppService.java
New file
@@ -0,0 +1,137 @@
package com.tcm.training.admin.system.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.system.dto.AgreementContentSaveRequest;
import com.tcm.training.admin.system.entity.AgreementContent;
import com.tcm.training.admin.system.mapper.AgreementContentMapper;
import com.tcm.training.admin.system.vo.AgreementContentItemVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgreementContentAppService {
    private static final String[] AGREEMENT_TYPES = new String[]{
            AgreementContent.TYPE_USER_AGREEMENT,
            AgreementContent.TYPE_PRIVACY_POLICY,
            AgreementContent.TYPE_INVITE_CODE_INSTRUCTIONS
    };
    private final AgreementContentMapper agreementContentMapper;
    public AgreementContentAppService(AgreementContentMapper agreementContentMapper) {
        this.agreementContentMapper = agreementContentMapper;
    }
    public List<AgreementContentItemVO> list() {
        List<AgreementContent> records = agreementContentMapper.selectList(new LambdaQueryWrapper<AgreementContent>()
                .eq(AgreementContent::getDeleted, 0));
        List<AgreementContentItemVO> result = new ArrayList<AgreementContentItemVO>();
        for (String agreementType : AGREEMENT_TYPES) {
            result.add(toItemVO(agreementType, findByType(records, agreementType)));
        }
        return result;
    }
    @Transactional
    public AgreementContentItemVO save(String agreementType, AgreementContentSaveRequest request) {
        String normalizedType = normalizeAgreementType(agreementType);
        String contentHtml = normalizeContent(request == null ? null : request.getContentHtml());
        AgreementContent record = agreementContentMapper.selectOne(new LambdaQueryWrapper<AgreementContent>()
                .eq(AgreementContent::getAgreementType, normalizedType)
                .eq(AgreementContent::getDeleted, 0)
                .last("limit 1"));
        if (record == null) {
            record = new AgreementContent();
            record.setAgreementType(normalizedType);
            record.setContentHtml(contentHtml);
            record.setDeleted(0);
            agreementContentMapper.insert(record);
        } else {
            record.setContentHtml(contentHtml);
            agreementContentMapper.updateById(record);
        }
        return toItemVO(normalizedType, record);
    }
    private AgreementContent findByType(List<AgreementContent> records, String agreementType) {
        for (AgreementContent record : records) {
            if (agreementType.equals(record.getAgreementType())) {
                return record;
            }
        }
        return null;
    }
    private AgreementContentItemVO toItemVO(String agreementType, AgreementContent record) {
        AgreementContentItemVO vo = new AgreementContentItemVO();
        vo.setAgreementType(agreementType);
        vo.setAgreementName(resolveAgreementName(agreementType));
        vo.setContentHtml(record == null || record.getContentHtml() == null ? "" : record.getContentHtml());
        return vo;
    }
    private String normalizeAgreementType(String agreementType) {
        String normalized = trimToNull(agreementType);
        if (normalized == null) {
            throw new IllegalArgumentException("协议类型不合法");
        }
        normalized = normalized.toUpperCase();
        for (String item : AGREEMENT_TYPES) {
            if (item.equals(normalized)) {
                return normalized;
            }
        }
        throw new IllegalArgumentException("协议类型不合法");
    }
    private String resolveAgreementName(String agreementType) {
        if (AgreementContent.TYPE_USER_AGREEMENT.equals(agreementType)) {
            return "用户协议";
        }
        if (AgreementContent.TYPE_PRIVACY_POLICY.equals(agreementType)) {
            return "隐私协议";
        }
        if (AgreementContent.TYPE_INVITE_CODE_INSTRUCTIONS.equals(agreementType)) {
            return "获取邀请码说明";
        }
        throw new IllegalArgumentException("协议类型不合法");
    }
    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/AgreementContentItemVO.java
New file
@@ -0,0 +1,15 @@
package com.tcm.training.admin.system.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class AgreementContentItemVO {
    private String agreementType;
    private String agreementName;
    private String contentHtml;
}
backend/src/test/java/com/tcm/training/admin/system/service/AgreementContentAppServiceTest.java
New file
@@ -0,0 +1,123 @@
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.AgreementContentSaveRequest;
import com.tcm.training.admin.system.entity.AgreementContent;
import com.tcm.training.admin.system.mapper.AgreementContentMapper;
import com.tcm.training.admin.system.vo.AgreementContentItemVO;
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.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
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 AgreementContentAppServiceTest {
    @BeforeAll
    static void initLambdaCache() {
        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
        TableInfoHelper.initTableInfo(assistant, AgreementContent.class);
    }
    @Mock
    private AgreementContentMapper agreementContentMapper;
    @InjectMocks
    private AgreementContentAppService agreementContentAppService;
    @Captor
    private ArgumentCaptor<AgreementContent> agreementContentCaptor;
    @Test
    void listShouldAlwaysReturnThreeAgreements() {
        AgreementContent record = new AgreementContent();
        record.setAgreementType(AgreementContent.TYPE_PRIVACY_POLICY);
        record.setContentHtml("<p>隐私协议内容</p>");
        when(agreementContentMapper.selectList(any())).thenReturn(Collections.singletonList(record));
        List<AgreementContentItemVO> result = agreementContentAppService.list();
        assertEquals(3, result.size());
        assertEquals(AgreementContent.TYPE_USER_AGREEMENT, result.get(0).getAgreementType());
        assertEquals("", result.get(0).getContentHtml());
        assertEquals(AgreementContent.TYPE_PRIVACY_POLICY, result.get(1).getAgreementType());
        assertEquals("<p>隐私协议内容</p>", result.get(1).getContentHtml());
        assertEquals(AgreementContent.TYPE_INVITE_CODE_INSTRUCTIONS, result.get(2).getAgreementType());
    }
    @Test
    void saveShouldInsertWhenAgreementDoesNotExist() {
        when(agreementContentMapper.selectOne(any())).thenReturn(null);
        doAnswer(invocation -> {
            AgreementContent record = invocation.getArgument(0);
            record.setId(1L);
            return 1;
        }).when(agreementContentMapper).insert(any(AgreementContent.class));
        AgreementContentSaveRequest request = new AgreementContentSaveRequest();
        request.setContentHtml("<p>用户协议内容</p>");
        AgreementContentItemVO result = agreementContentAppService.save("user_agreement", request);
        verify(agreementContentMapper).insert(agreementContentCaptor.capture());
        assertEquals(AgreementContent.TYPE_USER_AGREEMENT, agreementContentCaptor.getValue().getAgreementType());
        assertEquals("<p>用户协议内容</p>", agreementContentCaptor.getValue().getContentHtml());
        assertEquals("用户协议", result.getAgreementName());
    }
    @Test
    void saveShouldUpdateWhenAgreementExists() {
        AgreementContent record = new AgreementContent();
        record.setId(2L);
        record.setAgreementType(AgreementContent.TYPE_PRIVACY_POLICY);
        record.setContentHtml("<p>旧内容</p>");
        when(agreementContentMapper.selectOne(any())).thenReturn(record);
        AgreementContentSaveRequest request = new AgreementContentSaveRequest();
        request.setContentHtml("<p>新内容</p>");
        agreementContentAppService.save(AgreementContent.TYPE_PRIVACY_POLICY, request);
        verify(agreementContentMapper).updateById(agreementContentCaptor.capture());
        assertEquals("<p>新内容</p>", agreementContentCaptor.getValue().getContentHtml());
    }
    @Test
    void saveShouldRejectBlankContent() {
        AgreementContentSaveRequest request = new AgreementContentSaveRequest();
        request.setContentHtml("<p><br/></p>");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> agreementContentAppService.save(AgreementContent.TYPE_USER_AGREEMENT, request));
        assertEquals("请输入协议内容", exception.getMessage());
    }
    @Test
    void saveShouldRejectIllegalAgreementType() {
        AgreementContentSaveRequest request = new AgreementContentSaveRequest();
        request.setContentHtml("<p>内容</p>");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> agreementContentAppService.save("UNKNOWN", request));
        assertEquals("协议类型不合法", exception.getMessage());
    }
}
tcm-training-admin/config/routes.ts
@@ -105,6 +105,12 @@
            component: './system/platform-qualification',
          },
          {
            name: '协议内容',
            path: '/system/settings/agreement-content',
            access: 'system:agreement-content:view',
            component: './system/agreement-content',
          },
          {
            name: '消息推送配置',
            path: '/system/settings/message-push',
            access: 'system:message-push:view',
tcm-training-admin/package.json
@@ -60,6 +60,7 @@
    "@types/history": "^4.7.11",
    "@types/jest": "^29.4.0",
    "@types/lodash": "^4.14.191",
    "@types/minimatch": "^5.1.2",
    "@types/react": "^18.0.28",
    "@types/react-dom": "^18.0.11",
    "@types/react-helmet": "^6.1.6",
@@ -74,7 +75,7 @@
    "prettier": "^2.8.4",
    "swagger-ui-dist": "^4.15.5",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.5",
    "typescript": "^5.3.3",
    "umi-presets-pro": "^2.0.2"
  },
  "engines": {
tcm-training-admin/src/access.js
@@ -19,6 +19,7 @@
      !!permission?.['system:banner:view'] ||
      !!permission?.['system:activity-notice:view'] ||
      !!permission?.['system:platform-qualification:view'] ||
      !!permission?.['system:agreement-content:view'] ||
      !!permission?.['system:message-push:view'] ||
      !!permission?.['system:points-settings:view'],
    canViewMemberModule: canViewMemberUserGroup || canViewMemberVipGroup,
tcm-training-admin/src/app.tsx
@@ -9,16 +9,22 @@
const loginPath = '/login';
import '../public/font.css'
type CurrentUser = {
  username?: string;
};
type InitialState = {
  settings?: Partial<LayoutSettings>;
  currentUser?: CurrentUser;
  permission?: Record<string, boolean>;
  loading?: boolean;
};
/**
 * @see  https://umijs.org/zh-CN/plugins/plugin-initial-state
 * */
export async function getInitialState(): Promise<{
  settings?: Partial<LayoutSettings>;
  currentUser?: {};
  permission?: Array<Permissions>;
  loading?: boolean;
}> {
export async function getInitialState(): Promise<InitialState> {
  // 如果不是登录页面,执行
  const { location } = history;
@@ -26,7 +32,7 @@
    history.replace(loginPath)
  }
  return {
    permission: decryptData(localStorage.getItem('access')) || [],
    permission: decryptData(localStorage.getItem('access')) || {},
    currentUser: decryptData(localStorage.getItem('userInfo')) || {},
    settings: { ...defaultSettings, fixedHeader: true } as Partial<LayoutSettings>,
  };
@@ -42,7 +48,7 @@
      },
    },
    waterMarkProps: {
      content: false,
      content: '',
    },
    // 自定义 403 页面
    unAccessible: <div></div>,
tcm-training-admin/src/components/BraftEditor/index.d.ts
New file
@@ -0,0 +1,11 @@
import type React from 'react';
export type BraftEditorProps = {
  data?: string;
  resultCallback: (html: string) => void;
  readOnly?: boolean;
};
declare const BraftEditorCom: React.FC<BraftEditorProps>;
export default BraftEditorCom;
tcm-training-admin/src/pages/Login/service.d.ts
New file
@@ -0,0 +1,23 @@
export type LoginResponse = {
  data: {
    tokenType: string;
    token: string;
    user: {
      username?: string;
      permissionKeys?: string[];
      [key: string]: unknown;
    };
  };
};
export type CaptchaResponse = {
  data: {
    captchaId: string;
    imageData: string;
  };
};
export function updatePwd(params: Record<string, unknown>): Promise<unknown>;
export function sendMessage(params: Record<string, unknown>): Promise<unknown>;
export function login(data: Record<string, unknown>): Promise<LoginResponse>;
export function getCaptcha(): Promise<CaptchaResponse>;
tcm-training-admin/src/pages/system/agreement-content/index.tsx
New file
@@ -0,0 +1,202 @@
import BraftEditorCom from '@/components/BraftEditor';
import { history, useAccess } from '@umijs/max';
import { Button, Form, Spin, message } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { getAgreementContentList, saveAgreementContent } from './service';
import './style.less';
type AgreementType = 'USER_AGREEMENT' | 'PRIVACY_POLICY' | 'INVITE_CODE_INSTRUCTIONS';
type AgreementItem = {
  agreementType: AgreementType;
  agreementName: string;
  contentHtml?: string;
};
type AgreementFormValues = {
  contentHtml?: string;
};
const AGREEMENT_OPTIONS: AgreementItem[] = [
  { agreementType: 'USER_AGREEMENT', agreementName: '用户协议', contentHtml: '' },
  { agreementType: 'PRIVACY_POLICY', agreementName: '隐私协议', contentHtml: '' },
  { agreementType: 'INVITE_CODE_INSTRUCTIONS', agreementName: '获取邀请码说明', contentHtml: '' },
];
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 listToMap = (list: AgreementItem[]) => {
  const initialMap: Record<AgreementType, string> = {
    USER_AGREEMENT: '',
    PRIVACY_POLICY: '',
    INVITE_CODE_INSTRUCTIONS: '',
  };
  list.forEach((item) => {
    initialMap[item.agreementType] = item.contentHtml || '';
  });
  return initialMap;
};
const AgreementContentPage: React.FC = () => {
  const access = useAccess();
  const [form] = Form.useForm<AgreementFormValues>();
  const [loading, setLoading] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [activeType, setActiveType] = useState<AgreementType>('USER_AGREEMENT');
  const [editorContent, setEditorContent] = useState('');
  const draftsRef = useRef<Record<AgreementType, string>>({
    USER_AGREEMENT: '',
    PRIVACY_POLICY: '',
    INVITE_CODE_INSTRUCTIONS: '',
  });
  const canView = access['system:agreement-content:view'];
  const canEdit = access['system:agreement-content:edit'];
  const activeLabel =
    AGREEMENT_OPTIONS.find((item) => item.agreementType === activeType)?.agreementName || '协议内容';
  const loadAgreementContents = async () => {
    setLoading(true);
    try {
      const res = await getAgreementContentList();
      const nextContents = listToMap(res.data || AGREEMENT_OPTIONS);
      draftsRef.current = nextContents;
      setEditorContent(nextContents[activeType] || '');
      form.setFieldsValue({ contentHtml: nextContents[activeType] || '' });
    } catch (error) {
      message.error((error as Error)?.message || '协议内容加载失败');
      draftsRef.current = listToMap(AGREEMENT_OPTIONS);
      setEditorContent('');
      form.setFieldsValue({ contentHtml: '' });
    } finally {
      setLoading(false);
    }
  };
  useEffect(() => {
    if (!canView) {
      history.replace('/Welcome');
      return;
    }
    loadAgreementContents();
  }, [canView]);
  const handleTabChange = (agreementType: AgreementType) => {
    draftsRef.current[activeType] = form.getFieldValue('contentHtml') || '';
    setActiveType(agreementType);
    const nextContent = draftsRef.current[agreementType] || '';
    setEditorContent(nextContent);
    form.setFieldsValue({
      contentHtml: nextContent,
    });
  };
  const handleEditorChange = (html: string) => {
    form.setFieldsValue({ contentHtml: html });
    draftsRef.current[activeType] = html;
  };
  const handleSave = async () => {
    if (!canEdit || submitting) {
      return;
    }
    const values = await form.validateFields();
    if (isBlankRichText(values.contentHtml)) {
      form.setFields([
        {
          name: 'contentHtml',
          errors: ['请输入协议内容'],
        },
      ]);
      return;
    }
    setSubmitting(true);
    try {
      const payload = {
        contentHtml: values.contentHtml?.trim(),
      };
      const res = await saveAgreementContent(activeType, payload);
      const savedContent = res.data?.contentHtml || payload.contentHtml || '';
      draftsRef.current[activeType] = savedContent;
      setEditorContent(savedContent);
      form.setFieldsValue({ contentHtml: savedContent });
      message.success('保存成功');
    } catch (error) {
      message.error((error as Error)?.message || `${activeLabel}保存失败,请稍后重试`);
    } finally {
      setSubmitting(false);
    }
  };
  return (
    <div className="agreement-content-page">
      <div className="agreement-content-card">
        <div className="agreement-content-card__title">协议内容</div>
        <div className="agreement-content-tabs">
          {AGREEMENT_OPTIONS.map((item) => (
            <button
              key={item.agreementType}
              type="button"
              className={`agreement-content-tabs__item${
                item.agreementType === activeType ? ' agreement-content-tabs__item--active' : ''
              }`}
              onClick={() => handleTabChange(item.agreementType)}
            >
              {item.agreementName}
            </button>
          ))}
        </div>
        <Spin spinning={loading}>
          <Form form={form} layout="vertical" disabled={loading || !canEdit}>
            <Form.Item
              name="contentHtml"
              rules={[
                {
                  validator: async (_, value) => {
                    if (isBlankRichText(value)) {
                      throw new Error('请输入协议内容');
                    }
                  },
                },
              ]}
            >
              <div className="agreement-content-editor">
                <BraftEditorCom
                  key={activeType}
                  data={editorContent}
                  readOnly={!canEdit}
                  resultCallback={handleEditorChange}
                />
              </div>
            </Form.Item>
          </Form>
        </Spin>
        <div className="agreement-content-footer">
          <Button type="primary" onClick={handleSave} loading={submitting} disabled={!canEdit}>
            保存
          </Button>
        </div>
      </div>
    </div>
  );
};
export default AgreementContentPage;
tcm-training-admin/src/pages/system/agreement-content/service.ts
New file
@@ -0,0 +1,17 @@
import { request } from '@umijs/max';
export async function getAgreementContentList() {
  return request('/agreement-contents', {
    method: 'GET',
  });
}
export async function saveAgreementContent(
  agreementType: string,
  data: Record<string, any>,
) {
  return request(`/agreement-contents/${agreementType}`, {
    method: 'PUT',
    data,
  });
}
tcm-training-admin/src/pages/system/agreement-content/style.less
New file
@@ -0,0 +1,84 @@
.agreement-content-page {
  min-height: calc(100vh - 48px);
  padding: 18px 20px 24px;
  background: #f2f5f9;
}
.agreement-content-card {
  min-height: 700px;
  padding: 18px 18px 24px;
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.agreement-content-card__title {
  margin: 0 0 22px;
  color: #303133;
  font-weight: 500;
  font-size: 18px;
}
.agreement-content-tabs {
  display: inline-flex;
  margin-bottom: 28px;
  overflow: hidden;
  border: 1px solid #2f86f6;
  border-radius: 4px;
}
.agreement-content-tabs__item {
  min-width: 166px;
  height: 32px;
  color: #2f86f6;
  background: #fff;
  border: none;
  border-right: 1px solid #2f86f6;
  cursor: pointer;
}
.agreement-content-tabs__item:last-child {
  border-right: none;
}
.agreement-content-tabs__item--active {
  color: #fff;
  background: #2f86f6;
}
.agreement-content-editor {
  margin-bottom: 28px;
  border: 1px solid #dcdfe6;
}
.agreement-content-editor .DraftEditor-root {
  min-height: 420px;
}
.agreement-content-footer {
  display: flex;
  justify-content: flex-start;
}
@media (max-width: 900px) {
  .agreement-content-page {
    padding: 12px;
  }
  .agreement-content-card {
    min-height: auto;
    padding: 16px;
  }
  .agreement-content-tabs {
    display: flex;
    width: 100%;
  }
  .agreement-content-tabs__item {
    flex: 1;
    min-width: 0;
    padding: 0 8px;
    font-size: 12px;
  }
}
tcm-training-admin/src/pages/system/settings-redirect/index.tsx
@@ -25,6 +25,10 @@
      history.replace('/system/settings/platform-qualification');
      return;
    }
    if (access['system:agreement-content:view']) {
      history.replace('/system/settings/agreement-content');
      return;
    }
    if (access['system:message-push:view']) {
      history.replace('/system/settings/message-push');
      return;
tcm-training-admin/src/services/ant-design-pro/api.d.ts
New file
@@ -0,0 +1 @@
export function outLogin(): Promise<unknown>;
tcm-training-admin/src/utils/utils.d.ts
New file
@@ -0,0 +1,10 @@
export function customRequest(params: Record<string, unknown>, type?: number): void;
export function exportExcell(
  name: string,
  params: Record<string, unknown>,
  url: string,
): void;
export function downLoad(url: string, name: string): void;
export function downloadFile(url: string, fileName: string): Promise<void> | void;
export function encryptData(data: unknown): string;
export function decryptData(cipherText?: string | null): any;
tcm-training-admin/typings.d.ts
New file
@@ -0,0 +1 @@
declare var BASE_URL: string;