From 1e6eb9f248d37a880713dd4e41e33a2f6ff180ee Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期四, 26 三月 2026 18:12:35 +0800
Subject: [PATCH] feat:系统与安全:系统设置、APP版本设置、推送配置、Banner配置、健康科普、活动通知
---
backend/sql/init.sql | 145 +
backend/src/main/java/com/tcm/training/admin/system/controller/AppVersionSettingsController.java | 55
backend/src/main/java/com/tcm/training/admin/system/dto/ActivityNoticeCreateRequest.java | 21
tcm-training-admin/src/pages/system/message-push-settings/style.less | 72
tcm-training-admin/src/pages/system/activity-notice/service.ts | 21
backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionDetailVO.java | 24
tcm-training-admin/src/pages/system/banner-settings/service.ts | 74
tcm-training-admin/src/pages/system/message-push-settings/index.tsx | 151 +
backend/src/main/java/com/tcm/training/admin/system/vo/ActivityNoticeListItemVO.java | 17
backend/src/main/java/com/tcm/training/admin/upload/UploadController.java | 69
backend/sql/update_activity_notice_schema.sql | 24
backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerBatchDeleteRequest.java | 18
backend/src/main/java/com/tcm/training/admin/system/mapper/SystemSettingsMapper.java | 9
backend/sql/update_message_push_settings_schema.sql | 35
backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java | 84
tcm-training-admin/src/pages/system/activity-notice/index.tsx | 287 +++
tcm-training-admin/src/pages/system/banner-settings/style.less | 254 ++
backend/src/main/java/com/tcm/training/admin/system/controller/SystemSettingsController.java | 36
tcm-training-admin/src/pages/system/message-push-settings/service.ts | 14
backend/src/main/java/com/tcm/training/admin/system/entity/SystemBannerConfig.java | 74
backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeAppService.java | 116 +
backend/src/main/java/com/tcm/training/admin/system/dto/AppVersionCreateRequest.java | 42
backend/src/main/java/com/tcm/training/admin/system/service/SystemBannerAppService.java | 313 +++
backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerDetailVO.java | 27
tcm-training-admin/src/pages/system/basic-settings/service.ts | 14
backend/sql/upgrade_rbac.sql | 138 +
tcm-training-admin/src/pages/system/app-version-settings/style.less | 153 +
backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerSaveRequest.java | 45
backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerPublishStatusRequest.java | 16
backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerCourseOptionVO.java | 19
tcm-training-admin/src/pages/system/app-version-settings/index.tsx | 392 ++++
backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerPublishStatusVO.java | 19
backend/src/main/java/com/tcm/training/admin/system/mapper/MessagePushConfigMapper.java | 9
tcm-training-admin/src/pages/system/activity-notice/style.less | 123 +
backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java | 17
backend/src/main/java/com/tcm/training/admin/system/service/AppVersionSettingsAppService.java | 154 +
backend/src/main/java/com/tcm/training/admin/system/mapper/SystemBannerConfigMapper.java | 9
backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionListItemVO.java | 18
backend/src/main/java/com/tcm/training/admin/system/entity/ActivityNotice.java | 44
backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingItemSaveRequest.java | 25
backend/src/main/java/com/tcm/training/admin/system/controller/ActivityNoticeController.java | 49
backend/src/main/java/com/tcm/training/admin/system/entity/MessagePushConfig.java | 50
backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerListItemVO.java | 24
backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionCheckVO.java | 18
tcm-training-admin/src/pages/system/basic-settings/style.less | 129 +
backend/sql/update_system_settings_schema.sql | 29
backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java | 32
tcm-training-admin/src/pages/system/banner-settings/index.tsx | 711 +++++++
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java | 34
backend/src/main/java/com/tcm/training/admin/system/entity/AppVersionConfig.java | 65
tcm-training-admin/src/pages/system/basic-settings/index.tsx | 201 ++
tcm-training-admin/src/pages/system/settings-redirect/index.tsx | 34
tcm-training-admin/src/access.js | 5
backend/src/main/java/com/tcm/training/admin/system/controller/MessagePushSettingsController.java | 37
backend/sql/update_app_version_settings_schema.sql | 37
backend/src/main/java/com/tcm/training/admin/system/mapper/ActivityNoticeMapper.java | 9
tcm-training-admin/config/routes.ts | 33
backend/src/main/java/com/tcm/training/admin/system/controller/SystemBannerController.java | 91 +
backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java | 64
backend/src/test/java/com/tcm/training/admin/system/service/AppVersionSettingsAppServiceTest.java | 105 +
backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java | 50
backend/sql/update_system_banner_schema.sql | 43
backend/src/main/java/com/tcm/training/admin/system/mapper/AppVersionConfigMapper.java | 9
backend/pom.xml | 5
tcm-training-admin/src/pages/system/app-version-settings/service.ts | 31
backend/src/main/java/com/tcm/training/admin/system/vo/MessagePushSettingVO.java | 20
backend/src/main/java/com/tcm/training/admin/system/service/MessagePushSettingsAppService.java | 190 ++
backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingsSaveRequest.java | 19
backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java | 7
69 files changed, 5,298 insertions(+), 10 deletions(-)
diff --git a/backend/pom.xml b/backend/pom.xml
index 5603236..8f946f6 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -105,6 +105,11 @@
<version>3.25.10</version>
<scope>compile</scope>
</dependency>
+ <dependency>
+ <groupId>io.github.jpush</groupId>
+ <artifactId>jiguang-sdk</artifactId>
+ <version>5.3.0</version>
+ </dependency>
</dependencies>
<build>
diff --git a/backend/sql/init.sql b/backend/sql/init.sql
index bd5d041..da38d93 100644
--- a/backend/sql/init.sql
+++ b/backend/sql/init.sql
@@ -101,6 +101,23 @@
(1203, 1200, '编辑角色', 'system_role_edit', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:role:edit', '角色编辑按钮', 0),
(1204, 1200, '删除角色', 'system_role_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:role:delete', '角色删除按钮', 0),
(1300, 1000, '系统设置', 'system_setting_group', 'M', '/system/settings', NULL, NULL, 30, 1, 1, NULL, '系统设置目录', 0),
+(1305, 1300, '系统设置', 'system_basic_settings', 'C', '/system/settings/basic', 'System/BasicSettings', NULL, 5, 1, 1, 'system:basic-settings:view', '系统设置菜单', 0),
+(1306, 1305, '编辑系统设置', 'system_basic_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:basic-settings:edit', '系统设置编辑按钮', 0),
+(1307, 1300, 'APP版本设置', 'system_app_version_settings', 'C', '/system/settings/app-version', 'System/AppVersionSettings', NULL, 15, 1, 1, 'system:app-version:view', 'APP版本设置菜单', 0),
+(1308, 1307, '新增APP版本', 'system_app_version_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:app-version:add', '新增APP版本按钮', 0),
+(1309, 1307, '查看APP版本', 'system_app_version_settings_detail', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:app-version:detail', '查看APP版本按钮', 0),
+(1312, 1300, '消息推送配置', 'system_message_push_settings', 'C', '/system/settings/message-push', 'System/MessagePushSettings', NULL, 20, 1, 1, 'system:message-push:view', '消息推送配置菜单', 0),
+(1313, 1312, '编辑消息推送配置', 'system_message_push_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:message-push:edit', '消息推送配置编辑按钮', 0),
+(1314, 1300, 'Banner配置', 'system_banner_settings', 'C', '/system/settings/banner', 'System/BannerSettings', NULL, 12, 1, 1, 'system:banner:view', 'Banner配置菜单', 0),
+(1315, 1314, '新增Banner', 'system_banner_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:banner:add', 'Banner新增按钮', 0),
+(1316, 1314, '编辑Banner', 'system_banner_settings_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:banner:edit', 'Banner编辑按钮', 0),
+(1317, 1314, '查看Banner', 'system_banner_settings_detail', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:banner:detail', 'Banner查看按钮', 0),
+(1318, 1314, '删除Banner', 'system_banner_settings_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:banner:delete', 'Banner删除按钮', 0),
+(1319, 1314, '批量删除Banner', 'system_banner_settings_batch_delete', 'B', NULL, NULL, NULL, 5, 1, 1, 'system:banner:batch-delete', 'Banner批量删除按钮', 0),
+(1320, 1314, '切换Banner上架状态', 'system_banner_settings_publish_status', 'B', NULL, NULL, NULL, 6, 1, 1, 'system:banner:publish-status', 'Banner上架状态按钮', 0),
+(1321, 1300, '活动通知', 'system_activity_notice', 'C', '/system/settings/activity-notice', 'System/ActivityNotice', NULL, 13, 1, 1, 'system:activity-notice:view', '活动通知菜单', 0),
+(1322, 1321, '新增活动通知', 'system_activity_notice_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:activity-notice:add', '活动通知新增按钮', 0),
+(1323, 1321, '删除活动通知', 'system_activity_notice_delete', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:activity-notice: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),
@@ -186,7 +203,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, 1310), (1, 1311),
+(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, 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),
@@ -773,6 +790,132 @@
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场次答题记录表';
-- ----------------------------
+-- 系统设置
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_system_settings` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `service_phone` VARCHAR(32) NOT NULL COMMENT '客服电话',
+ `service_online_time` VARCHAR(64) NOT NULL COMMENT '客服在线时间',
+ `backup_cycle_days` INT NOT NULL COMMENT '备份周期(天)',
+ `member_expire_remind_days` INT NOT 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`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统设置表';
+
+INSERT IGNORE INTO `tcm_system_settings`
+(`id`, `service_phone`, `service_online_time`, `backup_cycle_days`, `member_expire_remind_days`, `deleted`)
+VALUES
+(1, '400-800-1234', '09:00-18:00', 7, 3, 0);
+
+-- ----------------------------
+-- APP版本设置
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_app_version_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `version_no` VARCHAR(32) NOT NULL COMMENT '展示版本号',
+ `version_code` INT NOT NULL COMMENT '版本字段',
+ `android_file_url` VARCHAR(512) NOT NULL COMMENT '安卓文件地址',
+ `android_file_name` VARCHAR(255) NOT NULL COMMENT '安卓文件名称',
+ `ios_file_url` VARCHAR(512) NOT NULL COMMENT 'iOS文件地址',
+ `ios_file_name` VARCHAR(255) NOT NULL COMMENT 'iOS文件名称',
+ `force_update` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制更新:1是 0否',
+ `update_notice` TEXT NOT 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_app_version_code` (`version_code`),
+ KEY `idx_app_version_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='APP版本设置表';
+
+INSERT IGNORE INTO `tcm_app_version_config`
+(`id`, `version_no`, `version_code`, `android_file_url`, `android_file_name`, `ios_file_url`, `ios_file_name`, `force_update`, `update_notice`, `deleted`)
+VALUES
+(1, '1.0.0', 1, 'https://example.com/app/android/1.0.0.apk', 'app-1.0.0.apk', 'https://example.com/app/ios/1.0.0.ipa', 'app-1.0.0.ipa', 0, '反馈内容反馈内容反馈内容反馈内容反馈内容', 0),
+(2, '1.0.1', 2, 'https://example.com/app/android/1.0.1.apk', 'app-1.0.1.apk', 'https://example.com/app/ios/1.0.1.ipa', 'app-1.0.1.ipa', 1, '反馈内容反馈内容反馈内容反馈内容反馈内容反馈内容反馈内容', 0);
+
+-- ----------------------------
+-- 消息推送配置
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_message_push_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `notice_type_code` VARCHAR(64) NOT NULL COMMENT '通知类型编码',
+ `notice_type_name` VARCHAR(64) NOT NULL COMMENT '通知类型名称',
+ `push_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启PUSH推送:1是 0否',
+ `sms_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启短信通知:1是 0否',
+ `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_message_push_notice_type_code` (`notice_type_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送配置表';
+
+INSERT IGNORE INTO `tcm_message_push_config`
+(`id`, `notice_type_code`, `notice_type_name`, `push_enabled`, `sms_enabled`, `deleted`)
+VALUES
+(1, 'MEMBER_EXPIRE_REMIND', '会员到期提醒', 1, 1, 0),
+(2, 'LIVE_REMIND', '直播提醒', 1, 1, 0),
+(3, 'ACTIVITY_NOTICE', '活动通知', 1, 0, 0),
+(4, 'ORDER_STATUS_UPDATE', '订单状态更新', 1, 0, 0),
+(5, 'COURSE_UPDATE_NOTICE', '课程更新通知', 1, 0, 0),
+(6, 'STUDY_PROGRESS_REMIND', '学习进度提醒', 1, 0, 0);
+
+-- ----------------------------
+-- Banner配置
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_system_banner_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `banner_img` VARCHAR(512) NOT NULL COMMENT 'Banner图片',
+ `title` VARCHAR(64) NOT NULL COMMENT 'Banner标题',
+ `start_at` DATETIME NOT NULL COMMENT '展示开始时间',
+ `end_at` DATETIME NOT NULL COMMENT '展示结束时间',
+ `jump_type` VARCHAR(32) NOT NULL COMMENT '跳转类型',
+ `target_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '跳转目标ID',
+ `target_name` VARCHAR(64) DEFAULT NULL COMMENT '跳转目标名称',
+ `target_url` VARCHAR(512) DEFAULT NULL COMMENT '三方地址',
+ `publish_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否上架:1是 0否',
+ `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_system_banner_time` (`start_at`, `end_at`),
+ KEY `idx_system_banner_jump_type` (`jump_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Banner配置表';
+
+INSERT IGNORE INTO `tcm_system_banner_config`
+(`id`, `banner_img`, `title`, `start_at`, `end_at`, `jump_type`, `target_id`, `target_name`, `target_url`, `publish_enabled`, `deleted`)
+VALUES
+(1, 'https://example.com/banner/member-discount.png', '会员显示折扣', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'MEMBER', NULL, '会员', NULL, 1, 0),
+(2, 'https://example.com/banner/health-course.png', '兑换商城优惠券更新', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'HEALTH_COURSE', 1, '养生课程1', NULL, 1, 0),
+(3, 'https://example.com/banner/third-party.png', '标题标题题', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'THIRD_PARTY_URL', NULL, NULL, 'https://www.baidu.com', 0, 0);
+
+-- ----------------------------
+-- 活动通知
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_activity_notice` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` VARCHAR(15) NOT NULL COMMENT '通知标题',
+ `content_html` LONGTEXT NOT 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_activity_notice_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动通知表';
+
+-- ----------------------------
-- 积分设置
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_points_config` (
diff --git a/backend/sql/update_activity_notice_schema.sql b/backend/sql/update_activity_notice_schema.sql
new file mode 100644
index 0000000..2ed896e
--- /dev/null
+++ b/backend/sql/update_activity_notice_schema.sql
@@ -0,0 +1,24 @@
+CREATE TABLE IF NOT EXISTS `tcm_activity_notice` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` VARCHAR(15) NOT NULL COMMENT '通知标题',
+ `content_html` LONGTEXT NOT 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_activity_notice_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),
+(1321, 1300, '活动通知', 'system_activity_notice', 'C', '/system/settings/activity-notice', 'System/ActivityNotice', NULL, 13, 1, 1, 'system:activity-notice:view', '活动通知菜单', 0),
+(1322, 1321, '新增活动通知', 'system_activity_notice_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:activity-notice:add', '活动通知新增按钮', 0),
+(1323, 1321, '删除活动通知', 'system_activity_notice_delete', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:activity-notice:delete', '活动通知删除按钮', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1321), (1, 1322), (1, 1323);
diff --git a/backend/sql/update_app_version_settings_schema.sql b/backend/sql/update_app_version_settings_schema.sql
new file mode 100644
index 0000000..bae465b
--- /dev/null
+++ b/backend/sql/update_app_version_settings_schema.sql
@@ -0,0 +1,37 @@
+CREATE TABLE IF NOT EXISTS `tcm_app_version_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `version_no` VARCHAR(32) NOT NULL COMMENT '展示版本号',
+ `version_code` INT NOT NULL COMMENT '版本字段',
+ `android_file_url` VARCHAR(512) NOT NULL COMMENT '安卓文件地址',
+ `android_file_name` VARCHAR(255) NOT NULL COMMENT '安卓文件名称',
+ `ios_file_url` VARCHAR(512) NOT NULL COMMENT 'iOS文件地址',
+ `ios_file_name` VARCHAR(255) NOT NULL COMMENT 'iOS文件名称',
+ `force_update` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制更新:1是 0否',
+ `update_notice` TEXT NOT 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_app_version_code` (`version_code`),
+ KEY `idx_app_version_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='APP版本设置表';
+
+INSERT IGNORE INTO `tcm_app_version_config`
+(`id`, `version_no`, `version_code`, `android_file_url`, `android_file_name`, `ios_file_url`, `ios_file_name`, `force_update`, `update_notice`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, '1.0.0', 1, 'https://example.com/app/android/1.0.0.apk', 'app-1.0.0.apk', 'https://example.com/app/ios/1.0.0.ipa', 'app-1.0.0.ipa', 0, '反馈内容反馈内容反馈内容反馈内容反馈内容', 0, '系统初始化', '系统初始化'),
+(2, '1.0.1', 2, 'https://example.com/app/android/1.0.1.apk', 'app-1.0.1.apk', 'https://example.com/app/ios/1.0.1.ipa', 'app-1.0.1.ipa', 1, '反馈内容反馈内容反馈内容反馈内容反馈内容反馈内容反馈内容', 0, '系统初始化', '系统初始化');
+
+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),
+(1307, 1300, 'APP版本设置', 'system_app_version_settings', 'C', '/system/settings/app-version', 'System/AppVersionSettings', NULL, 15, 1, 1, 'system:app-version:view', 'APP版本设置菜单', 0),
+(1308, 1307, '新增APP版本', 'system_app_version_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:app-version:add', '新增APP版本按钮', 0),
+(1309, 1307, '查看APP版本', 'system_app_version_settings_detail', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:app-version:detail', '查看APP版本按钮', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1307), (1, 1308), (1, 1309);
diff --git a/backend/sql/update_message_push_settings_schema.sql b/backend/sql/update_message_push_settings_schema.sql
new file mode 100644
index 0000000..f4bcd5c
--- /dev/null
+++ b/backend/sql/update_message_push_settings_schema.sql
@@ -0,0 +1,35 @@
+CREATE TABLE IF NOT EXISTS `tcm_message_push_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `notice_type_code` VARCHAR(64) NOT NULL COMMENT '通知类型编码',
+ `notice_type_name` VARCHAR(64) NOT NULL COMMENT '通知类型名称',
+ `push_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启PUSH推送:1是 0否',
+ `sms_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启短信通知:1是 0否',
+ `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_message_push_notice_type_code` (`notice_type_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送配置表';
+
+INSERT IGNORE INTO `tcm_message_push_config`
+(`id`, `notice_type_code`, `notice_type_name`, `push_enabled`, `sms_enabled`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, 'MEMBER_EXPIRE_REMIND', '会员到期提醒', 1, 1, 0, '系统初始化', '系统初始化'),
+(2, 'LIVE_REMIND', '直播提醒', 1, 1, 0, '系统初始化', '系统初始化'),
+(3, 'ACTIVITY_NOTICE', '活动通知', 1, 0, 0, '系统初始化', '系统初始化'),
+(4, 'ORDER_STATUS_UPDATE', '订单状态更新', 1, 0, 0, '系统初始化', '系统初始化'),
+(5, 'COURSE_UPDATE_NOTICE', '课程更新通知', 1, 0, 0, '系统初始化', '系统初始化'),
+(6, 'STUDY_PROGRESS_REMIND', '学习进度提醒', 1, 0, 0, '系统初始化', '系统初始化');
+
+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),
+(1312, 1300, '消息推送配置', 'system_message_push_settings', 'C', '/system/settings/message-push', 'System/MessagePushSettings', NULL, 20, 1, 1, 'system:message-push:view', '消息推送配置菜单', 0),
+(1313, 1312, '编辑消息推送配置', 'system_message_push_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:message-push:edit', '消息推送配置编辑按钮', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1312), (1, 1313);
diff --git a/backend/sql/update_system_banner_schema.sql b/backend/sql/update_system_banner_schema.sql
new file mode 100644
index 0000000..6de2351
--- /dev/null
+++ b/backend/sql/update_system_banner_schema.sql
@@ -0,0 +1,43 @@
+CREATE TABLE IF NOT EXISTS `tcm_system_banner_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `banner_img` VARCHAR(512) NOT NULL COMMENT 'Banner图片',
+ `title` VARCHAR(64) NOT NULL COMMENT 'Banner标题',
+ `start_at` DATETIME NOT NULL COMMENT '展示开始时间',
+ `end_at` DATETIME NOT NULL COMMENT '展示结束时间',
+ `jump_type` VARCHAR(32) NOT NULL COMMENT '跳转类型',
+ `target_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '跳转目标ID',
+ `target_name` VARCHAR(64) DEFAULT NULL COMMENT '跳转目标名称',
+ `target_url` VARCHAR(512) DEFAULT NULL COMMENT '三方地址',
+ `publish_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否上架:1是 0否',
+ `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_system_banner_time` (`start_at`, `end_at`),
+ KEY `idx_system_banner_jump_type` (`jump_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Banner配置表';
+
+INSERT IGNORE INTO `tcm_system_banner_config`
+(`id`, `banner_img`, `title`, `start_at`, `end_at`, `jump_type`, `target_id`, `target_name`, `target_url`, `publish_enabled`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, 'https://example.com/banner/member-discount.png', '会员显示折扣', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'MEMBER', NULL, '会员', NULL, 1, 0, '系统初始化', '系统初始化'),
+(2, 'https://example.com/banner/health-course.png', '兑换商城优惠券更新', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'HEALTH_COURSE', 1, '养生课程1', NULL, 1, 0, '系统初始化', '系统初始化'),
+(3, 'https://example.com/banner/third-party.png', '标题标题题', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'THIRD_PARTY_URL', NULL, NULL, 'https://www.baidu.com', 0, 0, '系统初始化', '系统初始化');
+
+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),
+(1314, 1300, 'Banner配置', 'system_banner_settings', 'C', '/system/settings/banner', 'System/BannerSettings', NULL, 12, 1, 1, 'system:banner:view', 'Banner配置菜单', 0),
+(1315, 1314, '新增Banner', 'system_banner_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:banner:add', 'Banner新增按钮', 0),
+(1316, 1314, '编辑Banner', 'system_banner_settings_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:banner:edit', 'Banner编辑按钮', 0),
+(1317, 1314, '查看Banner', 'system_banner_settings_detail', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:banner:detail', 'Banner查看按钮', 0),
+(1318, 1314, '删除Banner', 'system_banner_settings_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:banner:delete', 'Banner删除按钮', 0),
+(1319, 1314, '批量删除Banner', 'system_banner_settings_batch_delete', 'B', NULL, NULL, NULL, 5, 1, 1, 'system:banner:batch-delete', 'Banner批量删除按钮', 0),
+(1320, 1314, '切换Banner上架状态', 'system_banner_settings_publish_status', 'B', NULL, NULL, NULL, 6, 1, 1, 'system:banner:publish-status', 'Banner上架状态按钮', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320);
diff --git a/backend/sql/update_system_settings_schema.sql b/backend/sql/update_system_settings_schema.sql
new file mode 100644
index 0000000..5345e95
--- /dev/null
+++ b/backend/sql/update_system_settings_schema.sql
@@ -0,0 +1,29 @@
+CREATE TABLE IF NOT EXISTS `tcm_system_settings` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `service_phone` VARCHAR(32) NOT NULL COMMENT '客服电话',
+ `service_online_time` VARCHAR(64) NOT NULL COMMENT '客服在线时间',
+ `backup_cycle_days` INT NOT NULL COMMENT '备份周期(天)',
+ `member_expire_remind_days` INT NOT 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`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统设置表';
+
+INSERT IGNORE INTO `tcm_system_settings`
+(`id`, `service_phone`, `service_online_time`, `backup_cycle_days`, `member_expire_remind_days`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, '400-800-1234', '09:00-18:00', 7, 3, 0, '系统初始化', '系统初始化');
+
+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),
+(1305, 1300, '系统设置', 'system_basic_settings', 'C', '/system/settings/basic', 'System/BasicSettings', NULL, 5, 1, 1, 'system:basic-settings:view', '系统设置菜单', 0),
+(1306, 1305, '编辑系统设置', 'system_basic_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:basic-settings:edit', '系统设置编辑按钮', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1305), (1, 1306);
diff --git a/backend/sql/upgrade_rbac.sql b/backend/sql/upgrade_rbac.sql
index fdd8688..7c15b9b 100644
--- a/backend/sql/upgrade_rbac.sql
+++ b/backend/sql/upgrade_rbac.sql
@@ -238,6 +238,21 @@
(1202, 1200, '新增角色', 'system_role_add', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:role:add', '角色新增按钮', 0),
(1203, 1200, '编辑角色', 'system_role_edit', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:role:edit', '角色编辑按钮', 0),
(1204, 1200, '删除角色', 'system_role_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:role:delete', '角色删除按钮', 0),
+(1300, 1000, '系统设置', 'system_setting_group', 'M', '/system/settings', NULL, NULL, 30, 1, 1, NULL, '系统设置目录', 0),
+(1305, 1300, '系统设置', 'system_basic_settings', 'C', '/system/settings/basic', 'System/BasicSettings', NULL, 5, 1, 1, 'system:basic-settings:view', '系统设置菜单', 0),
+(1306, 1305, '编辑系统设置', 'system_basic_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:basic-settings:edit', '系统设置编辑按钮', 0),
+(1307, 1300, 'APP版本设置', 'system_app_version_settings', 'C', '/system/settings/app-version', 'System/AppVersionSettings', NULL, 15, 1, 1, 'system:app-version:view', 'APP版本设置菜单', 0),
+(1308, 1307, '新增APP版本', 'system_app_version_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:app-version:add', '新增APP版本按钮', 0),
+(1309, 1307, '查看APP版本', 'system_app_version_settings_detail', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:app-version:detail', '查看APP版本按钮', 0),
+(1312, 1300, '消息推送配置', 'system_message_push_settings', 'C', '/system/settings/message-push', 'System/MessagePushSettings', NULL, 20, 1, 1, 'system:message-push:view', '消息推送配置菜单', 0),
+(1313, 1312, '编辑消息推送配置', 'system_message_push_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:message-push:edit', '消息推送配置编辑按钮', 0),
+(1314, 1300, 'Banner配置', 'system_banner_settings', 'C', '/system/settings/banner', 'System/BannerSettings', NULL, 12, 1, 1, 'system:banner:view', 'Banner配置菜单', 0),
+(1315, 1314, '新增Banner', 'system_banner_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:banner:add', 'Banner新增按钮', 0),
+(1316, 1314, '编辑Banner', 'system_banner_settings_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:banner:edit', 'Banner编辑按钮', 0),
+(1317, 1314, '查看Banner', 'system_banner_settings_detail', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:banner:detail', 'Banner查看按钮', 0),
+(1318, 1314, '删除Banner', 'system_banner_settings_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:banner:delete', 'Banner删除按钮', 0),
+(1319, 1314, '批量删除Banner', 'system_banner_settings_batch_delete', 'B', NULL, NULL, NULL, 5, 1, 1, 'system:banner:batch-delete', 'Banner批量删除按钮', 0),
+(1320, 1314, '切换Banner上架状态', 'system_banner_settings_publish_status', 'B', NULL, NULL, NULL, 6, 1, 1, 'system:banner:publish-status', 'Banner上架状态按钮', 0),
(2000, 0, '用户与会员', 'member_root', 'M', '/member', NULL, 'team', 200, 1, 1, NULL, '用户与会员模块', 0),
(2100, 2000, '用户', 'member_user_group', 'M', '/member/user', NULL, NULL, 10, 1, 1, NULL, '用户分组菜单', 0),
(2110, 2100, '用户管理', 'member_user_manage', 'C', '/member/user/manage', 'Member/UserList', NULL, 10, 1, 1, 'member:user:view', '用户管理菜单', 0),
@@ -281,7 +296,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, 1200), (1, 1201), (1, 1202), (1, 1203), (1, 1204), (1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309),
(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),
@@ -713,3 +728,124 @@
SELECT `id`, 1
FROM `sys_user`
WHERE `username` = 'admin';
+
+CREATE TABLE IF NOT EXISTS `tcm_system_settings` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `service_phone` VARCHAR(32) NOT NULL COMMENT '客服电话',
+ `service_online_time` VARCHAR(64) NOT NULL COMMENT '客服在线时间',
+ `backup_cycle_days` INT NOT NULL COMMENT '备份周期(天)',
+ `member_expire_remind_days` INT NOT 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`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统设置表';
+
+INSERT IGNORE INTO `tcm_system_settings`
+(`id`, `service_phone`, `service_online_time`, `backup_cycle_days`, `member_expire_remind_days`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, '400-800-1234', '09:00-18:00', 7, 3, 0, '系统初始化', '系统初始化');
+
+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),
+(1305, 1300, '系统设置', 'system_basic_settings', 'C', '/system/settings/basic', 'System/BasicSettings', NULL, 5, 1, 1, 'system:basic-settings:view', '系统设置菜单', 0),
+(1306, 1305, '编辑系统设置', 'system_basic_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:basic-settings:edit', '系统设置编辑按钮', 0),
+(1307, 1300, 'APP版本设置', 'system_app_version_settings', 'C', '/system/settings/app-version', 'System/AppVersionSettings', NULL, 15, 1, 1, 'system:app-version:view', 'APP版本设置菜单', 0),
+(1308, 1307, '新增APP版本', 'system_app_version_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:app-version:add', '新增APP版本按钮', 0),
+(1309, 1307, '查看APP版本', 'system_app_version_settings_detail', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:app-version:detail', '查看APP版本按钮', 0),
+(1312, 1300, '消息推送配置', 'system_message_push_settings', 'C', '/system/settings/message-push', 'System/MessagePushSettings', NULL, 20, 1, 1, 'system:message-push:view', '消息推送配置菜单', 0),
+(1313, 1312, '编辑消息推送配置', 'system_message_push_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:message-push:edit', '消息推送配置编辑按钮', 0),
+(1314, 1300, 'Banner配置', 'system_banner_settings', 'C', '/system/settings/banner', 'System/BannerSettings', NULL, 12, 1, 1, 'system:banner:view', 'Banner配置菜单', 0),
+(1315, 1314, '新增Banner', 'system_banner_settings_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:banner:add', 'Banner新增按钮', 0),
+(1316, 1314, '编辑Banner', 'system_banner_settings_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'system:banner:edit', 'Banner编辑按钮', 0),
+(1317, 1314, '查看Banner', 'system_banner_settings_detail', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:banner:detail', 'Banner查看按钮', 0),
+(1318, 1314, '删除Banner', 'system_banner_settings_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:banner:delete', 'Banner删除按钮', 0),
+(1319, 1314, '批量删除Banner', 'system_banner_settings_batch_delete', 'B', NULL, NULL, NULL, 5, 1, 1, 'system:banner:batch-delete', 'Banner批量删除按钮', 0),
+(1320, 1314, '切换Banner上架状态', 'system_banner_settings_publish_status', 'B', NULL, NULL, NULL, 6, 1, 1, 'system:banner:publish-status', 'Banner上架状态按钮', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309), (1, 1312), (1, 1313), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320);
+
+CREATE TABLE IF NOT EXISTS `tcm_app_version_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `version_no` VARCHAR(32) NOT NULL COMMENT '展示版本号',
+ `version_code` INT NOT NULL COMMENT '版本字段',
+ `android_file_url` VARCHAR(512) NOT NULL COMMENT '安卓文件地址',
+ `android_file_name` VARCHAR(255) NOT NULL COMMENT '安卓文件名称',
+ `ios_file_url` VARCHAR(512) NOT NULL COMMENT 'iOS文件地址',
+ `ios_file_name` VARCHAR(255) NOT NULL COMMENT 'iOS文件名称',
+ `force_update` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制更新:1是 0否',
+ `update_notice` TEXT NOT 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_app_version_code` (`version_code`),
+ KEY `idx_app_version_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='APP版本设置表';
+
+INSERT IGNORE INTO `tcm_app_version_config`
+(`id`, `version_no`, `version_code`, `android_file_url`, `android_file_name`, `ios_file_url`, `ios_file_name`, `force_update`, `update_notice`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, '1.0.0', 1, 'https://example.com/app/android/1.0.0.apk', 'app-1.0.0.apk', 'https://example.com/app/ios/1.0.0.ipa', 'app-1.0.0.ipa', 0, '反馈内容反馈内容反馈内容反馈内容反馈内容', 0, '系统初始化', '系统初始化'),
+(2, '1.0.1', 2, 'https://example.com/app/android/1.0.1.apk', 'app-1.0.1.apk', 'https://example.com/app/ios/1.0.1.ipa', 'app-1.0.1.ipa', 1, '反馈内容反馈内容反馈内容反馈内容反馈内容反馈内容反馈内容', 0, '系统初始化', '系统初始化');
+
+CREATE TABLE IF NOT EXISTS `tcm_message_push_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `notice_type_code` VARCHAR(64) NOT NULL COMMENT '通知类型编码',
+ `notice_type_name` VARCHAR(64) NOT NULL COMMENT '通知类型名称',
+ `push_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启PUSH推送:1是 0否',
+ `sms_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启短信通知:1是 0否',
+ `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_message_push_notice_type_code` (`notice_type_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送配置表';
+
+INSERT IGNORE INTO `tcm_message_push_config`
+(`id`, `notice_type_code`, `notice_type_name`, `push_enabled`, `sms_enabled`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, 'MEMBER_EXPIRE_REMIND', '会员到期提醒', 1, 1, 0, '系统初始化', '系统初始化'),
+(2, 'LIVE_REMIND', '直播提醒', 1, 1, 0, '系统初始化', '系统初始化'),
+(3, 'ACTIVITY_NOTICE', '活动通知', 1, 0, 0, '系统初始化', '系统初始化'),
+(4, 'ORDER_STATUS_UPDATE', '订单状态更新', 1, 0, 0, '系统初始化', '系统初始化'),
+(5, 'COURSE_UPDATE_NOTICE', '课程更新通知', 1, 0, 0, '系统初始化', '系统初始化'),
+(6, 'STUDY_PROGRESS_REMIND', '学习进度提醒', 1, 0, 0, '系统初始化', '系统初始化');
+
+CREATE TABLE IF NOT EXISTS `tcm_system_banner_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `banner_img` VARCHAR(512) NOT NULL COMMENT 'Banner图片',
+ `title` VARCHAR(64) NOT NULL COMMENT 'Banner标题',
+ `start_at` DATETIME NOT NULL COMMENT '展示开始时间',
+ `end_at` DATETIME NOT NULL COMMENT '展示结束时间',
+ `jump_type` VARCHAR(32) NOT NULL COMMENT '跳转类型',
+ `target_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '跳转目标ID',
+ `target_name` VARCHAR(64) DEFAULT NULL COMMENT '跳转目标名称',
+ `target_url` VARCHAR(512) DEFAULT NULL COMMENT '三方地址',
+ `publish_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否上架:1是 0否',
+ `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_system_banner_time` (`start_at`, `end_at`),
+ KEY `idx_system_banner_jump_type` (`jump_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Banner配置表';
+
+INSERT IGNORE INTO `tcm_system_banner_config`
+(`id`, `banner_img`, `title`, `start_at`, `end_at`, `jump_type`, `target_id`, `target_name`, `target_url`, `publish_enabled`, `deleted`, `created_by`, `updated_by`)
+VALUES
+(1, 'https://example.com/banner/member-discount.png', '会员显示折扣', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'MEMBER', NULL, '会员', NULL, 1, 0, '系统初始化', '系统初始化'),
+(2, 'https://example.com/banner/health-course.png', '兑换商城优惠券更新', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'HEALTH_COURSE', 1, '养生课程1', NULL, 1, 0, '系统初始化', '系统初始化'),
+(3, 'https://example.com/banner/third-party.png', '标题标题题', '2024-05-05 12:25:33', '2024-06-01 12:25:33', 'THIRD_PARTY_URL', NULL, NULL, 'https://www.baidu.com', 0, 0, '系统初始化', '系统初始化');
diff --git a/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java b/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
index 9b5e9f4..15e9a47 100644
--- a/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
+++ b/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -119,6 +119,40 @@
null, null, null, 4, "system:role:delete", "角色删除按钮"));
menuIds.add(saveMenu(1300L, 1000L, "系统设置", "system_setting_group", "M",
"/system/settings", null, null, 30, null, "系统设置目录"));
+ menuIds.add(saveMenu(1305L, 1300L, "系统设置", "system_basic_settings", "C",
+ "/system/settings/basic", "System/BasicSettings", null, 5, "system:basic-settings:view", "系统设置菜单"));
+ menuIds.add(saveMenu(1306L, 1305L, "编辑系统设置", "system_basic_settings_edit", "B",
+ null, null, null, 1, "system:basic-settings:edit", "系统设置编辑按钮"));
+ menuIds.add(saveMenu(1307L, 1300L, "APP版本设置", "system_app_version_settings", "C",
+ "/system/settings/app-version", "System/AppVersionSettings", null, 15, "system:app-version:view", "APP版本设置菜单"));
+ menuIds.add(saveMenu(1308L, 1307L, "新增APP版本", "system_app_version_settings_add", "B",
+ null, null, null, 1, "system:app-version:add", "新增APP版本按钮"));
+ menuIds.add(saveMenu(1309L, 1307L, "查看APP版本", "system_app_version_settings_detail", "B",
+ null, null, null, 2, "system:app-version:detail", "查看APP版本按钮"));
+ menuIds.add(saveMenu(1312L, 1300L, "消息推送配置", "system_message_push_settings", "C",
+ "/system/settings/message-push", "System/MessagePushSettings", null, 20, "system:message-push:view", "消息推送配置菜单"));
+ menuIds.add(saveMenu(1313L, 1312L, "编辑消息推送配置", "system_message_push_settings_edit", "B",
+ null, null, null, 1, "system:message-push:edit", "消息推送配置编辑按钮"));
+ menuIds.add(saveMenu(1314L, 1300L, "Banner配置", "system_banner_settings", "C",
+ "/system/settings/banner", "System/BannerSettings", null, 12, "system:banner:view", "Banner配置菜单"));
+ menuIds.add(saveMenu(1315L, 1314L, "新增Banner", "system_banner_settings_add", "B",
+ null, null, null, 1, "system:banner:add", "Banner新增按钮"));
+ menuIds.add(saveMenu(1316L, 1314L, "编辑Banner", "system_banner_settings_edit", "B",
+ null, null, null, 2, "system:banner:edit", "Banner编辑按钮"));
+ menuIds.add(saveMenu(1317L, 1314L, "查看Banner", "system_banner_settings_detail", "B",
+ null, null, null, 3, "system:banner:detail", "Banner查看按钮"));
+ menuIds.add(saveMenu(1318L, 1314L, "删除Banner", "system_banner_settings_delete", "B",
+ null, null, null, 4, "system:banner:delete", "Banner删除按钮"));
+ menuIds.add(saveMenu(1319L, 1314L, "批量删除Banner", "system_banner_settings_batch_delete", "B",
+ null, null, null, 5, "system:banner:batch-delete", "Banner批量删除按钮"));
+ menuIds.add(saveMenu(1320L, 1314L, "切换Banner上架状态", "system_banner_settings_publish_status", "B",
+ null, null, null, 6, "system:banner:publish-status", "Banner上架状态按钮"));
+ menuIds.add(saveMenu(1321L, 1300L, "活动通知", "system_activity_notice", "C",
+ "/system/settings/activity-notice", "System/ActivityNotice", null, 13, "system:activity-notice:view", "活动通知菜单"));
+ menuIds.add(saveMenu(1322L, 1321L, "新增活动通知", "system_activity_notice_add", "B",
+ null, null, null, 1, "system:activity-notice:add", "活动通知新增按钮"));
+ menuIds.add(saveMenu(1323L, 1321L, "删除活动通知", "system_activity_notice_delete", "B",
+ null, null, null, 2, "system:activity-notice: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",
diff --git a/backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java b/backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
index 2df5ef6..091c90c 100644
--- a/backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
+++ b/backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
@@ -36,7 +36,12 @@
StpUtil.checkLogin();
}))
.addPathPatterns("/api/**", "/api/v1/**")
- .excludePathPatterns("/api/auth/login","/api/auth/captcha");
+ .excludePathPatterns(
+ "/api/auth/login",
+ "/api/auth/captcha",
+ "/api/app-version-settings/check",
+ "/api/v1/app-version-settings/check"
+ );
registry.addInterceptor(xssParamInterceptor)
.addPathPatterns("/api/**", "/api/v1/**");
diff --git a/backend/src/main/java/com/tcm/training/admin/system/controller/ActivityNoticeController.java b/backend/src/main/java/com/tcm/training/admin/system/controller/ActivityNoticeController.java
new file mode 100644
index 0000000..cbc23e1
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/controller/ActivityNoticeController.java
@@ -0,0 +1,49 @@
+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.ActivityNoticeCreateRequest;
+import com.tcm.training.admin.system.service.ActivityNoticeAppService;
+import com.tcm.training.admin.system.vo.ActivityNoticeListItemVO;
+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.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 ActivityNoticeController {
+
+ private final ActivityNoticeAppService activityNoticeAppService;
+
+ public ActivityNoticeController(ActivityNoticeAppService activityNoticeAppService) {
+ this.activityNoticeAppService = activityNoticeAppService;
+ }
+
+ @GetMapping("/activity-notices")
+ public ApiResponse<PageResponse<ActivityNoticeListItemVO>> page(
+ @RequestParam(required = false, defaultValue = "1") int page,
+ @RequestParam(required = false, defaultValue = "10") int pageSize,
+ @RequestParam(required = false) String title) {
+ return ApiResponse.ok(activityNoticeAppService.page(page, pageSize, title));
+ }
+
+ @PostMapping("/activity-notices")
+ public ApiResponse<ActivityNoticeListItemVO> create(@Valid @RequestBody ActivityNoticeCreateRequest request) {
+ return ApiResponse.ok(activityNoticeAppService.create(request));
+ }
+
+ @DeleteMapping("/activity-notices/{id}")
+ public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+ activityNoticeAppService.delete(id);
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/controller/AppVersionSettingsController.java b/backend/src/main/java/com/tcm/training/admin/system/controller/AppVersionSettingsController.java
new file mode 100644
index 0000000..aaf1162
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/controller/AppVersionSettingsController.java
@@ -0,0 +1,55 @@
+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.AppVersionCreateRequest;
+import com.tcm.training.admin.system.service.AppVersionSettingsAppService;
+import com.tcm.training.admin.system.vo.AppVersionCheckVO;
+import com.tcm.training.admin.system.vo.AppVersionDetailVO;
+import com.tcm.training.admin.system.vo.AppVersionListItemVO;
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class AppVersionSettingsController {
+
+ private final AppVersionSettingsAppService appVersionSettingsAppService;
+
+ public AppVersionSettingsController(AppVersionSettingsAppService appVersionSettingsAppService) {
+ this.appVersionSettingsAppService = appVersionSettingsAppService;
+ }
+
+ @GetMapping("/app-version-settings")
+ public ApiResponse<PageResponse<AppVersionListItemVO>> page(
+ @RequestParam(required = false, defaultValue = "1") int page,
+ @RequestParam(required = false, defaultValue = "10") int pageSize) {
+ return ApiResponse.ok(appVersionSettingsAppService.page(page, pageSize));
+ }
+
+ @GetMapping("/app-version-settings/check")
+ public ApiResponse<AppVersionCheckVO> checkUpdate(
+ @RequestParam String platform,
+ @RequestParam Integer currentVersionCode) {
+ return ApiResponse.ok(appVersionSettingsAppService.checkUpdate(platform, currentVersionCode));
+ }
+
+ @GetMapping("/app-version-settings/{id}")
+ public ApiResponse<AppVersionDetailVO> detail(@PathVariable("id") Long id) {
+ return ApiResponse.ok(appVersionSettingsAppService.detail(id));
+ }
+
+ @PostMapping("/app-version-settings")
+ public ApiResponse<AppVersionDetailVO> create(@Valid @RequestBody AppVersionCreateRequest request) {
+ return ApiResponse.ok(appVersionSettingsAppService.create(request));
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/controller/MessagePushSettingsController.java b/backend/src/main/java/com/tcm/training/admin/system/controller/MessagePushSettingsController.java
new file mode 100644
index 0000000..742ee23
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/controller/MessagePushSettingsController.java
@@ -0,0 +1,37 @@
+package com.tcm.training.admin.system.controller;
+
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.system.dto.MessagePushSettingsSaveRequest;
+import com.tcm.training.admin.system.service.MessagePushSettingsAppService;
+import com.tcm.training.admin.system.vo.MessagePushSettingVO;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+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 MessagePushSettingsController {
+
+ private final MessagePushSettingsAppService messagePushSettingsAppService;
+
+ public MessagePushSettingsController(MessagePushSettingsAppService messagePushSettingsAppService) {
+ this.messagePushSettingsAppService = messagePushSettingsAppService;
+ }
+
+ @GetMapping("/message-push-settings")
+ public ApiResponse<List<MessagePushSettingVO>> current() {
+ return ApiResponse.ok(messagePushSettingsAppService.current());
+ }
+
+ @PutMapping("/message-push-settings")
+ public ApiResponse<List<MessagePushSettingVO>> saveCurrent(@Valid @RequestBody MessagePushSettingsSaveRequest request) {
+ return ApiResponse.ok(messagePushSettingsAppService.saveCurrent(request));
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/controller/SystemBannerController.java b/backend/src/main/java/com/tcm/training/admin/system/controller/SystemBannerController.java
new file mode 100644
index 0000000..69915e2
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/controller/SystemBannerController.java
@@ -0,0 +1,91 @@
+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.SystemBannerBatchDeleteRequest;
+import com.tcm.training.admin.system.dto.SystemBannerPublishStatusRequest;
+import com.tcm.training.admin.system.dto.SystemBannerSaveRequest;
+import com.tcm.training.admin.system.service.SystemBannerAppService;
+import com.tcm.training.admin.system.vo.SystemBannerCourseOptionVO;
+import com.tcm.training.admin.system.vo.SystemBannerDetailVO;
+import com.tcm.training.admin.system.vo.SystemBannerListItemVO;
+import com.tcm.training.admin.system.vo.SystemBannerPublishStatusVO;
+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.PatchMapping;
+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;
+import java.util.List;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class SystemBannerController {
+
+ private final SystemBannerAppService systemBannerAppService;
+
+ public SystemBannerController(SystemBannerAppService systemBannerAppService) {
+ this.systemBannerAppService = systemBannerAppService;
+ }
+
+ @GetMapping("/system-banners")
+ public ApiResponse<PageResponse<SystemBannerListItemVO>> page(
+ @RequestParam(required = false, defaultValue = "1") int page,
+ @RequestParam(required = false, defaultValue = "10") int pageSize,
+ @RequestParam(required = false) String title,
+ @RequestParam(required = false) Integer status) {
+ return ApiResponse.ok(systemBannerAppService.page(page, pageSize, title, status));
+ }
+
+ @GetMapping("/system-banners/{id}")
+ public ApiResponse<SystemBannerDetailVO> detail(@PathVariable("id") Long id) {
+ return ApiResponse.ok(systemBannerAppService.detail(id));
+ }
+
+ @PostMapping("/system-banners")
+ public ApiResponse<SystemBannerDetailVO> create(@Valid @RequestBody SystemBannerSaveRequest request) {
+ return ApiResponse.ok(systemBannerAppService.create(request));
+ }
+
+ @PutMapping("/system-banners/{id}")
+ public ApiResponse<SystemBannerDetailVO> update(@PathVariable("id") Long id,
+ @Valid @RequestBody SystemBannerSaveRequest request) {
+ return ApiResponse.ok(systemBannerAppService.update(id, request));
+ }
+
+ @DeleteMapping("/system-banners/{id}")
+ public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+ systemBannerAppService.delete(id);
+ return ApiResponse.ok(null);
+ }
+
+ @DeleteMapping("/system-banners/batch")
+ public ApiResponse<Void> batchDelete(@Valid @RequestBody SystemBannerBatchDeleteRequest request) {
+ systemBannerAppService.batchDelete(request);
+ return ApiResponse.ok(null);
+ }
+
+ @PatchMapping("/system-banners/{id}/publish-status")
+ public ApiResponse<SystemBannerPublishStatusVO> patchPublishStatus(@PathVariable("id") Long id,
+ @Valid @RequestBody SystemBannerPublishStatusRequest request) {
+ return ApiResponse.ok(systemBannerAppService.patchPublishStatus(id, request));
+ }
+
+ @GetMapping("/system-banners/health-course-options")
+ public ApiResponse<List<SystemBannerCourseOptionVO>> healthCourseOptions(@RequestParam(required = false) String keyword) {
+ return ApiResponse.ok(systemBannerAppService.healthCourseOptions(keyword));
+ }
+
+ @GetMapping("/system-banners/live-course-options")
+ public ApiResponse<List<SystemBannerCourseOptionVO>> liveCourseOptions(@RequestParam(required = false) String keyword) {
+ return ApiResponse.ok(systemBannerAppService.liveCourseOptions(keyword));
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/controller/SystemSettingsController.java b/backend/src/main/java/com/tcm/training/admin/system/controller/SystemSettingsController.java
new file mode 100644
index 0000000..bc1ab99
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/controller/SystemSettingsController.java
@@ -0,0 +1,36 @@
+package com.tcm.training.admin.system.controller;
+
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.system.dto.SystemSettingsSaveRequest;
+import com.tcm.training.admin.system.service.SystemSettingsAppService;
+import com.tcm.training.admin.system.vo.SystemSettingsVO;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+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;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class SystemSettingsController {
+
+ private final SystemSettingsAppService systemSettingsAppService;
+
+ public SystemSettingsController(SystemSettingsAppService systemSettingsAppService) {
+ this.systemSettingsAppService = systemSettingsAppService;
+ }
+
+ @GetMapping("/system-settings/current")
+ public ApiResponse<SystemSettingsVO> current() {
+ return ApiResponse.ok(systemSettingsAppService.current());
+ }
+
+ @PutMapping("/system-settings/current")
+ public ApiResponse<SystemSettingsVO> saveCurrent(@Valid @RequestBody SystemSettingsSaveRequest request) {
+ return ApiResponse.ok(systemSettingsAppService.saveCurrent(request));
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/ActivityNoticeCreateRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/ActivityNoticeCreateRequest.java
new file mode 100644
index 0000000..88d2319
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/ActivityNoticeCreateRequest.java
@@ -0,0 +1,21 @@
+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 ActivityNoticeCreateRequest {
+
+ @NotBlank(message = "请输入通知标题")
+ @Size(max = 15, message = "通知标题长度不能超过15个字符")
+ private String title;
+
+ @NotBlank(message = "请输入通知内容")
+ private String contentHtml;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/AppVersionCreateRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/AppVersionCreateRequest.java
new file mode 100644
index 0000000..97f854e
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/AppVersionCreateRequest.java
@@ -0,0 +1,42 @@
+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.NotNull;
+import javax.validation.constraints.Size;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppVersionCreateRequest {
+
+ @NotBlank(message = "请输入版本号")
+ @Size(max = 32, message = "版本号长度不能超过32个字符")
+ private String versionNo;
+
+ @NotBlank(message = "请上传安卓文件")
+ @Size(max = 512, message = "安卓文件地址长度不能超过512个字符")
+ private String androidFileUrl;
+
+ @NotBlank(message = "请填写安卓文件名称")
+ @Size(max = 255, message = "安卓文件名称长度不能超过255个字符")
+ private String androidFileName;
+
+ @NotBlank(message = "请上传iOS文件")
+ @Size(max = 512, message = "iOS文件地址长度不能超过512个字符")
+ private String iosFileUrl;
+
+ @NotBlank(message = "请填写iOS文件名称")
+ @Size(max = 255, message = "iOS文件名称长度不能超过255个字符")
+ private String iosFileName;
+
+ @NotNull(message = "请选择是否强制更新")
+ private Boolean forceUpdate;
+
+ @NotBlank(message = "请输入更新说明")
+ @Size(max = 2000, message = "更新说明长度不能超过2000个字符")
+ private String updateNotice;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingItemSaveRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingItemSaveRequest.java
new file mode 100644
index 0000000..e234a8c
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingItemSaveRequest.java
@@ -0,0 +1,25 @@
+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.NotNull;
+import javax.validation.constraints.Size;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class MessagePushSettingItemSaveRequest {
+
+ @NotBlank(message = "通知类型不能为空")
+ @Size(max = 64, message = "通知类型编码长度不能超过64个字符")
+ private String noticeTypeCode;
+
+ @NotNull(message = "请设置PUSH推送")
+ private Boolean pushEnabled;
+
+ @NotNull(message = "请设置短信通知")
+ private Boolean smsEnabled;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingsSaveRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingsSaveRequest.java
new file mode 100644
index 0000000..f578ac1
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/MessagePushSettingsSaveRequest.java
@@ -0,0 +1,19 @@
+package com.tcm.training.admin.system.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+import java.util.List;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class MessagePushSettingsSaveRequest {
+
+ @Valid
+ @NotEmpty(message = "消息推送配置不能为空")
+ private List<MessagePushSettingItemSaveRequest> items;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerBatchDeleteRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerBatchDeleteRequest.java
new file mode 100644
index 0000000..7f4bcf9
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerBatchDeleteRequest.java
@@ -0,0 +1,18 @@
+package com.tcm.training.admin.system.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemBannerBatchDeleteRequest {
+
+ @NotEmpty(message = "请选择要删除的banner")
+ private List<@NotNull(message = "banner标识不能为空") Long> ids;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerPublishStatusRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerPublishStatusRequest.java
new file mode 100644
index 0000000..75a8926
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerPublishStatusRequest.java
@@ -0,0 +1,16 @@
+package com.tcm.training.admin.system.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotNull;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemBannerPublishStatusRequest {
+
+ @NotNull(message = "请选择banner上架状态")
+ private Boolean publishEnabled;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerSaveRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerSaveRequest.java
new file mode 100644
index 0000000..6bd4ab2
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemBannerSaveRequest.java
@@ -0,0 +1,45 @@
+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.NotNull;
+import javax.validation.constraints.Size;
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemBannerSaveRequest {
+
+ @NotBlank(message = "请上传banner图")
+ @Size(max = 512, message = "banner图地址长度不能超过512个字符")
+ private String bannerImg;
+
+ @NotBlank(message = "请输入banner标题")
+ @Size(max = 64, message = "banner标题长度不能超过64个字符")
+ private String title;
+
+ @NotNull(message = "请选择banner开始时间")
+ private LocalDateTime startAt;
+
+ @NotNull(message = "请选择banner结束时间")
+ private LocalDateTime endAt;
+
+ @NotBlank(message = "请选择跳转类型")
+ @Size(max = 32, message = "跳转类型长度不能超过32个字符")
+ private String jumpType;
+
+ private Long targetId;
+
+ @Size(max = 64, message = "目标名称长度不能超过64个字符")
+ private String targetName;
+
+ @Size(max = 512, message = "地址信息长度不能超过512个字符")
+ private String targetUrl;
+
+ @NotNull(message = "请选择banner上架状态")
+ private Boolean publishEnabled;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java
new file mode 100644
index 0000000..462927f
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java
@@ -0,0 +1,32 @@
+package com.tcm.training.admin.system.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemSettingsSaveRequest {
+
+ @NotBlank(message = "请输入客服电话")
+ @Size(max = 32, message = "客服电话长度不能超过32个字符")
+ private String servicePhone;
+
+ @NotBlank(message = "请输入客服在线时间")
+ @Size(max = 64, message = "客服在线时间长度不能超过64个字符")
+ private String serviceOnlineTime;
+
+ @NotNull(message = "请输入备份周期")
+ @Min(value = 1, message = "备份周期必须大于0")
+ private Integer backupCycleDays;
+
+ @NotNull(message = "请输入会员到期提醒天数")
+ @Min(value = 1, message = "会员到期提醒天数必须大于0")
+ private Integer memberExpireRemindDays;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/entity/ActivityNotice.java b/backend/src/main/java/com/tcm/training/admin/system/entity/ActivityNotice.java
new file mode 100644
index 0000000..54e2af0
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/entity/ActivityNotice.java
@@ -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_activity_notice")
+@Getter
+@Setter
+@NoArgsConstructor
+public class ActivityNotice {
+
+ @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;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/entity/AppVersionConfig.java b/backend/src/main/java/com/tcm/training/admin/system/entity/AppVersionConfig.java
new file mode 100644
index 0000000..2195dff
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/entity/AppVersionConfig.java
@@ -0,0 +1,65 @@
+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_app_version_config")
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppVersionConfig {
+
+ public static final String PLATFORM_ANDROID = "ANDROID";
+ public static final String PLATFORM_IOS = "IOS";
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("version_no")
+ private String versionNo;
+
+ @TableField("version_code")
+ private Integer versionCode;
+
+ @TableField("android_file_url")
+ private String androidFileUrl;
+
+ @TableField("android_file_name")
+ private String androidFileName;
+
+ @TableField("ios_file_url")
+ private String iosFileUrl;
+
+ @TableField("ios_file_name")
+ private String iosFileName;
+
+ @TableField("force_update")
+ private Integer forceUpdate;
+
+ @TableField("update_notice")
+ private String updateNotice;
+
+ @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;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/entity/MessagePushConfig.java b/backend/src/main/java/com/tcm/training/admin/system/entity/MessagePushConfig.java
new file mode 100644
index 0000000..b2b7923
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/entity/MessagePushConfig.java
@@ -0,0 +1,50 @@
+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_message_push_config")
+@Getter
+@Setter
+@NoArgsConstructor
+public class MessagePushConfig {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("notice_type_code")
+ private String noticeTypeCode;
+
+ @TableField("notice_type_name")
+ private String noticeTypeName;
+
+ @TableField("push_enabled")
+ private Integer pushEnabled;
+
+ @TableField("sms_enabled")
+ private Integer smsEnabled;
+
+ @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;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/entity/SystemBannerConfig.java b/backend/src/main/java/com/tcm/training/admin/system/entity/SystemBannerConfig.java
new file mode 100644
index 0000000..00a002c
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/entity/SystemBannerConfig.java
@@ -0,0 +1,74 @@
+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_system_banner_config")
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemBannerConfig {
+
+ public static final String JUMP_TYPE_HEALTH_COURSE = "HEALTH_COURSE";
+ public static final String JUMP_TYPE_LIVE_COURSE = "LIVE_COURSE";
+ public static final String JUMP_TYPE_MEMBER = "MEMBER";
+ public static final String JUMP_TYPE_THIRD_PARTY_URL = "THIRD_PARTY_URL";
+
+ public static final int DISPLAY_STATUS_NOT_STARTED = 1;
+ public static final int DISPLAY_STATUS_ONGOING = 2;
+ public static final int DISPLAY_STATUS_ENDED = 3;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("banner_img")
+ private String bannerImg;
+
+ @TableField("title")
+ private String title;
+
+ @TableField("start_at")
+ private LocalDateTime startAt;
+
+ @TableField("end_at")
+ private LocalDateTime endAt;
+
+ @TableField("jump_type")
+ private String jumpType;
+
+ @TableField("target_id")
+ private Long targetId;
+
+ @TableField("target_name")
+ private String targetName;
+
+ @TableField("target_url")
+ private String targetUrl;
+
+ @TableField("publish_enabled")
+ private Integer publishEnabled;
+
+ @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;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java b/backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java
new file mode 100644
index 0000000..53c3566
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java
@@ -0,0 +1,50 @@
+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_system_settings")
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemSettings {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("service_phone")
+ private String servicePhone;
+
+ @TableField("service_online_time")
+ private String serviceOnlineTime;
+
+ @TableField("backup_cycle_days")
+ private Integer backupCycleDays;
+
+ @TableField("member_expire_remind_days")
+ private Integer memberExpireRemindDays;
+
+ @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;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/ActivityNoticeMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/ActivityNoticeMapper.java
new file mode 100644
index 0000000..15e3b3e
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/ActivityNoticeMapper.java
@@ -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.ActivityNotice;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ActivityNoticeMapper extends BaseMapper<ActivityNotice> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/AppVersionConfigMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/AppVersionConfigMapper.java
new file mode 100644
index 0000000..b558e33
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/AppVersionConfigMapper.java
@@ -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.AppVersionConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AppVersionConfigMapper extends BaseMapper<AppVersionConfig> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/MessagePushConfigMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/MessagePushConfigMapper.java
new file mode 100644
index 0000000..b70c53c
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/MessagePushConfigMapper.java
@@ -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.MessagePushConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MessagePushConfigMapper extends BaseMapper<MessagePushConfig> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/SystemBannerConfigMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/SystemBannerConfigMapper.java
new file mode 100644
index 0000000..357a5ee
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/SystemBannerConfigMapper.java
@@ -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.SystemBannerConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SystemBannerConfigMapper extends BaseMapper<SystemBannerConfig> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/mapper/SystemSettingsMapper.java b/backend/src/main/java/com/tcm/training/admin/system/mapper/SystemSettingsMapper.java
new file mode 100644
index 0000000..4f53a01
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/mapper/SystemSettingsMapper.java
@@ -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.SystemSettings;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SystemSettingsMapper extends BaseMapper<SystemSettings> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeAppService.java b/backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeAppService.java
new file mode 100644
index 0000000..8cbd9c5
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeAppService.java
@@ -0,0 +1,116 @@
+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.ActivityNoticeCreateRequest;
+import com.tcm.training.admin.system.entity.ActivityNotice;
+import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
+import com.tcm.training.admin.system.vo.ActivityNoticeListItemVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+public class ActivityNoticeAppService {
+
+ private final ActivityNoticeMapper activityNoticeMapper;
+
+ public ActivityNoticeAppService(ActivityNoticeMapper activityNoticeMapper) {
+ this.activityNoticeMapper = activityNoticeMapper;
+ }
+
+ public PageResponse<ActivityNoticeListItemVO> page(int page, int pageSize, String title) {
+ LambdaQueryWrapper<ActivityNotice> qw = new LambdaQueryWrapper<ActivityNotice>()
+ .eq(ActivityNotice::getDeleted, 0)
+ .like(title != null && !title.trim().isEmpty(), ActivityNotice::getTitle, title == null ? null : title.trim())
+ .orderByDesc(ActivityNotice::getCreatedAt)
+ .orderByDesc(ActivityNotice::getId);
+ IPage<ActivityNotice> result = activityNoticeMapper.selectPage(new Page<ActivityNotice>(page, pageSize), qw);
+ List<ActivityNoticeListItemVO> list = new ArrayList<ActivityNoticeListItemVO>();
+ for (ActivityNotice item : result.getRecords()) {
+ list.add(toListItem(item));
+ }
+ return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ @Transactional
+ public ActivityNoticeListItemVO create(ActivityNoticeCreateRequest request) {
+ ActivityNotice item = new ActivityNotice();
+ item.setTitle(normalizeTitle(request.getTitle()));
+ item.setContentHtml(normalizeContent(request.getContentHtml()));
+ item.setDeleted(0);
+ activityNoticeMapper.insert(item);
+ return toListItem(requireItem(item.getId()));
+ }
+
+ @Transactional
+ public void delete(Long id) {
+ requireItem(id);
+ activityNoticeMapper.deleteById(id);
+ }
+
+ private ActivityNotice requireItem(Long id) {
+ ActivityNotice item = activityNoticeMapper.selectOne(new LambdaQueryWrapper<ActivityNotice>()
+ .eq(ActivityNotice::getId, id)
+ .eq(ActivityNotice::getDeleted, 0)
+ .last("limit 1"));
+ if (item == null) {
+ throw new IllegalArgumentException("活动通知不存在");
+ }
+ return item;
+ }
+
+ private ActivityNoticeListItemVO toListItem(ActivityNotice item) {
+ ActivityNoticeListItemVO vo = new ActivityNoticeListItemVO();
+ vo.setId(item.getId());
+ vo.setTitle(item.getTitle());
+ vo.setCreatedAt(item.getCreatedAt());
+ return vo;
+ }
+
+ private String normalizeTitle(String title) {
+ String trimmed = trimToNull(title);
+ if (trimmed == null) {
+ throw new IllegalArgumentException("请输入通知标题");
+ }
+ 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;
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/service/AppVersionSettingsAppService.java b/backend/src/main/java/com/tcm/training/admin/system/service/AppVersionSettingsAppService.java
new file mode 100644
index 0000000..cb70f01
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/service/AppVersionSettingsAppService.java
@@ -0,0 +1,154 @@
+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.AppVersionCreateRequest;
+import com.tcm.training.admin.system.entity.AppVersionConfig;
+import com.tcm.training.admin.system.mapper.AppVersionConfigMapper;
+import com.tcm.training.admin.system.vo.AppVersionCheckVO;
+import com.tcm.training.admin.system.vo.AppVersionDetailVO;
+import com.tcm.training.admin.system.vo.AppVersionListItemVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+public class AppVersionSettingsAppService {
+
+ private final AppVersionConfigMapper appVersionConfigMapper;
+
+ public AppVersionSettingsAppService(AppVersionConfigMapper appVersionConfigMapper) {
+ this.appVersionConfigMapper = appVersionConfigMapper;
+ }
+
+ public PageResponse<AppVersionListItemVO> page(int page, int pageSize) {
+ LambdaQueryWrapper<AppVersionConfig> qw = new LambdaQueryWrapper<AppVersionConfig>()
+ .eq(AppVersionConfig::getDeleted, 0)
+ .orderByDesc(AppVersionConfig::getVersionCode)
+ .orderByDesc(AppVersionConfig::getId);
+ IPage<AppVersionConfig> result = appVersionConfigMapper.selectPage(new Page<AppVersionConfig>(page, pageSize), qw);
+ List<AppVersionListItemVO> list = new ArrayList<AppVersionListItemVO>();
+ for (AppVersionConfig item : result.getRecords()) {
+ list.add(toListItem(item));
+ }
+ return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ public AppVersionDetailVO detail(Long id) {
+ return toDetailVO(requireItem(id));
+ }
+
+ @Transactional
+ public AppVersionDetailVO create(AppVersionCreateRequest request) {
+ AppVersionConfig latest = latest();
+ AppVersionConfig item = new AppVersionConfig();
+ item.setVersionNo(request.getVersionNo().trim());
+ item.setVersionCode(latest == null ? 1 : defaultNumber(latest.getVersionCode()) + 1);
+ item.setAndroidFileUrl(request.getAndroidFileUrl().trim());
+ item.setAndroidFileName(request.getAndroidFileName().trim());
+ item.setIosFileUrl(request.getIosFileUrl().trim());
+ item.setIosFileName(request.getIosFileName().trim());
+ item.setForceUpdate(booleanToInteger(request.getForceUpdate()));
+ item.setUpdateNotice(request.getUpdateNotice().trim());
+ item.setDeleted(0);
+ appVersionConfigMapper.insert(item);
+ return toDetailVO(item);
+ }
+
+ public AppVersionCheckVO checkUpdate(String platform, Integer currentVersionCode) {
+ if (currentVersionCode == null) {
+ throw new IllegalArgumentException("当前版本号不能为空");
+ }
+ if (currentVersionCode < 0) {
+ throw new IllegalArgumentException("当前版本号不能小于0");
+ }
+ String normalizedPlatform = normalizePlatform(platform);
+ AppVersionConfig latest = latest();
+ AppVersionCheckVO vo = new AppVersionCheckVO();
+ if (latest == null) {
+ vo.setHasUpdate(Boolean.FALSE);
+ return vo;
+ }
+
+ boolean hasUpdate = defaultNumber(latest.getVersionCode()) > currentVersionCode;
+ vo.setHasUpdate(hasUpdate);
+ vo.setLatestVersionNo(latest.getVersionNo());
+ vo.setLatestVersionCode(defaultNumber(latest.getVersionCode()));
+ vo.setForceUpdate(integerToBoolean(latest.getForceUpdate()));
+ vo.setUpdateNotice(latest.getUpdateNotice());
+ vo.setDownloadUrl(AppVersionConfig.PLATFORM_ANDROID.equals(normalizedPlatform)
+ ? latest.getAndroidFileUrl()
+ : latest.getIosFileUrl());
+ return vo;
+ }
+
+ private AppVersionConfig latest() {
+ return appVersionConfigMapper.selectOne(new LambdaQueryWrapper<AppVersionConfig>()
+ .eq(AppVersionConfig::getDeleted, 0)
+ .orderByDesc(AppVersionConfig::getVersionCode)
+ .orderByDesc(AppVersionConfig::getId)
+ .last("limit 1"));
+ }
+
+ private AppVersionConfig requireItem(Long id) {
+ AppVersionConfig item = appVersionConfigMapper.selectOne(new LambdaQueryWrapper<AppVersionConfig>()
+ .eq(AppVersionConfig::getId, id)
+ .eq(AppVersionConfig::getDeleted, 0)
+ .last("limit 1"));
+ if (item == null) {
+ throw new IllegalArgumentException("APP版本不存在");
+ }
+ return item;
+ }
+
+ private String normalizePlatform(String platform) {
+ if (platform == null) {
+ throw new IllegalArgumentException("平台类型不能为空");
+ }
+ String normalized = platform.trim().toUpperCase();
+ if (AppVersionConfig.PLATFORM_ANDROID.equals(normalized) || AppVersionConfig.PLATFORM_IOS.equals(normalized)) {
+ return normalized;
+ }
+ throw new IllegalArgumentException("平台类型不合法");
+ }
+
+ private AppVersionListItemVO toListItem(AppVersionConfig item) {
+ AppVersionListItemVO vo = new AppVersionListItemVO();
+ vo.setId(item.getId());
+ vo.setVersionNo(item.getVersionNo());
+ vo.setUpdateNotice(item.getUpdateNotice());
+ vo.setCreatedAt(item.getCreatedAt());
+ return vo;
+ }
+
+ private AppVersionDetailVO toDetailVO(AppVersionConfig item) {
+ AppVersionDetailVO vo = new AppVersionDetailVO();
+ vo.setId(item.getId());
+ vo.setVersionNo(item.getVersionNo());
+ vo.setVersionCode(defaultNumber(item.getVersionCode()));
+ vo.setAndroidFileUrl(item.getAndroidFileUrl());
+ vo.setAndroidFileName(item.getAndroidFileName());
+ vo.setIosFileUrl(item.getIosFileUrl());
+ vo.setIosFileName(item.getIosFileName());
+ vo.setForceUpdate(integerToBoolean(item.getForceUpdate()));
+ vo.setUpdateNotice(item.getUpdateNotice());
+ vo.setCreatedAt(item.getCreatedAt());
+ return vo;
+ }
+
+ private Integer defaultNumber(Integer value) {
+ return value == null ? 0 : value;
+ }
+
+ private Integer booleanToInteger(Boolean value) {
+ return Boolean.TRUE.equals(value) ? 1 : 0;
+ }
+
+ private Boolean integerToBoolean(Integer value) {
+ return value != null && value == 1;
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/service/MessagePushSettingsAppService.java b/backend/src/main/java/com/tcm/training/admin/system/service/MessagePushSettingsAppService.java
new file mode 100644
index 0000000..4d73fa1
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/service/MessagePushSettingsAppService.java
@@ -0,0 +1,190 @@
+package com.tcm.training.admin.system.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.tcm.training.admin.system.dto.MessagePushSettingItemSaveRequest;
+import com.tcm.training.admin.system.dto.MessagePushSettingsSaveRequest;
+import com.tcm.training.admin.system.entity.MessagePushConfig;
+import com.tcm.training.admin.system.mapper.MessagePushConfigMapper;
+import com.tcm.training.admin.system.vo.MessagePushSettingVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class MessagePushSettingsAppService {
+
+ private static final List<MessagePushTypeDefinition> TYPE_DEFINITIONS = Arrays.asList(
+ new MessagePushTypeDefinition("MEMBER_EXPIRE_REMIND", "会员到期提醒", true, true),
+ new MessagePushTypeDefinition("LIVE_REMIND", "直播提醒", true, true),
+ new MessagePushTypeDefinition("ACTIVITY_NOTICE", "活动通知", true, false),
+ new MessagePushTypeDefinition("ORDER_STATUS_UPDATE", "订单状态更新", true, false),
+ new MessagePushTypeDefinition("COURSE_UPDATE_NOTICE", "课程更新通知", true, false),
+ new MessagePushTypeDefinition("STUDY_PROGRESS_REMIND", "学习进度提醒", true, false)
+ );
+
+ private final MessagePushConfigMapper messagePushConfigMapper;
+
+ public MessagePushSettingsAppService(MessagePushConfigMapper messagePushConfigMapper) {
+ this.messagePushConfigMapper = messagePushConfigMapper;
+ }
+
+ @Transactional
+ public List<MessagePushSettingVO> current() {
+ ensureDefaults();
+ return loadCurrentList();
+ }
+
+ @Transactional
+ public List<MessagePushSettingVO> saveCurrent(MessagePushSettingsSaveRequest request) {
+ ensureDefaults();
+ Map<String, MessagePushSettingItemSaveRequest> requestMap = normalizeRequest(request);
+ Map<String, MessagePushConfig> currentMap = currentConfigMap();
+ for (MessagePushTypeDefinition definition : TYPE_DEFINITIONS) {
+ MessagePushSettingItemSaveRequest itemRequest = requestMap.get(definition.getCode());
+ MessagePushConfig config = currentMap.get(definition.getCode());
+ if (config == null) {
+ config = createDefaultConfig(definition);
+ messagePushConfigMapper.insert(config);
+ }
+ config.setPushEnabled(booleanToInteger(itemRequest.getPushEnabled()));
+ config.setSmsEnabled(booleanToInteger(itemRequest.getSmsEnabled()));
+ messagePushConfigMapper.updateById(config);
+ }
+ return loadCurrentList();
+ }
+
+ private void ensureDefaults() {
+ Map<String, MessagePushConfig> currentMap = currentConfigMap();
+ for (MessagePushTypeDefinition definition : TYPE_DEFINITIONS) {
+ if (!currentMap.containsKey(definition.getCode())) {
+ MessagePushConfig config = createDefaultConfig(definition);
+ messagePushConfigMapper.insert(config);
+ }
+ }
+ }
+
+ private MessagePushConfig createDefaultConfig(MessagePushTypeDefinition definition) {
+ MessagePushConfig config = new MessagePushConfig();
+ config.setNoticeTypeCode(definition.getCode());
+ config.setNoticeTypeName(definition.getName());
+ config.setPushEnabled(booleanToInteger(definition.getDefaultPushEnabled()));
+ config.setSmsEnabled(booleanToInteger(definition.getDefaultSmsEnabled()));
+ config.setDeleted(0);
+ return config;
+ }
+
+ private Map<String, MessagePushSettingItemSaveRequest> normalizeRequest(MessagePushSettingsSaveRequest request) {
+ Map<String, MessagePushSettingItemSaveRequest> requestMap = new LinkedHashMap<String, MessagePushSettingItemSaveRequest>();
+ Set<String> expectedCodes = typeCodeSet();
+ for (MessagePushSettingItemSaveRequest item : request.getItems()) {
+ String code = item.getNoticeTypeCode().trim();
+ if (!expectedCodes.contains(code)) {
+ throw new IllegalArgumentException("通知类型不合法");
+ }
+ if (requestMap.containsKey(code)) {
+ throw new IllegalArgumentException("通知类型不能重复");
+ }
+ item.setNoticeTypeCode(code);
+ requestMap.put(code, item);
+ }
+ if (requestMap.size() != TYPE_DEFINITIONS.size()) {
+ throw new IllegalArgumentException("消息推送配置项不完整");
+ }
+ for (MessagePushTypeDefinition definition : TYPE_DEFINITIONS) {
+ if (!requestMap.containsKey(definition.getCode())) {
+ throw new IllegalArgumentException("消息推送配置项不完整");
+ }
+ }
+ return requestMap;
+ }
+
+ private List<MessagePushSettingVO> loadCurrentList() {
+ Map<String, MessagePushConfig> currentMap = currentConfigMap();
+ List<MessagePushSettingVO> list = new ArrayList<MessagePushSettingVO>();
+ for (MessagePushTypeDefinition definition : TYPE_DEFINITIONS) {
+ MessagePushConfig config = currentMap.get(definition.getCode());
+ if (config != null) {
+ list.add(toVO(config));
+ }
+ }
+ return list;
+ }
+
+ private Map<String, MessagePushConfig> currentConfigMap() {
+ List<MessagePushConfig> list = messagePushConfigMapper.selectList(new LambdaQueryWrapper<MessagePushConfig>()
+ .eq(MessagePushConfig::getDeleted, 0)
+ .orderByAsc(MessagePushConfig::getId));
+ Map<String, MessagePushConfig> map = new LinkedHashMap<String, MessagePushConfig>();
+ for (MessagePushConfig item : list) {
+ if (!map.containsKey(item.getNoticeTypeCode())) {
+ map.put(item.getNoticeTypeCode(), item);
+ }
+ }
+ return map;
+ }
+
+ private Set<String> typeCodeSet() {
+ Set<String> codes = new LinkedHashSet<String>();
+ for (MessagePushTypeDefinition definition : TYPE_DEFINITIONS) {
+ codes.add(definition.getCode());
+ }
+ return codes;
+ }
+
+ private MessagePushSettingVO toVO(MessagePushConfig config) {
+ MessagePushSettingVO vo = new MessagePushSettingVO();
+ vo.setId(config.getId());
+ vo.setNoticeTypeCode(config.getNoticeTypeCode());
+ vo.setNoticeTypeName(config.getNoticeTypeName());
+ vo.setPushEnabled(integerToBoolean(config.getPushEnabled()));
+ vo.setSmsEnabled(integerToBoolean(config.getSmsEnabled()));
+ vo.setUpdatedAt(config.getUpdatedAt());
+ return vo;
+ }
+
+ private Integer booleanToInteger(Boolean value) {
+ return Boolean.TRUE.equals(value) ? 1 : 0;
+ }
+
+ private Boolean integerToBoolean(Integer value) {
+ return value != null && value == 1;
+ }
+
+ private static final class MessagePushTypeDefinition {
+
+ private final String code;
+ private final String name;
+ private final boolean defaultPushEnabled;
+ private final boolean defaultSmsEnabled;
+
+ private MessagePushTypeDefinition(String code, String name, boolean defaultPushEnabled, boolean defaultSmsEnabled) {
+ this.code = code;
+ this.name = name;
+ this.defaultPushEnabled = defaultPushEnabled;
+ this.defaultSmsEnabled = defaultSmsEnabled;
+ }
+
+ private String getCode() {
+ return code;
+ }
+
+ private String getName() {
+ return name;
+ }
+
+ private boolean getDefaultPushEnabled() {
+ return defaultPushEnabled;
+ }
+
+ private boolean getDefaultSmsEnabled() {
+ return defaultSmsEnabled;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/service/SystemBannerAppService.java b/backend/src/main/java/com/tcm/training/admin/system/service/SystemBannerAppService.java
new file mode 100644
index 0000000..9e1a758
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/service/SystemBannerAppService.java
@@ -0,0 +1,313 @@
+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.live.entity.HealthCourse;
+import com.tcm.training.admin.live.entity.LiveCourse;
+import com.tcm.training.admin.live.mapper.HealthCourseMapper;
+import com.tcm.training.admin.live.mapper.LiveCourseMapper;
+import com.tcm.training.admin.system.dto.SystemBannerBatchDeleteRequest;
+import com.tcm.training.admin.system.dto.SystemBannerPublishStatusRequest;
+import com.tcm.training.admin.system.dto.SystemBannerSaveRequest;
+import com.tcm.training.admin.system.entity.SystemBannerConfig;
+import com.tcm.training.admin.system.mapper.SystemBannerConfigMapper;
+import com.tcm.training.admin.system.vo.SystemBannerCourseOptionVO;
+import com.tcm.training.admin.system.vo.SystemBannerDetailVO;
+import com.tcm.training.admin.system.vo.SystemBannerListItemVO;
+import com.tcm.training.admin.system.vo.SystemBannerPublishStatusVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class SystemBannerAppService {
+
+ private final SystemBannerConfigMapper systemBannerConfigMapper;
+ private final HealthCourseMapper healthCourseMapper;
+ private final LiveCourseMapper liveCourseMapper;
+
+ public SystemBannerAppService(SystemBannerConfigMapper systemBannerConfigMapper,
+ HealthCourseMapper healthCourseMapper,
+ LiveCourseMapper liveCourseMapper) {
+ this.systemBannerConfigMapper = systemBannerConfigMapper;
+ this.healthCourseMapper = healthCourseMapper;
+ this.liveCourseMapper = liveCourseMapper;
+ }
+
+ public PageResponse<SystemBannerListItemVO> page(int page, int pageSize, String title, Integer status) {
+ LambdaQueryWrapper<SystemBannerConfig> qw = new LambdaQueryWrapper<SystemBannerConfig>()
+ .eq(SystemBannerConfig::getDeleted, 0)
+ .orderByDesc(SystemBannerConfig::getId);
+ String trimmedTitle = trimToNull(title);
+ if (trimmedTitle != null) {
+ qw.like(SystemBannerConfig::getTitle, trimmedTitle);
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ if (status != null) {
+ if (status == SystemBannerConfig.DISPLAY_STATUS_NOT_STARTED) {
+ qw.gt(SystemBannerConfig::getStartAt, now);
+ } else if (status == SystemBannerConfig.DISPLAY_STATUS_ONGOING) {
+ qw.le(SystemBannerConfig::getStartAt, now).ge(SystemBannerConfig::getEndAt, now);
+ } else if (status == SystemBannerConfig.DISPLAY_STATUS_ENDED) {
+ qw.lt(SystemBannerConfig::getEndAt, now);
+ } else {
+ throw new IllegalArgumentException("状态筛选不合法");
+ }
+ }
+
+ IPage<SystemBannerConfig> result = systemBannerConfigMapper.selectPage(new Page<SystemBannerConfig>(page, pageSize), qw);
+ List<SystemBannerListItemVO> list = new ArrayList<SystemBannerListItemVO>();
+ for (SystemBannerConfig item : result.getRecords()) {
+ list.add(toListItem(item));
+ }
+ return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ public SystemBannerDetailVO detail(Long id) {
+ return toDetailVO(requireItem(id));
+ }
+
+ @Transactional
+ public SystemBannerDetailVO create(SystemBannerSaveRequest request) {
+ SystemBannerConfig item = new SystemBannerConfig();
+ fillEntity(item, request);
+ item.setDeleted(0);
+ systemBannerConfigMapper.insert(item);
+ return detail(item.getId());
+ }
+
+ @Transactional
+ public SystemBannerDetailVO update(Long id, SystemBannerSaveRequest request) {
+ SystemBannerConfig item = requireItem(id);
+ fillEntity(item, request);
+ systemBannerConfigMapper.updateById(item);
+ return detail(id);
+ }
+
+ @Transactional
+ public SystemBannerPublishStatusVO patchPublishStatus(Long id, SystemBannerPublishStatusRequest request) {
+ SystemBannerConfig item = requireItem(id);
+ item.setPublishEnabled(booleanToInteger(request.getPublishEnabled()));
+ systemBannerConfigMapper.updateById(item);
+ return new SystemBannerPublishStatusVO(id, request.getPublishEnabled());
+ }
+
+ @Transactional
+ public void delete(Long id) {
+ requireItem(id);
+ systemBannerConfigMapper.deleteById(id);
+ }
+
+ @Transactional
+ public void batchDelete(SystemBannerBatchDeleteRequest request) {
+ Set<Long> ids = new LinkedHashSet<Long>(request.getIds());
+ if (ids.isEmpty()) {
+ throw new IllegalArgumentException("请选择要删除的banner");
+ }
+ for (Long id : ids) {
+ requireItem(id);
+ }
+ systemBannerConfigMapper.deleteBatchIds(new ArrayList<Long>(ids));
+ }
+
+ public List<SystemBannerCourseOptionVO> healthCourseOptions(String keyword) {
+ List<HealthCourse> list = healthCourseMapper.selectList(new LambdaQueryWrapper<HealthCourse>()
+ .eq(HealthCourse::getDeleted, 0)
+ .like(keyword != null && !keyword.trim().isEmpty(), HealthCourse::getTitle, keyword == null ? null : keyword.trim())
+ .orderByAsc(HealthCourse::getSort)
+ .orderByDesc(HealthCourse::getId));
+ List<SystemBannerCourseOptionVO> options = new ArrayList<SystemBannerCourseOptionVO>();
+ for (HealthCourse item : list) {
+ options.add(new SystemBannerCourseOptionVO(item.getId(), item.getTitle()));
+ }
+ return options;
+ }
+
+ public List<SystemBannerCourseOptionVO> liveCourseOptions(String keyword) {
+ List<LiveCourse> list = liveCourseMapper.selectList(new LambdaQueryWrapper<LiveCourse>()
+ .eq(LiveCourse::getDeleted, 0)
+ .like(keyword != null && !keyword.trim().isEmpty(), LiveCourse::getTitle, keyword == null ? null : keyword.trim())
+ .orderByDesc(LiveCourse::getId));
+ List<SystemBannerCourseOptionVO> options = new ArrayList<SystemBannerCourseOptionVO>();
+ for (LiveCourse item : list) {
+ options.add(new SystemBannerCourseOptionVO(item.getId(), item.getTitle()));
+ }
+ return options;
+ }
+
+ private void fillEntity(SystemBannerConfig item, SystemBannerSaveRequest request) {
+ LocalDateTime startAt = request.getStartAt();
+ LocalDateTime endAt = request.getEndAt();
+ if (!endAt.isAfter(startAt)) {
+ throw new IllegalArgumentException("banner结束时间必须晚于开始时间");
+ }
+
+ String jumpType = normalizeJumpType(request.getJumpType());
+ item.setBannerImg(request.getBannerImg().trim());
+ item.setTitle(request.getTitle().trim());
+ item.setStartAt(startAt);
+ item.setEndAt(endAt);
+ item.setJumpType(jumpType);
+ item.setPublishEnabled(booleanToInteger(request.getPublishEnabled()));
+
+ if (SystemBannerConfig.JUMP_TYPE_HEALTH_COURSE.equals(jumpType)) {
+ HealthCourse course = requireHealthCourse(request.getTargetId());
+ item.setTargetId(course.getId());
+ item.setTargetName(course.getTitle());
+ item.setTargetUrl(null);
+ return;
+ }
+
+ if (SystemBannerConfig.JUMP_TYPE_LIVE_COURSE.equals(jumpType)) {
+ LiveCourse course = requireLiveCourse(request.getTargetId());
+ item.setTargetId(course.getId());
+ item.setTargetName(course.getTitle());
+ item.setTargetUrl(null);
+ return;
+ }
+
+ if (SystemBannerConfig.JUMP_TYPE_MEMBER.equals(jumpType)) {
+ item.setTargetId(null);
+ item.setTargetName("会员");
+ item.setTargetUrl(null);
+ return;
+ }
+
+ String targetUrl = trimToNull(request.getTargetUrl());
+ if (targetUrl == null) {
+ throw new IllegalArgumentException("请输入地址信息");
+ }
+ item.setTargetId(null);
+ item.setTargetName(null);
+ item.setTargetUrl(targetUrl);
+ }
+
+ private String normalizeJumpType(String jumpType) {
+ if (jumpType == null) {
+ throw new IllegalArgumentException("请选择跳转类型");
+ }
+ String normalized = jumpType.trim().toUpperCase();
+ if (SystemBannerConfig.JUMP_TYPE_HEALTH_COURSE.equals(normalized)
+ || SystemBannerConfig.JUMP_TYPE_LIVE_COURSE.equals(normalized)
+ || SystemBannerConfig.JUMP_TYPE_MEMBER.equals(normalized)
+ || SystemBannerConfig.JUMP_TYPE_THIRD_PARTY_URL.equals(normalized)) {
+ return normalized;
+ }
+ throw new IllegalArgumentException("跳转类型不合法");
+ }
+
+ private HealthCourse requireHealthCourse(Long targetId) {
+ if (targetId == null) {
+ throw new IllegalArgumentException("请选择养生课程");
+ }
+ HealthCourse course = healthCourseMapper.selectById(targetId);
+ if (course == null || defaultNumber(course.getDeleted()) == 1) {
+ throw new IllegalArgumentException("养生课程不存在");
+ }
+ return course;
+ }
+
+ private LiveCourse requireLiveCourse(Long targetId) {
+ if (targetId == null) {
+ throw new IllegalArgumentException("请选择直播课程");
+ }
+ LiveCourse course = liveCourseMapper.selectById(targetId);
+ if (course == null || defaultNumber(course.getDeleted()) == 1) {
+ throw new IllegalArgumentException("直播课程不存在");
+ }
+ return course;
+ }
+
+ private SystemBannerConfig requireItem(Long id) {
+ SystemBannerConfig item = systemBannerConfigMapper.selectOne(new LambdaQueryWrapper<SystemBannerConfig>()
+ .eq(SystemBannerConfig::getId, id)
+ .eq(SystemBannerConfig::getDeleted, 0)
+ .last("limit 1"));
+ if (item == null) {
+ throw new IllegalArgumentException("banner不存在");
+ }
+ return item;
+ }
+
+ private SystemBannerListItemVO toListItem(SystemBannerConfig item) {
+ SystemBannerListItemVO vo = new SystemBannerListItemVO();
+ vo.setId(item.getId());
+ vo.setBannerImg(item.getBannerImg());
+ vo.setTitle(item.getTitle());
+ vo.setStartAt(item.getStartAt());
+ vo.setEndAt(item.getEndAt());
+ vo.setStatus(resolveDisplayStatus(item.getStartAt(), item.getEndAt()));
+ vo.setPublishEnabled(integerToBoolean(item.getPublishEnabled()));
+ vo.setJumpType(item.getJumpType());
+ vo.setJumpTargetDisplay(resolveJumpTargetDisplay(item));
+ vo.setCreatedAt(item.getCreatedAt());
+ return vo;
+ }
+
+ private SystemBannerDetailVO toDetailVO(SystemBannerConfig item) {
+ SystemBannerDetailVO vo = new SystemBannerDetailVO();
+ vo.setId(item.getId());
+ vo.setBannerImg(item.getBannerImg());
+ vo.setTitle(item.getTitle());
+ vo.setStartAt(item.getStartAt());
+ vo.setEndAt(item.getEndAt());
+ vo.setJumpType(item.getJumpType());
+ vo.setTargetId(item.getTargetId());
+ vo.setTargetName(item.getTargetName());
+ vo.setTargetUrl(item.getTargetUrl());
+ vo.setPublishEnabled(integerToBoolean(item.getPublishEnabled()));
+ vo.setStatus(resolveDisplayStatus(item.getStartAt(), item.getEndAt()));
+ vo.setCreatedAt(item.getCreatedAt());
+ vo.setUpdatedAt(item.getUpdatedAt());
+ return vo;
+ }
+
+ private Integer resolveDisplayStatus(LocalDateTime startAt, LocalDateTime endAt) {
+ LocalDateTime now = LocalDateTime.now();
+ if (startAt != null && now.isBefore(startAt)) {
+ return SystemBannerConfig.DISPLAY_STATUS_NOT_STARTED;
+ }
+ if (endAt != null && now.isAfter(endAt)) {
+ return SystemBannerConfig.DISPLAY_STATUS_ENDED;
+ }
+ return SystemBannerConfig.DISPLAY_STATUS_ONGOING;
+ }
+
+ private String resolveJumpTargetDisplay(SystemBannerConfig item) {
+ if (SystemBannerConfig.JUMP_TYPE_MEMBER.equals(item.getJumpType())) {
+ return "会员";
+ }
+ if (SystemBannerConfig.JUMP_TYPE_THIRD_PARTY_URL.equals(item.getJumpType())) {
+ return item.getTargetUrl();
+ }
+ return item.getTargetName();
+ }
+
+ private Integer booleanToInteger(Boolean value) {
+ return Boolean.TRUE.equals(value) ? 1 : 0;
+ }
+
+ private Boolean integerToBoolean(Integer value) {
+ return value != null && value == 1;
+ }
+
+ private Integer defaultNumber(Integer value) {
+ return value == null ? 0 : value;
+ }
+
+ private String trimToNull(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java b/backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java
new file mode 100644
index 0000000..fc1c45d
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java
@@ -0,0 +1,64 @@
+package com.tcm.training.admin.system.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.tcm.training.admin.system.dto.SystemSettingsSaveRequest;
+import com.tcm.training.admin.system.entity.SystemSettings;
+import com.tcm.training.admin.system.mapper.SystemSettingsMapper;
+import com.tcm.training.admin.system.vo.SystemSettingsVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class SystemSettingsAppService {
+
+ private final SystemSettingsMapper systemSettingsMapper;
+
+ public SystemSettingsAppService(SystemSettingsMapper systemSettingsMapper) {
+ this.systemSettingsMapper = systemSettingsMapper;
+ }
+
+ public SystemSettingsVO current() {
+ SystemSettings config = systemSettingsMapper.selectOne(new LambdaQueryWrapper<SystemSettings>()
+ .eq(SystemSettings::getDeleted, 0)
+ .orderByDesc(SystemSettings::getId)
+ .last("limit 1"));
+ return toVO(config);
+ }
+
+ @Transactional
+ public SystemSettingsVO saveCurrent(SystemSettingsSaveRequest request) {
+ SystemSettings config = systemSettingsMapper.selectOne(new LambdaQueryWrapper<SystemSettings>()
+ .eq(SystemSettings::getDeleted, 0)
+ .orderByDesc(SystemSettings::getId)
+ .last("limit 1"));
+ if (config == null) {
+ config = new SystemSettings();
+ fillConfig(config, request);
+ systemSettingsMapper.insert(config);
+ } else {
+ fillConfig(config, request);
+ systemSettingsMapper.updateById(config);
+ }
+ return toVO(config);
+ }
+
+ private void fillConfig(SystemSettings config, SystemSettingsSaveRequest request) {
+ config.setServicePhone(request.getServicePhone().trim());
+ config.setServiceOnlineTime(request.getServiceOnlineTime().trim());
+ config.setBackupCycleDays(request.getBackupCycleDays());
+ config.setMemberExpireRemindDays(request.getMemberExpireRemindDays());
+ }
+
+ private SystemSettingsVO toVO(SystemSettings config) {
+ if (config == null) {
+ return null;
+ }
+ SystemSettingsVO vo = new SystemSettingsVO();
+ vo.setId(config.getId());
+ vo.setServicePhone(config.getServicePhone());
+ vo.setServiceOnlineTime(config.getServiceOnlineTime());
+ vo.setBackupCycleDays(config.getBackupCycleDays());
+ vo.setMemberExpireRemindDays(config.getMemberExpireRemindDays());
+ return vo;
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/ActivityNoticeListItemVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/ActivityNoticeListItemVO.java
new file mode 100644
index 0000000..956ef2d
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/ActivityNoticeListItemVO.java
@@ -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 ActivityNoticeListItemVO {
+
+ private Long id;
+ private String title;
+ private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionCheckVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionCheckVO.java
new file mode 100644
index 0000000..ec5421d
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionCheckVO.java
@@ -0,0 +1,18 @@
+package com.tcm.training.admin.system.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppVersionCheckVO {
+
+ private Boolean hasUpdate;
+ private String latestVersionNo;
+ private Integer latestVersionCode;
+ private Boolean forceUpdate;
+ private String updateNotice;
+ private String downloadUrl;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionDetailVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionDetailVO.java
new file mode 100644
index 0000000..d54fe2b
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionDetailVO.java
@@ -0,0 +1,24 @@
+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 AppVersionDetailVO {
+
+ private Long id;
+ private String versionNo;
+ private Integer versionCode;
+ private String androidFileUrl;
+ private String androidFileName;
+ private String iosFileUrl;
+ private String iosFileName;
+ private Boolean forceUpdate;
+ private String updateNotice;
+ private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionListItemVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionListItemVO.java
new file mode 100644
index 0000000..7c6d1e1
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/AppVersionListItemVO.java
@@ -0,0 +1,18 @@
+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 AppVersionListItemVO {
+
+ private Long id;
+ private String versionNo;
+ private String updateNotice;
+ private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/MessagePushSettingVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/MessagePushSettingVO.java
new file mode 100644
index 0000000..ca25afc
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/MessagePushSettingVO.java
@@ -0,0 +1,20 @@
+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 MessagePushSettingVO {
+
+ private Long id;
+ private String noticeTypeCode;
+ private String noticeTypeName;
+ private Boolean pushEnabled;
+ private Boolean smsEnabled;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerCourseOptionVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerCourseOptionVO.java
new file mode 100644
index 0000000..52597bc
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerCourseOptionVO.java
@@ -0,0 +1,19 @@
+package com.tcm.training.admin.system.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemBannerCourseOptionVO {
+
+ private Long id;
+ private String title;
+
+ public SystemBannerCourseOptionVO(Long id, String title) {
+ this.id = id;
+ this.title = title;
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerDetailVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerDetailVO.java
new file mode 100644
index 0000000..10469ac
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerDetailVO.java
@@ -0,0 +1,27 @@
+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 SystemBannerDetailVO {
+
+ private Long id;
+ private String bannerImg;
+ private String title;
+ private LocalDateTime startAt;
+ private LocalDateTime endAt;
+ private String jumpType;
+ private Long targetId;
+ private String targetName;
+ private String targetUrl;
+ private Boolean publishEnabled;
+ private Integer status;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerListItemVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerListItemVO.java
new file mode 100644
index 0000000..b027e18
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerListItemVO.java
@@ -0,0 +1,24 @@
+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 SystemBannerListItemVO {
+
+ private Long id;
+ private String bannerImg;
+ private String title;
+ private LocalDateTime startAt;
+ private LocalDateTime endAt;
+ private Integer status;
+ private Boolean publishEnabled;
+ private String jumpType;
+ private String jumpTargetDisplay;
+ private LocalDateTime createdAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerPublishStatusVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerPublishStatusVO.java
new file mode 100644
index 0000000..d1ad2ec
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemBannerPublishStatusVO.java
@@ -0,0 +1,19 @@
+package com.tcm.training.admin.system.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemBannerPublishStatusVO {
+
+ private Long id;
+ private Boolean publishEnabled;
+
+ public SystemBannerPublishStatusVO(Long id, Boolean publishEnabled) {
+ this.id = id;
+ this.publishEnabled = publishEnabled;
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java
new file mode 100644
index 0000000..70e20af
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java
@@ -0,0 +1,17 @@
+package com.tcm.training.admin.system.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class SystemSettingsVO {
+
+ private Long id;
+ private String servicePhone;
+ private String serviceOnlineTime;
+ private Integer backupCycleDays;
+ private Integer memberExpireRemindDays;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/upload/UploadController.java b/backend/src/main/java/com/tcm/training/admin/upload/UploadController.java
index fdf149b..2c37f64 100644
--- a/backend/src/main/java/com/tcm/training/admin/upload/UploadController.java
+++ b/backend/src/main/java/com/tcm/training/admin/upload/UploadController.java
@@ -7,6 +7,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
+import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.Arrays;
@@ -19,12 +20,24 @@
private static final long MAX_SIZE = 5 * 1024 * 1024L; // 5MB
private static final long VIDEO_MAX_SIZE = 200 * 1024 * 1024L; // 200MB
+ private static final long APP_PACKAGE_MAX_SIZE = 500 * 1024 * 1024L; // 500MB
private static final Set<String> ALLOWED_TYPES = new HashSet<>(
Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp")
);
private static final Set<String> ALLOWED_VIDEO_TYPES = new HashSet<>(
Arrays.asList("video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/webm")
);
+ private static final Set<String> ALLOWED_APP_PACKAGE_TYPES = new HashSet<>(
+ Arrays.asList(
+ "application/vnd.android.package-archive",
+ "application/octet-stream",
+ "application/x-itunes-ipa",
+ "application/zip",
+ "application/x-zip-compressed"
+ )
+ );
+ private static final String PLATFORM_ANDROID = "ANDROID";
+ private static final String PLATFORM_IOS = "IOS";
private final ObsUploadService obsUploadService;
private final UploadProgressService uploadProgressService;
@@ -52,6 +65,16 @@
return ApiResponse.ok(storeFile(file, VIDEO_MAX_SIZE, ALLOWED_VIDEO_TYPES, "仅支持 MP4、MOV、AVI、MKV、WebM 格式", "video", uploadTaskId));
}
+ @PostMapping("/upload/app-package")
+ public ApiResponse<UploadResult> uploadAppPackage(
+ @RequestParam("file") MultipartFile file,
+ @RequestParam("platform") String platform) throws IOException {
+ String normalizedPlatform = normalizePlatform(platform);
+ validateAppPackage(file, normalizedPlatform);
+ UploadFileResult uploadResult = obsUploadService.upload(file, "app-package/" + normalizedPlatform.toLowerCase(), null);
+ return ApiResponse.ok(new UploadResult(uploadResult.getUrl(), uploadResult.getFileName()));
+ }
+
@GetMapping("/upload/video/progress")
public ApiResponse<UploadProgressInfo> uploadVideoProgress(
@RequestParam("taskId") String taskId) {
@@ -64,12 +87,7 @@
String typeMessage,
String bizPath,
String uploadTaskId) throws IOException {
- if (file == null || file.isEmpty()) {
- throw new IllegalArgumentException("请选择要上传的文件");
- }
- if (file.getSize() > maxSize) {
- throw new IllegalArgumentException("文件大小超出限制");
- }
+ validateFileSize(file, maxSize);
String contentType = file.getContentType();
if (contentType == null || !allowedTypes.contains(contentType)) {
throw new IllegalArgumentException(typeMessage);
@@ -78,6 +96,45 @@
return new UploadResult(uploadResult.getUrl(), uploadResult.getFileName());
}
+ private void validateFileSize(MultipartFile file, long maxSize) {
+ if (file == null || file.isEmpty()) {
+ throw new IllegalArgumentException("请选择要上传的文件");
+ }
+ if (file.getSize() > maxSize) {
+ throw new IllegalArgumentException("文件大小超出限制");
+ }
+ }
+
+ private void validateAppPackage(MultipartFile file, String platform) {
+ validateFileSize(file, APP_PACKAGE_MAX_SIZE);
+ String originalName = file.getOriginalFilename();
+ String extension = "";
+ if (StringUtils.hasText(originalName) && originalName.contains(".")) {
+ extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
+ }
+ if (PLATFORM_ANDROID.equals(platform) && !"apk".equals(extension)) {
+ throw new IllegalArgumentException("安卓安装包仅支持 APK 格式");
+ }
+ if (PLATFORM_IOS.equals(platform) && !"ipa".equals(extension)) {
+ throw new IllegalArgumentException("iOS 安装包仅支持 IPA 格式");
+ }
+ String contentType = file.getContentType();
+ if (StringUtils.hasText(contentType) && !ALLOWED_APP_PACKAGE_TYPES.contains(contentType)) {
+ throw new IllegalArgumentException("安装包格式不合法");
+ }
+ }
+
+ private String normalizePlatform(String platform) {
+ if (!StringUtils.hasText(platform)) {
+ throw new IllegalArgumentException("平台类型不能为空");
+ }
+ String normalized = platform.trim().toUpperCase();
+ if (PLATFORM_ANDROID.equals(normalized) || PLATFORM_IOS.equals(normalized)) {
+ return normalized;
+ }
+ throw new IllegalArgumentException("平台类型不合法");
+ }
+
public static class UploadResult {
private final String url;
private final String fileName;
diff --git a/backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java b/backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java
new file mode 100644
index 0000000..aa0fec2
--- /dev/null
+++ b/backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java
@@ -0,0 +1,84 @@
+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.ActivityNoticeCreateRequest;
+import com.tcm.training.admin.system.entity.ActivityNotice;
+import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
+import com.tcm.training.admin.system.vo.ActivityNoticeListItemVO;
+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 ActivityNoticeAppServiceTest {
+
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, ActivityNotice.class);
+ }
+
+ @Mock
+ private ActivityNoticeMapper activityNoticeMapper;
+
+ @InjectMocks
+ private ActivityNoticeAppService activityNoticeAppService;
+
+ @Captor
+ private ArgumentCaptor<ActivityNotice> activityNoticeCaptor;
+
+ @Test
+ void createShouldTrimTitleAndPersistContent() {
+ doAnswer(invocation -> {
+ ActivityNotice item = invocation.getArgument(0);
+ item.setId(1L);
+ return 1;
+ }).when(activityNoticeMapper).insert(any(ActivityNotice.class));
+
+ ActivityNotice persisted = new ActivityNotice();
+ persisted.setId(1L);
+ persisted.setTitle("春季活动");
+ persisted.setContentHtml("<p>活动内容</p>");
+ persisted.setCreatedAt(LocalDateTime.of(2026, 3, 26, 18, 10, 0));
+ when(activityNoticeMapper.selectOne(any())).thenReturn(persisted);
+
+ ActivityNoticeCreateRequest request = new ActivityNoticeCreateRequest();
+ request.setTitle(" 春季活动 ");
+ request.setContentHtml("<p>活动内容</p>");
+
+ ActivityNoticeListItemVO result = activityNoticeAppService.create(request);
+
+ verify(activityNoticeMapper).insert(activityNoticeCaptor.capture());
+ assertEquals("春季活动", activityNoticeCaptor.getValue().getTitle());
+ assertEquals("<p>活动内容</p>", activityNoticeCaptor.getValue().getContentHtml());
+ assertEquals("春季活动", result.getTitle());
+ }
+
+ @Test
+ void createShouldRejectBlankRichTextContent() {
+ ActivityNoticeCreateRequest request = new ActivityNoticeCreateRequest();
+ request.setTitle("活动标题");
+ request.setContentHtml("<p><br/></p>");
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> activityNoticeAppService.create(request));
+
+ assertEquals("请输入通知内容", exception.getMessage());
+ }
+}
diff --git a/backend/src/test/java/com/tcm/training/admin/system/service/AppVersionSettingsAppServiceTest.java b/backend/src/test/java/com/tcm/training/admin/system/service/AppVersionSettingsAppServiceTest.java
new file mode 100644
index 0000000..5e917c9
--- /dev/null
+++ b/backend/src/test/java/com/tcm/training/admin/system/service/AppVersionSettingsAppServiceTest.java
@@ -0,0 +1,105 @@
+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.AppVersionCreateRequest;
+import com.tcm.training.admin.system.entity.AppVersionConfig;
+import com.tcm.training.admin.system.mapper.AppVersionConfigMapper;
+import com.tcm.training.admin.system.vo.AppVersionCheckVO;
+import com.tcm.training.admin.system.vo.AppVersionDetailVO;
+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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AppVersionSettingsAppServiceTest {
+
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, AppVersionConfig.class);
+ }
+
+ @Mock
+ private AppVersionConfigMapper appVersionConfigMapper;
+
+ @InjectMocks
+ private AppVersionSettingsAppService appVersionSettingsAppService;
+
+ @Captor
+ private ArgumentCaptor<AppVersionConfig> appVersionCaptor;
+
+ @Test
+ void createShouldIncrementVersionCodeFromLatestRecord() {
+ AppVersionConfig latest = new AppVersionConfig();
+ latest.setId(9L);
+ latest.setVersionCode(3);
+ when(appVersionConfigMapper.selectOne(any())).thenReturn(latest);
+
+ AppVersionCreateRequest request = new AppVersionCreateRequest();
+ request.setVersionNo("1.0.3");
+ request.setAndroidFileUrl("https://cdn.test/app-android.apk");
+ request.setAndroidFileName("app-android.apk");
+ request.setIosFileUrl("https://cdn.test/app-ios.ipa");
+ request.setIosFileName("app-ios.ipa");
+ request.setForceUpdate(Boolean.TRUE);
+ request.setUpdateNotice("修复已知问题");
+
+ AppVersionDetailVO result = appVersionSettingsAppService.create(request);
+
+ verify(appVersionConfigMapper).insert(appVersionCaptor.capture());
+ assertEquals(Integer.valueOf(4), appVersionCaptor.getValue().getVersionCode());
+ assertEquals(Integer.valueOf(4), result.getVersionCode());
+ assertTrue(result.getForceUpdate());
+ }
+
+ @Test
+ void checkUpdateShouldReturnLatestDownloadForAndroid() {
+ AppVersionConfig latest = new AppVersionConfig();
+ latest.setVersionNo("1.0.5");
+ latest.setVersionCode(5);
+ latest.setAndroidFileUrl("https://cdn.test/app-android.apk");
+ latest.setIosFileUrl("https://cdn.test/app-ios.ipa");
+ latest.setForceUpdate(1);
+ latest.setUpdateNotice("新增版本内容");
+ when(appVersionConfigMapper.selectOne(any())).thenReturn(latest);
+
+ AppVersionCheckVO result = appVersionSettingsAppService.checkUpdate("android", 3);
+
+ assertTrue(result.getHasUpdate());
+ assertEquals("1.0.5", result.getLatestVersionNo());
+ assertEquals(Integer.valueOf(5), result.getLatestVersionCode());
+ assertEquals("https://cdn.test/app-android.apk", result.getDownloadUrl());
+ assertTrue(result.getForceUpdate());
+ }
+
+ @Test
+ void checkUpdateShouldReturnFalseWhenCurrentVersionIsLatest() {
+ AppVersionConfig latest = new AppVersionConfig();
+ latest.setVersionNo("1.0.5");
+ latest.setVersionCode(5);
+ latest.setAndroidFileUrl("https://cdn.test/app-android.apk");
+ latest.setIosFileUrl("https://cdn.test/app-ios.ipa");
+ latest.setForceUpdate(0);
+ latest.setUpdateNotice("新增版本内容");
+ when(appVersionConfigMapper.selectOne(any())).thenReturn(latest);
+
+ AppVersionCheckVO result = appVersionSettingsAppService.checkUpdate("IOS", 5);
+
+ assertFalse(result.getHasUpdate());
+ assertEquals("https://cdn.test/app-ios.ipa", result.getDownloadUrl());
+ }
+}
diff --git a/tcm-training-admin/config/routes.ts b/tcm-training-admin/config/routes.ts
index bab682f..29e5c71 100644
--- a/tcm-training-admin/config/routes.ts
+++ b/tcm-training-admin/config/routes.ts
@@ -71,7 +71,38 @@
routes: [
{
path: '/system/settings',
- redirect: '/system/settings/points',
+ hideInMenu: true,
+ component: './system/settings-redirect',
+ },
+ {
+ name: '系统设置',
+ path: '/system/settings/basic',
+ access: 'system:basic-settings:view',
+ component: './system/basic-settings',
+ },
+ {
+ name: 'APP版本设置',
+ path: '/system/settings/app-version',
+ access: 'system:app-version:view',
+ component: './system/app-version-settings',
+ },
+ {
+ name: 'Banner配置',
+ path: '/system/settings/banner',
+ access: 'system:banner:view',
+ component: './system/banner-settings',
+ },
+ {
+ name: '活动通知',
+ path: '/system/settings/activity-notice',
+ access: 'system:activity-notice:view',
+ component: './system/activity-notice',
+ },
+ {
+ name: '消息推送配置',
+ path: '/system/settings/message-push',
+ access: 'system:message-push:view',
+ component: './system/message-push-settings',
},
{
name: '积分设置',
diff --git a/tcm-training-admin/src/access.js b/tcm-training-admin/src/access.js
index 244b414..0381ae8 100644
--- a/tcm-training-admin/src/access.js
+++ b/tcm-training-admin/src/access.js
@@ -14,6 +14,11 @@
canViewSystemSecurity:
!!permission?.['system:account:view'] ||
!!permission?.['system:role:view'] ||
+ !!permission?.['system:basic-settings:view'] ||
+ !!permission?.['system:app-version:view'] ||
+ !!permission?.['system:banner:view'] ||
+ !!permission?.['system:activity-notice:view'] ||
+ !!permission?.['system:message-push:view'] ||
!!permission?.['system:points-settings:view'],
canViewMemberModule: canViewMemberUserGroup || canViewMemberVipGroup,
canViewMemberUserGroup,
diff --git a/tcm-training-admin/src/pages/system/activity-notice/index.tsx b/tcm-training-admin/src/pages/system/activity-notice/index.tsx
new file mode 100644
index 0000000..9793829
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/activity-notice/index.tsx
@@ -0,0 +1,287 @@
+import BraftEditorCom from '@/components/BraftEditor';
+import { PlusOutlined } from '@ant-design/icons';
+import { history, useAccess } from '@umijs/max';
+import { Button, Form, Input, Modal, Pagination, Table, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import React, { useEffect, useState } from 'react';
+import { createActivityNotice, deleteActivityNotice, getActivityNoticePage } from './service';
+import './style.less';
+
+type SearchValues = {
+ title?: string;
+};
+
+type ActivityNoticeItem = {
+ id: number;
+ title: string;
+ createdAt?: string;
+};
+
+type ActivityNoticeFormValues = {
+ title?: string;
+ contentHtml?: string;
+};
+
+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 ActivityNoticePage: React.FC = () => {
+ const access = useAccess();
+ const [searchForm] = Form.useForm<SearchValues>();
+ const [form] = Form.useForm<ActivityNoticeFormValues>();
+ const [list, setList] = useState<ActivityNoticeItem[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
+
+ const canView = access['system:activity-notice:view'];
+ const canAdd = access['system:activity-notice:add'];
+ const canDelete = access['system:activity-notice:delete'];
+
+ const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
+ setLoading(true);
+ try {
+ const res = await getActivityNoticePage({
+ 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);
+ form.resetFields();
+ };
+
+ const handleCreate = async () => {
+ const values = await form.validateFields();
+ if (isBlankRichText(values.contentHtml)) {
+ form.setFields([
+ {
+ name: 'contentHtml',
+ errors: ['请输入通知内容'],
+ },
+ ]);
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await createActivityNotice({
+ title: values.title?.trim(),
+ contentHtml: values.contentHtml?.trim(),
+ });
+ message.success('新建成功');
+ setModalOpen(false);
+ form.resetFields();
+ await loadList(1, pagination.pageSize);
+ } catch (error) {
+ message.error((error as Error)?.message || '新建失败,请稍后重试');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDelete = (record: ActivityNoticeItem) => {
+ Modal.confirm({
+ title: '确定要删除该活动通知吗?',
+ okText: '删除',
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ await deleteActivityNotice(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 columns: ColumnsType<ActivityNoticeItem> = [
+ {
+ title: '序号',
+ width: 80,
+ render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
+ },
+ {
+ title: '通知标题',
+ dataIndex: 'title',
+ ellipsis: true,
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'createdAt',
+ width: 180,
+ },
+ {
+ title: '操作',
+ width: 120,
+ render: (_, record) =>
+ canDelete ? (
+ <Button type="link" size="small" onClick={() => handleDelete(record)}>
+ 删除
+ </Button>
+ ) : null,
+ },
+ ];
+
+ return (
+ <div className="activity-notice-page">
+ <div className="activity-notice-card">
+ <div className="activity-notice-card__title">活动通知</div>
+ <div className="activity-notice-search-card">
+ <Form form={searchForm} layout="horizontal" className="activity-notice-search-form">
+ <Form.Item label="通知标题" name="title">
+ <Input placeholder="请输入" allowClear />
+ </Form.Item>
+ <div className="activity-notice-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="activity-notice-toolbar">
+ {canAdd ? (
+ <Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
+ 新建
+ </Button>
+ ) : null}
+ </div>
+
+ <div className="activity-notice-table-card">
+ <Table<ActivityNoticeItem>
+ rowKey="id"
+ columns={columns}
+ dataSource={list}
+ loading={loading}
+ pagination={false}
+ scroll={{ x: 900 }}
+ />
+ <div className="activity-notice-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="activity-notice-modal"
+ destroyOnClose
+ centered
+ width={820}
+ title="新建活动"
+ onCancel={handleCloseModal}
+ footer={[
+ <Button key="cancel" onClick={handleCloseModal} disabled={submitting}>
+ 取消
+ </Button>,
+ <Button key="submit" type="primary" loading={submitting} onClick={handleCreate}>
+ 确定
+ </Button>,
+ ]}
+ >
+ <Form form={form} layout="horizontal" className="activity-notice-form" labelCol={{ span: 4 }}>
+ <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={[
+ { required: true, message: '请输入通知内容' },
+ {
+ validator: async (_, value) => {
+ if (isBlankRichText(value)) {
+ throw new Error('请输入通知内容');
+ }
+ },
+ },
+ ]}
+ >
+ <div className="activity-notice-form__editor">
+ <BraftEditorCom
+ data={form.getFieldValue('contentHtml') || ''}
+ resultCallback={(html: string) => form.setFieldsValue({ contentHtml: html })}
+ />
+ </div>
+ </Form.Item>
+ </Form>
+ </Modal>
+ </div>
+ );
+};
+
+export default ActivityNoticePage;
diff --git a/tcm-training-admin/src/pages/system/activity-notice/service.ts b/tcm-training-admin/src/pages/system/activity-notice/service.ts
new file mode 100644
index 0000000..71f65c9
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/activity-notice/service.ts
@@ -0,0 +1,21 @@
+import { request } from '@umijs/max';
+
+export async function getActivityNoticePage(params?: Record<string, any>) {
+ return request('/activity-notices', {
+ method: 'GET',
+ params,
+ });
+}
+
+export async function createActivityNotice(data: Record<string, any>) {
+ return request('/activity-notices', {
+ method: 'POST',
+ data,
+ });
+}
+
+export async function deleteActivityNotice(id: number | string) {
+ return request(`/activity-notices/${id}`, {
+ method: 'DELETE',
+ });
+}
diff --git a/tcm-training-admin/src/pages/system/activity-notice/style.less b/tcm-training-admin/src/pages/system/activity-notice/style.less
new file mode 100644
index 0000000..ac3fcc7
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/activity-notice/style.less
@@ -0,0 +1,123 @@
+.activity-notice-page {
+ min-height: calc(100vh - 48px);
+ padding: 18px 20px 24px;
+ background: #f2f5f9;
+}
+
+.activity-notice-card {
+ min-height: 700px;
+ padding: 8px 14px 18px;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.activity-notice-card__title {
+ margin: 0 0 22px;
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.activity-notice-search-card {
+ margin-bottom: 20px;
+}
+
+.activity-notice-search-form {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 18px;
+ align-items: center;
+}
+
+.activity-notice-search-form .ant-form-item {
+ margin-bottom: 0;
+}
+
+.activity-notice-search-form .ant-input {
+ width: 170px;
+}
+
+.activity-notice-search-form__actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.activity-notice-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 14px;
+}
+
+.activity-notice-table-card {
+ overflow: hidden;
+ border: 1px solid #e6ebf2;
+ border-radius: 4px;
+}
+
+.activity-notice-table-card .ant-table-thead > tr > th {
+ color: #303133;
+ font-weight: 500;
+ background: #f5f7fa;
+}
+
+.activity-notice-table-card .ant-table-tbody > tr > td {
+ color: #606266;
+ vertical-align: middle;
+}
+
+.activity-notice-pagination {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 18px;
+ background: #fff;
+}
+
+.activity-notice-modal .ant-modal-content {
+ padding: 18px 24px 20px;
+ border-radius: 8px;
+}
+
+.activity-notice-modal .ant-modal-title {
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.activity-notice-modal .ant-modal-body {
+ padding-top: 12px;
+}
+
+.activity-notice-form .ant-form-item {
+ margin-bottom: 18px;
+}
+
+.activity-notice-form .ant-form-item-label > label {
+ color: #303133;
+}
+
+.activity-notice-form__editor {
+ border: 1px solid #dcdfe6;
+ border-radius: 4px;
+}
+
+@media (max-width: 900px) {
+ .activity-notice-page {
+ padding: 12px;
+ }
+
+ .activity-notice-card {
+ min-height: auto;
+ padding: 16px;
+ }
+
+ .activity-notice-search-form {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .activity-notice-search-form .ant-input {
+ width: 100%;
+ }
+}
diff --git a/tcm-training-admin/src/pages/system/app-version-settings/index.tsx b/tcm-training-admin/src/pages/system/app-version-settings/index.tsx
new file mode 100644
index 0000000..17eb4a5
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/app-version-settings/index.tsx
@@ -0,0 +1,392 @@
+import {
+ createAppVersion,
+ getAppVersionDetail,
+ getAppVersionPage,
+ uploadAppPackage,
+} from './service';
+import { UploadOutlined } from '@ant-design/icons';
+import { history, useAccess } from '@umijs/max';
+import { Button, Form, Input, Modal, Pagination, Spin, Switch, Table, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import React, { useEffect, useState } from 'react';
+import './style.less';
+
+type AppVersionItem = {
+ createdAt: string;
+ id: number;
+ updateNotice: string;
+ versionNo: string;
+};
+
+type AppVersionDetail = {
+ androidFileName?: string;
+ androidFileUrl?: string;
+ forceUpdate?: boolean;
+ id: number;
+ iosFileName?: string;
+ iosFileUrl?: string;
+ updateNotice?: string;
+ versionCode?: number;
+ versionNo?: string;
+};
+
+type AppVersionFormValues = {
+ androidFileName?: string;
+ androidFileUrl?: string;
+ forceUpdate?: boolean;
+ iosFileName?: string;
+ iosFileUrl?: string;
+ updateNotice?: string;
+ versionNo?: string;
+};
+
+type ModalMode = 'create' | 'detail';
+
+const defaultValues: AppVersionFormValues = {
+ versionNo: '',
+ androidFileUrl: '',
+ androidFileName: '',
+ iosFileUrl: '',
+ iosFileName: '',
+ forceUpdate: false,
+ updateNotice: '',
+};
+
+const AppVersionSettingsPage: React.FC = () => {
+ const access = useAccess();
+ const [form] = Form.useForm<AppVersionFormValues>();
+ const androidFileName = Form.useWatch('androidFileName', form);
+ const iosFileName = Form.useWatch('iosFileName', form);
+ const [pageLoading, setPageLoading] = useState(false);
+ const [modalLoading, setModalLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [list, setList] = useState<AppVersionItem[]>([]);
+ const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modalMode, setModalMode] = useState<ModalMode>('create');
+ const [uploadingAndroid, setUploadingAndroid] = useState(false);
+ const [uploadingIos, setUploadingIos] = useState(false);
+
+ const canView = access['system:app-version:view'];
+ const canAdd = access['system:app-version:add'];
+ const canDetail = access['system:app-version:detail'];
+ const isDetailMode = modalMode === 'detail';
+
+ const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
+ setPageLoading(true);
+ try {
+ const res = await getAppVersionPage({ page, pageSize });
+ 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 || 'APP版本列表加载失败');
+ } finally {
+ setPageLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!canView) {
+ history.replace('/Welcome');
+ return;
+ }
+ loadList(1, 10);
+ }, [canView]);
+
+ const openCreateModal = () => {
+ setModalMode('create');
+ setModalLoading(false);
+ form.resetFields();
+ form.setFieldsValue(defaultValues);
+ setModalOpen(true);
+ };
+
+ const openDetailModal = async (record: AppVersionItem) => {
+ setModalMode('detail');
+ form.resetFields();
+ setModalOpen(true);
+ setModalLoading(true);
+ try {
+ const res = await getAppVersionDetail(record.id);
+ const detail: AppVersionDetail = res.data;
+ form.setFieldsValue({
+ versionNo: detail.versionNo || '',
+ androidFileUrl: detail.androidFileUrl || '',
+ androidFileName: detail.androidFileName || '',
+ iosFileUrl: detail.iosFileUrl || '',
+ iosFileName: detail.iosFileName || '',
+ forceUpdate: detail.forceUpdate || false,
+ updateNotice: detail.updateNotice || '',
+ });
+ } catch (error) {
+ message.error((error as Error)?.message || 'APP版本详情加载失败');
+ setModalOpen(false);
+ } finally {
+ setModalLoading(false);
+ }
+ };
+
+ const handleCloseModal = () => {
+ if (submitting || uploadingAndroid || uploadingIos) {
+ return;
+ }
+ setModalOpen(false);
+ setModalLoading(false);
+ form.resetFields();
+ };
+
+ const handlePackageUpload = async (file: File, platform: 'ANDROID' | 'IOS') => {
+ const setUploading = platform === 'ANDROID' ? setUploadingAndroid : setUploadingIos;
+ const urlField = platform === 'ANDROID' ? 'androidFileUrl' : 'iosFileUrl';
+ const nameField = platform === 'ANDROID' ? 'androidFileName' : 'iosFileName';
+ setUploading(true);
+ try {
+ const res = await uploadAppPackage(file, platform);
+ const url = res.data?.url;
+ if (!url) {
+ throw new Error('上传失败');
+ }
+ form.setFieldsValue({
+ [urlField]: url,
+ [nameField]: res.data?.fileName || file.name,
+ });
+ message.success(platform === 'ANDROID' ? '安卓安装包上传成功' : 'iOS安装包上传成功');
+ } catch (error) {
+ message.error((error as Error)?.message || '安装包上传失败');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (isDetailMode) {
+ handleCloseModal();
+ return;
+ }
+ const values = await form.validateFields();
+ setSubmitting(true);
+ try {
+ await createAppVersion({
+ versionNo: values.versionNo?.trim(),
+ androidFileUrl: values.androidFileUrl?.trim(),
+ androidFileName: values.androidFileName?.trim(),
+ iosFileUrl: values.iosFileUrl?.trim(),
+ iosFileName: values.iosFileName?.trim(),
+ forceUpdate: !!values.forceUpdate,
+ updateNotice: values.updateNotice?.trim(),
+ });
+ message.success('新增成功');
+ setModalOpen(false);
+ form.resetFields();
+ await loadList(1, pagination.pageSize);
+ } catch (error) {
+ message.error((error as Error)?.message || '新增失败,请稍后重试');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const columns: ColumnsType<AppVersionItem> = [
+ {
+ title: '序号',
+ width: 72,
+ render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
+ },
+ {
+ title: '版本号',
+ dataIndex: 'versionNo',
+ width: 150,
+ },
+ {
+ title: '更新提示',
+ dataIndex: 'updateNotice',
+ ellipsis: true,
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'createdAt',
+ width: 180,
+ },
+ {
+ title: '操作',
+ width: 120,
+ render: (_, record) =>
+ canDetail ? (
+ <button
+ type="button"
+ className="app-version-settings-table__link"
+ onClick={() => openDetailModal(record)}
+ >
+ 查看
+ </button>
+ ) : null,
+ },
+ ];
+
+ return (
+ <div className="app-version-settings-page">
+ <div className="app-version-settings-panel">
+ <div className="app-version-settings-panel__title">APP版本设置</div>
+ <div className="app-version-settings-toolbar">
+ {canAdd ? (
+ <Button type="primary" onClick={openCreateModal}>
+ 新增版本号
+ </Button>
+ ) : null}
+ </div>
+ <div className="app-version-settings-table-card">
+ <Table<AppVersionItem>
+ rowKey="id"
+ columns={columns}
+ dataSource={list}
+ loading={pageLoading}
+ pagination={false}
+ scroll={{ x: 920 }}
+ />
+ {pagination.total > pagination.pageSize ? (
+ <div className="app-version-settings-pagination">
+ <Pagination
+ current={pagination.page}
+ pageSize={pagination.pageSize}
+ total={pagination.total}
+ showSizeChanger
+ showQuickJumper
+ showTotal={(total) => `共 ${total} 条`}
+ onChange={(page, pageSize) => loadList(page, pageSize)}
+ />
+ </div>
+ ) : null}
+ </div>
+ </div>
+
+ <Modal
+ open={modalOpen}
+ title={isDetailMode ? '查看APP版本' : '新增APP版本'}
+ className="app-version-settings-modal"
+ width={760}
+ destroyOnClose
+ centered
+ onCancel={handleCloseModal}
+ footer={[
+ <Button key="cancel" onClick={handleCloseModal} disabled={submitting}>
+ {isDetailMode ? '关闭' : '取消'}
+ </Button>,
+ isDetailMode ? null : (
+ <Button key="submit" type="primary" loading={submitting} onClick={handleSubmit}>
+ 确定
+ </Button>
+ ),
+ ]}
+ >
+ <Spin spinning={modalLoading}>
+ <Form
+ form={form}
+ layout="horizontal"
+ className="app-version-settings-form"
+ disabled={isDetailMode}
+ labelCol={{ flex: '78px' }}
+ wrapperCol={{ flex: '1' }}
+ >
+ <Form.Item
+ label="版本号"
+ name="versionNo"
+ rules={[
+ { required: true, message: '请输入版本号' },
+ { max: 32, message: '版本号长度不能超过32个字符' },
+ ]}
+ className="app-version-settings-form__version-item"
+ >
+ <Input placeholder="请输入" maxLength={32} />
+ </Form.Item>
+
+ <div className="app-version-settings-form__aligned-content">
+ <div className="app-version-settings-form__upload-row">
+ <div className="app-version-settings-form__upload-col">
+ <div className="app-version-settings-form__upload-label">安卓文件:</div>
+ <Form.Item name="androidFileUrl" rules={[{ required: true, message: '请上传安卓文件' }]} hidden>
+ <Input />
+ </Form.Item>
+ <Form.Item name="androidFileName" rules={[{ required: true, message: '请填写安卓文件名称' }]} hidden>
+ <Input />
+ </Form.Item>
+ <label className="app-version-settings-form__upload-trigger">
+ <input
+ type="file"
+ accept=".apk"
+ hidden
+ disabled={isDetailMode}
+ onChange={async (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file) {
+ return;
+ }
+ await handlePackageUpload(file, 'ANDROID');
+ }}
+ />
+ <Button icon={<UploadOutlined />} loading={uploadingAndroid} disabled={isDetailMode}>
+ 上传
+ </Button>
+ </label>
+ <div className="app-version-settings-form__file-name">{androidFileName || '未上传文件'}</div>
+ </div>
+
+ <div className="app-version-settings-form__upload-col">
+ <div className="app-version-settings-form__upload-label">ios文件:</div>
+ <Form.Item name="iosFileUrl" rules={[{ required: true, message: '请上传iOS文件' }]} hidden>
+ <Input />
+ </Form.Item>
+ <Form.Item name="iosFileName" rules={[{ required: true, message: '请填写iOS文件名称' }]} hidden>
+ <Input />
+ </Form.Item>
+ <label className="app-version-settings-form__upload-trigger">
+ <input
+ type="file"
+ accept=".ipa"
+ hidden
+ disabled={isDetailMode}
+ onChange={async (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file) {
+ return;
+ }
+ await handlePackageUpload(file, 'IOS');
+ }}
+ />
+ <Button icon={<UploadOutlined />} loading={uploadingIos} disabled={isDetailMode}>
+ 上传
+ </Button>
+ </label>
+ <div className="app-version-settings-form__file-name">{iosFileName || '未上传文件'}</div>
+ </div>
+ </div>
+ </div>
+
+ <Form.Item label="强制更新" name="forceUpdate" valuePropName="checked">
+ <Switch />
+ </Form.Item>
+
+ <Form.Item
+ label="更新说明"
+ name="updateNotice"
+ rules={[
+ { required: true, message: '请输入更新说明' },
+ { max: 2000, message: '更新说明长度不能超过2000个字符' },
+ ]}
+ className="app-version-settings-form__textarea-item"
+ >
+ <Input.TextArea rows={6} maxLength={2000} placeholder="请输入" />
+ </Form.Item>
+ </Form>
+ </Spin>
+ </Modal>
+ </div>
+ );
+};
+
+export default AppVersionSettingsPage;
diff --git a/tcm-training-admin/src/pages/system/app-version-settings/service.ts b/tcm-training-admin/src/pages/system/app-version-settings/service.ts
new file mode 100644
index 0000000..15e636d
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/app-version-settings/service.ts
@@ -0,0 +1,31 @@
+import { request } from '@umijs/max';
+
+export async function getAppVersionPage(params?: Record<string, any>) {
+ return request('/app-version-settings', {
+ method: 'GET',
+ params,
+ });
+}
+
+export async function getAppVersionDetail(id: number | string) {
+ return request(`/app-version-settings/${id}`, {
+ method: 'GET',
+ });
+}
+
+export async function createAppVersion(data: Record<string, any>) {
+ return request('/app-version-settings', {
+ method: 'POST',
+ data,
+ });
+}
+
+export async function uploadAppPackage(file: File, platform: 'ANDROID' | 'IOS') {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('platform', platform);
+ return request('/upload/app-package', {
+ method: 'POST',
+ data: formData,
+ });
+}
diff --git a/tcm-training-admin/src/pages/system/app-version-settings/style.less b/tcm-training-admin/src/pages/system/app-version-settings/style.less
new file mode 100644
index 0000000..be54634
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/app-version-settings/style.less
@@ -0,0 +1,153 @@
+.app-version-settings-page {
+ min-height: calc(100vh - 48px);
+ padding: 18px 20px 24px;
+ background: #f2f5f9;
+}
+
+.app-version-settings-panel {
+ min-height: 700px;
+ padding: 18px 18px 24px;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.app-version-settings-panel__title {
+ margin-bottom: 18px;
+ color: #303133;
+ font-weight: 500;
+ font-size: 20px;
+}
+
+.app-version-settings-toolbar {
+ display: flex;
+ justify-content: flex-start;
+ margin-bottom: 10px;
+}
+
+.app-version-settings-table-card {
+ min-height: 560px;
+ overflow: hidden;
+ border: 1px solid #e6ebf2;
+ border-radius: 4px;
+}
+
+.app-version-settings-table-card .ant-table-thead > tr > th {
+ color: #303133;
+ font-weight: 500;
+ background: #f5f7fa;
+}
+
+.app-version-settings-table-card .ant-table-tbody > tr > td {
+ color: #606266;
+}
+
+.app-version-settings-table__link {
+ padding: 0;
+ color: #409eff;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+}
+
+.app-version-settings-pagination {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 18px;
+ background: #fff;
+}
+
+.app-version-settings-modal .ant-modal-content {
+ padding: 14px 18px 18px;
+ border-radius: 8px;
+}
+
+.app-version-settings-modal .ant-modal-header {
+ margin-bottom: 12px;
+}
+
+.app-version-settings-modal .ant-modal-title {
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.app-version-settings-modal .ant-modal-close {
+ top: 18px;
+}
+
+.app-version-settings-form .ant-form-item {
+ margin-bottom: 16px;
+}
+
+.app-version-settings-form {
+ --app-version-label-width: 78px;
+}
+
+.app-version-settings-form__version-item {
+ max-width: 340px;
+}
+
+.app-version-settings-form__aligned-content {
+ padding-left: var(--app-version-label-width);
+}
+
+.app-version-settings-form__upload-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px 56px;
+ margin-bottom: 12px;
+}
+
+.app-version-settings-form__upload-col {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ align-items: flex-start;
+ min-width: 0;
+}
+
+.app-version-settings-form__upload-label {
+ color: #303133;
+ font-size: 14px;
+}
+
+.app-version-settings-form__upload-trigger .ant-btn {
+ min-width: 110px;
+}
+
+.app-version-settings-form__file-name {
+ max-width: 240px;
+ color: #606266;
+ font-size: 12px;
+ line-height: 18px;
+ word-break: break-all;
+}
+
+.app-version-settings-form__textarea-item {
+ margin-top: 4px;
+}
+
+@media (max-width: 768px) {
+ .app-version-settings-page {
+ padding: 12px;
+ }
+
+ .app-version-settings-panel {
+ min-height: auto;
+ padding: 16px;
+ }
+
+ .app-version-settings-form__upload-row {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+
+ .app-version-settings-form__aligned-content {
+ padding-left: 0;
+ }
+
+ .app-version-settings-form__version-item {
+ max-width: none;
+ }
+}
diff --git a/tcm-training-admin/src/pages/system/banner-settings/index.tsx b/tcm-training-admin/src/pages/system/banner-settings/index.tsx
new file mode 100644
index 0000000..8a25d71
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/banner-settings/index.tsx
@@ -0,0 +1,711 @@
+import {
+ batchDeleteSystemBanner,
+ createSystemBanner,
+ deleteSystemBanner,
+ getSystemBannerDetail,
+ getSystemBannerHealthCourseOptions,
+ getSystemBannerLiveCourseOptions,
+ getSystemBannerPage,
+ updateSystemBanner,
+ updateSystemBannerPublishStatus,
+ uploadSystemBannerImage,
+} from './service';
+import { PlusOutlined } from '@ant-design/icons';
+import { history, useAccess } from '@umijs/max';
+import {
+ Button,
+ DatePicker,
+ Form,
+ Image,
+ Input,
+ Modal,
+ Pagination,
+ Select,
+ Space,
+ Spin,
+ Switch,
+ Table,
+ Tag,
+ Upload,
+ message,
+} from 'antd';
+import dayjs from 'dayjs';
+import type { ColumnsType } from 'antd/es/table';
+import React, { useEffect, useMemo, useState } from 'react';
+import './style.less';
+
+type SearchValues = {
+ status?: number;
+ title?: string;
+};
+
+type BannerItem = {
+ bannerImg?: string;
+ createdAt: string;
+ endAt: string;
+ id: number;
+ jumpTargetDisplay?: string;
+ jumpType: string;
+ publishEnabled: boolean;
+ startAt: string;
+ status: number;
+ title: string;
+};
+
+type BannerDetail = {
+ bannerImg?: string;
+ createdAt?: string;
+ endAt?: string;
+ id: number;
+ jumpType?: string;
+ publishEnabled?: boolean;
+ startAt?: string;
+ status?: number;
+ targetId?: number;
+ targetName?: string;
+ targetUrl?: string;
+ title?: string;
+ updatedAt?: string;
+};
+
+type CourseOption = {
+ id: number;
+ title: string;
+};
+
+type BannerFormValues = {
+ bannerImg?: string;
+ endAt?: any;
+ jumpType?: string;
+ publishEnabled?: boolean;
+ startAt?: any;
+ targetId?: number;
+ targetName?: string;
+ targetUrl?: string;
+ title?: string;
+};
+
+type ModalMode = 'create' | 'edit' | 'detail';
+
+const JUMP_TYPE_HEALTH_COURSE = 'HEALTH_COURSE';
+const JUMP_TYPE_LIVE_COURSE = 'LIVE_COURSE';
+const JUMP_TYPE_MEMBER = 'MEMBER';
+const JUMP_TYPE_THIRD_PARTY_URL = 'THIRD_PARTY_URL';
+
+const jumpTypeOptions = [
+ { label: '养生课', value: JUMP_TYPE_HEALTH_COURSE },
+ { label: '直播课', value: JUMP_TYPE_LIVE_COURSE },
+ { label: '会员', value: JUMP_TYPE_MEMBER },
+ { label: '三方地址', value: JUMP_TYPE_THIRD_PARTY_URL },
+];
+
+const statusOptions = [
+ { label: '未开始', value: 1 },
+ { label: '进行中', value: 2 },
+ { label: '已结束', value: 3 },
+];
+
+const defaultValues: BannerFormValues = {
+ bannerImg: '',
+ title: '',
+ jumpType: JUMP_TYPE_MEMBER,
+ targetId: undefined,
+ targetName: '',
+ targetUrl: '',
+ publishEnabled: true,
+};
+
+const trimSearchValues = (values: SearchValues) => ({
+ title: values.title?.trim() || undefined,
+ status: values.status,
+});
+
+const BannerSettingsPage: React.FC = () => {
+ const access = useAccess();
+ const [searchForm] = Form.useForm<SearchValues>();
+ const [form] = Form.useForm<BannerFormValues>();
+ const [list, setList] = useState<BannerItem[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
+ const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modalMode, setModalMode] = useState<ModalMode>('create');
+ const [editingId, setEditingId] = useState<number>();
+ const [modalLoading, setModalLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [courseOptions, setCourseOptions] = useState<CourseOption[]>([]);
+ const [optionsLoading, setOptionsLoading] = useState(false);
+
+ const canView = access['system:banner:view'];
+ const canAdd = access['system:banner:add'];
+ const canEdit = access['system:banner:edit'];
+ const canDetail = access['system:banner:detail'];
+ const canDelete = access['system:banner:delete'];
+ const canBatchDelete = access['system:banner:batch-delete'];
+ const canPublishStatus = access['system:banner:publish-status'];
+ const isDetailMode = modalMode === 'detail';
+ const jumpType = Form.useWatch('jumpType', form);
+ const bannerImg = Form.useWatch('bannerImg', form);
+
+ const showCourseField =
+ jumpType === JUMP_TYPE_HEALTH_COURSE || jumpType === JUMP_TYPE_LIVE_COURSE;
+ const showUrlField = jumpType === JUMP_TYPE_THIRD_PARTY_URL;
+
+ const coursePlaceholder = useMemo(() => {
+ if (jumpType === JUMP_TYPE_HEALTH_COURSE) {
+ return '请输入养生课程';
+ }
+ if (jumpType === JUMP_TYPE_LIVE_COURSE) {
+ return '请输入直播课程';
+ }
+ return '请输入课程';
+ }, [jumpType]);
+
+ const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
+ setLoading(true);
+ try {
+ const res = await getSystemBannerPage({
+ 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),
+ });
+ setSelectedRowKeys([]);
+ } catch (error) {
+ message.error((error as Error)?.message || 'Banner列表加载失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!canView) {
+ history.replace('/Welcome');
+ return;
+ }
+ loadList(1, 10);
+ }, [canView]);
+
+ const loadCourseOptions = async (keyword?: string, type?: string) => {
+ const currentType = type || form.getFieldValue('jumpType');
+ if (currentType !== JUMP_TYPE_HEALTH_COURSE && currentType !== JUMP_TYPE_LIVE_COURSE) {
+ setCourseOptions([]);
+ return;
+ }
+ setOptionsLoading(true);
+ try {
+ const res =
+ currentType === JUMP_TYPE_HEALTH_COURSE
+ ? await getSystemBannerHealthCourseOptions(keyword)
+ : await getSystemBannerLiveCourseOptions(keyword);
+ setCourseOptions(res.data || []);
+ } catch (error) {
+ message.error((error as Error)?.message || '课程选项加载失败');
+ } finally {
+ setOptionsLoading(false);
+ }
+ };
+
+ const openCreateModal = () => {
+ setModalMode('create');
+ setEditingId(undefined);
+ setModalLoading(false);
+ setCourseOptions([]);
+ form.resetFields();
+ form.setFieldsValue(defaultValues);
+ setModalOpen(true);
+ };
+
+ const openDetailModal = async (record: BannerItem, mode: ModalMode) => {
+ setModalMode(mode);
+ setEditingId(record.id);
+ setModalOpen(true);
+ setModalLoading(true);
+ setCourseOptions([]);
+ form.resetFields();
+ try {
+ const res = await getSystemBannerDetail(record.id);
+ const detail: BannerDetail = res.data;
+ const nextValues: BannerFormValues = {
+ bannerImg: detail.bannerImg || '',
+ title: detail.title || '',
+ startAt: detail.startAt ? dayjs(detail.startAt) : undefined,
+ endAt: detail.endAt ? dayjs(detail.endAt) : undefined,
+ jumpType: detail.jumpType || JUMP_TYPE_MEMBER,
+ targetId: detail.targetId,
+ targetName: detail.targetName || '',
+ targetUrl: detail.targetUrl || '',
+ publishEnabled: detail.publishEnabled ?? true,
+ };
+ form.setFieldsValue(nextValues);
+ if (
+ (detail.jumpType === JUMP_TYPE_HEALTH_COURSE || detail.jumpType === JUMP_TYPE_LIVE_COURSE) &&
+ detail.targetId
+ ) {
+ setCourseOptions([{ id: detail.targetId, title: detail.targetName || '' }]);
+ }
+ } catch (error) {
+ message.error((error as Error)?.message || 'Banner详情加载失败');
+ setModalOpen(false);
+ } finally {
+ setModalLoading(false);
+ }
+ };
+
+ const handleCloseModal = () => {
+ if (submitting || uploading) {
+ return;
+ }
+ setModalOpen(false);
+ setModalLoading(false);
+ setCourseOptions([]);
+ form.resetFields();
+ };
+
+ const handleDelete = (record: BannerItem) => {
+ Modal.confirm({
+ title: '确定要删除该Banner吗?',
+ okText: '删除',
+ cancelText: '取消',
+ onOk: async () => {
+ await deleteSystemBanner(record.id);
+ message.success('删除成功');
+ const nextPage = list.length === 1 && pagination.page > 1 ? pagination.page - 1 : pagination.page;
+ await loadList(nextPage, pagination.pageSize);
+ },
+ });
+ };
+
+ const handleBatchDelete = () => {
+ if (!selectedRowKeys.length) {
+ return;
+ }
+ Modal.confirm({
+ title: '确定要批量删除选中的Banner吗?',
+ okText: '删除',
+ cancelText: '取消',
+ onOk: async () => {
+ await batchDeleteSystemBanner(selectedRowKeys as number[]);
+ message.success('批量删除成功');
+ const shouldBackPage = list.length === selectedRowKeys.length && pagination.page > 1;
+ await loadList(shouldBackPage ? pagination.page - 1 : pagination.page, pagination.pageSize);
+ },
+ });
+ };
+
+ const handleSubmit = async () => {
+ if (isDetailMode) {
+ handleCloseModal();
+ return;
+ }
+ const values = await form.validateFields();
+ const data = {
+ bannerImg: values.bannerImg?.trim(),
+ title: values.title?.trim(),
+ startAt: dayjs(values.startAt).format('YYYY-MM-DD HH:mm:ss'),
+ endAt: dayjs(values.endAt).format('YYYY-MM-DD HH:mm:ss'),
+ jumpType: values.jumpType,
+ targetId: showCourseField ? values.targetId : undefined,
+ targetName: showCourseField ? values.targetName?.trim() : undefined,
+ targetUrl: showUrlField ? values.targetUrl?.trim() : undefined,
+ publishEnabled: !!values.publishEnabled,
+ };
+ setSubmitting(true);
+ try {
+ if (modalMode === 'create') {
+ await createSystemBanner(data);
+ message.success('新增成功');
+ } else if (editingId) {
+ await updateSystemBanner(editingId, data);
+ 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<BannerItem> = [
+ {
+ title: 'banner图',
+ dataIndex: 'bannerImg',
+ width: 140,
+ render: (value) => (
+ <div className="banner-settings-table__image">
+ {value ? <Image src={value} preview={false} width={110} height={42} /> : null}
+ </div>
+ ),
+ },
+ {
+ title: '标题',
+ dataIndex: 'title',
+ width: 160,
+ ellipsis: true,
+ },
+ {
+ title: '显示时间',
+ width: 220,
+ render: (_, record) => (
+ <div className="banner-settings-table__time">
+ <div>{record.startAt || '-'}</div>
+ <div>{record.endAt || '-'}</div>
+ </div>
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: (value) => {
+ if (value === 1) {
+ return <Tag color="cyan">未开始</Tag>;
+ }
+ if (value === 2) {
+ return <Tag color="green">进行中</Tag>;
+ }
+ return <Tag>已结束</Tag>;
+ },
+ },
+ {
+ title: '上架状态',
+ dataIndex: 'publishEnabled',
+ width: 120,
+ render: (value, record) => (
+ <Switch
+ checked={!!value}
+ disabled={!canPublishStatus}
+ onChange={async (checked) => {
+ try {
+ await updateSystemBannerPublishStatus(record.id, checked);
+ message.success('上架状态更新成功');
+ await loadList(pagination.page, pagination.pageSize);
+ } catch (error) {
+ message.error((error as Error)?.message || '上架状态更新失败');
+ }
+ }}
+ />
+ ),
+ },
+ {
+ title: '跳转地址',
+ dataIndex: 'jumpTargetDisplay',
+ width: 180,
+ ellipsis: true,
+ render: (value) => value || '-',
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'createdAt',
+ width: 180,
+ },
+ {
+ title: '操作',
+ width: 200,
+ render: (_, record) => (
+ <Space size={8}>
+ {canDetail ? (
+ <Button size="small" onClick={() => openDetailModal(record, 'detail')}>
+ 查看
+ </Button>
+ ) : null}
+ {canEdit ? (
+ <Button size="small" onClick={() => openDetailModal(record, 'edit')}>
+ 编辑
+ </Button>
+ ) : null}
+ {canDelete ? (
+ <Button size="small" onClick={() => handleDelete(record)}>
+ 删除
+ </Button>
+ ) : null}
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div className="banner-settings-page">
+ <div className="banner-settings-card">
+ <div className="banner-settings-card__title">banner管理</div>
+ <div className="banner-settings-search-card">
+ <Form form={searchForm} layout="horizontal" className="banner-settings-search-form">
+ <Form.Item label="标题" name="title">
+ <Input placeholder="请输入" allowClear />
+ </Form.Item>
+ <Form.Item label="状态" name="status">
+ <Select placeholder="请选择" allowClear options={statusOptions} />
+ </Form.Item>
+ <div className="banner-settings-search-form__actions">
+ <Button type="primary" onClick={() => loadList(1, pagination.pageSize)}>
+ 查询
+ </Button>
+ <Button
+ onClick={async () => {
+ searchForm.resetFields();
+ await loadList(1, pagination.pageSize);
+ }}
+ >
+ 重置
+ </Button>
+ </div>
+ </Form>
+ </div>
+
+ <div className="banner-settings-toolbar">
+ {canBatchDelete ? (
+ <Button danger disabled={!selectedRowKeys.length} onClick={handleBatchDelete}>
+ 批量删除
+ </Button>
+ ) : (
+ <span />
+ )}
+ {canAdd ? (
+ <Button type="primary" onClick={openCreateModal}>
+ 添加
+ </Button>
+ ) : null}
+ </div>
+
+ <div className="banner-settings-table-card">
+ <Table<BannerItem>
+ rowKey="id"
+ columns={columns}
+ dataSource={list}
+ loading={loading}
+ pagination={false}
+ scroll={{ x: 1280 }}
+ rowSelection={
+ canBatchDelete
+ ? {
+ selectedRowKeys,
+ onChange: (keys) => setSelectedRowKeys(keys),
+ }
+ : undefined
+ }
+ />
+ <div className="banner-settings-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="banner-settings-modal"
+ destroyOnClose
+ centered
+ width={680}
+ title={modalMode === 'create' ? '添加banner' : modalMode === 'edit' ? '编辑banner' : '查看banner'}
+ onCancel={handleCloseModal}
+ footer={[
+ <Button key="cancel" onClick={handleCloseModal} disabled={submitting}>
+ {isDetailMode ? '关闭' : '取消'}
+ </Button>,
+ isDetailMode ? null : (
+ <Button key="submit" type="primary" loading={submitting} onClick={handleSubmit}>
+ 确定
+ </Button>
+ ),
+ ]}
+ >
+ <Spin spinning={modalLoading}>
+ <Form
+ form={form}
+ className="banner-settings-form"
+ layout="horizontal"
+ disabled={isDetailMode}
+ labelCol={{ flex: '118px' }}
+ wrapperCol={{ flex: '1' }}
+ initialValues={defaultValues}
+ onValuesChange={(changedValues) => {
+ if (!Object.prototype.hasOwnProperty.call(changedValues, 'jumpType')) {
+ return;
+ }
+ const nextType = changedValues.jumpType;
+ form.setFieldsValue({
+ targetId: undefined,
+ targetName: '',
+ targetUrl: nextType === JUMP_TYPE_THIRD_PARTY_URL ? form.getFieldValue('targetUrl') : '',
+ });
+ setCourseOptions([]);
+ if (nextType === JUMP_TYPE_HEALTH_COURSE || nextType === JUMP_TYPE_LIVE_COURSE) {
+ loadCourseOptions('', nextType);
+ }
+ }}
+ >
+ <Form.Item label="banner图" required className="banner-settings-form__upload-item">
+ <Form.Item name="bannerImg" rules={[{ required: true, message: '请上传banner图' }]} hidden>
+ <Input />
+ </Form.Item>
+ <div className="banner-settings-form__upload-content">
+ <Upload
+ accept="image/png,image/jpeg"
+ showUploadList={false}
+ disabled={isDetailMode}
+ customRequest={async (options) => {
+ setUploading(true);
+ try {
+ const file = options.file as File;
+ const res = await uploadSystemBannerImage(file);
+ const url = res.data?.url;
+ if (!url) {
+ throw new Error('上传失败');
+ }
+ form.setFieldsValue({ bannerImg: url });
+ options.onSuccess?.(res);
+ } catch (error) {
+ message.error((error as Error)?.message || '图片上传失败');
+ options.onError?.(error as Error);
+ } finally {
+ setUploading(false);
+ }
+ }}
+ >
+ <div className="banner-settings-form__upload-box">
+ {bannerImg ? (
+ <Image src={bannerImg} preview={false} width={96} height={96} />
+ ) : (
+ <div className="banner-settings-form__upload-placeholder">
+ <PlusOutlined />
+ <span>上传照片</span>
+ </div>
+ )}
+ </div>
+ </Upload>
+ <div className="banner-settings-form__upload-tip">
+ <div>支持jpg、png格式 banner建议尺寸:X px Xpx</div>
+ <div>弹出banner建议尺寸:X px Xpx</div>
+ </div>
+ </div>
+ </Form.Item>
+
+ <Form.Item
+ label="banner标题"
+ name="title"
+ rules={[
+ { required: true, message: '请输入banner标题' },
+ { max: 64, message: 'banner标题长度不能超过64个字符' },
+ ]}
+ >
+ <Input placeholder="请输入" maxLength={64} />
+ </Form.Item>
+
+ <Form.Item
+ label="banner开始时间"
+ name="startAt"
+ rules={[{ required: true, message: '请选择banner开始时间' }]}
+ >
+ <DatePicker
+ showTime
+ format="YYYY-MM-DD HH:mm:ss"
+ placeholder="年 / 月 / 日"
+ className="banner-settings-form__date"
+ />
+ </Form.Item>
+
+ <Form.Item
+ label="banner结束时间"
+ name="endAt"
+ rules={[
+ { required: true, message: '请选择banner结束时间' },
+ ({ getFieldValue }) => ({
+ validator(_, value) {
+ const startAt = getFieldValue('startAt');
+ if (!value || !startAt || dayjs(value).isAfter(dayjs(startAt))) {
+ return Promise.resolve();
+ }
+ return Promise.reject(new Error('banner结束时间必须晚于开始时间'));
+ },
+ }),
+ ]}
+ >
+ <DatePicker
+ showTime
+ format="YYYY-MM-DD HH:mm:ss"
+ placeholder="年 / 月 / 日"
+ className="banner-settings-form__date"
+ />
+ </Form.Item>
+
+ <Form.Item
+ label="跳转类型"
+ name="jumpType"
+ rules={[{ required: true, message: '请选择跳转类型' }]}
+ >
+ <Select placeholder="请选择" options={jumpTypeOptions} />
+ </Form.Item>
+
+ {showCourseField ? (
+ <Form.Item
+ label="课程"
+ name="targetId"
+ rules={[
+ {
+ required: true,
+ message: jumpType === JUMP_TYPE_HEALTH_COURSE ? '请选择养生课程' : '请选择直播课程',
+ },
+ ]}
+ >
+ <Select
+ showSearch
+ placeholder={coursePlaceholder}
+ filterOption={false}
+ options={courseOptions.map((item) => ({ label: item.title, value: item.id }))}
+ loading={optionsLoading}
+ onFocus={() => loadCourseOptions('', jumpType)}
+ onSearch={(keyword) => loadCourseOptions(keyword, jumpType)}
+ onChange={(value, option) => {
+ const selectedOption = Array.isArray(option) ? option[0] : option;
+ form.setFieldValue('targetName', selectedOption?.label || '');
+ }}
+ />
+ </Form.Item>
+ ) : null}
+
+ {showUrlField ? (
+ <Form.Item
+ label="地址信息"
+ name="targetUrl"
+ rules={[
+ { required: true, message: '请输入地址信息' },
+ { max: 512, message: '地址信息长度不能超过512个字符' },
+ ]}
+ >
+ <Input placeholder="请输入" maxLength={512} />
+ </Form.Item>
+ ) : null}
+
+ <Form.Item
+ label="banner上架状态"
+ name="publishEnabled"
+ valuePropName="checked"
+ className="banner-settings-form__switch-item"
+ >
+ <Switch />
+ </Form.Item>
+ </Form>
+ </Spin>
+ </Modal>
+ </div>
+ );
+};
+
+export default BannerSettingsPage;
diff --git a/tcm-training-admin/src/pages/system/banner-settings/service.ts b/tcm-training-admin/src/pages/system/banner-settings/service.ts
new file mode 100644
index 0000000..b0d888e
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/banner-settings/service.ts
@@ -0,0 +1,74 @@
+import { request } from '@umijs/max';
+
+export async function getSystemBannerPage(params?: Record<string, any>) {
+ return request('/system-banners', {
+ method: 'GET',
+ params,
+ });
+}
+
+export async function getSystemBannerDetail(id: number | string) {
+ return request(`/system-banners/${id}`, {
+ method: 'GET',
+ });
+}
+
+export async function createSystemBanner(data: Record<string, any>) {
+ return request('/system-banners', {
+ method: 'POST',
+ data,
+ });
+}
+
+export async function updateSystemBanner(id: number | string, data: Record<string, any>) {
+ return request(`/system-banners/${id}`, {
+ method: 'PUT',
+ data,
+ });
+}
+
+export async function deleteSystemBanner(id: number | string) {
+ return request(`/system-banners/${id}`, {
+ method: 'DELETE',
+ });
+}
+
+export async function batchDeleteSystemBanner(ids: Array<number | string>) {
+ return request('/system-banners/batch', {
+ method: 'DELETE',
+ data: { ids },
+ });
+}
+
+export async function updateSystemBannerPublishStatus(
+ id: number | string,
+ publishEnabled: boolean,
+) {
+ return request(`/system-banners/${id}/publish-status`, {
+ method: 'PATCH',
+ data: { publishEnabled },
+ });
+}
+
+export async function getSystemBannerHealthCourseOptions(keyword?: string) {
+ return request('/system-banners/health-course-options', {
+ method: 'GET',
+ params: { keyword },
+ });
+}
+
+export async function getSystemBannerLiveCourseOptions(keyword?: string) {
+ return request('/system-banners/live-course-options', {
+ method: 'GET',
+ params: { keyword },
+ });
+}
+
+export async function uploadSystemBannerImage(file: File) {
+ const formData = new FormData();
+ formData.append('file', file);
+ return request('/upload/image', {
+ method: 'POST',
+ data: formData,
+ });
+}
diff --git a/tcm-training-admin/src/pages/system/banner-settings/style.less b/tcm-training-admin/src/pages/system/banner-settings/style.less
new file mode 100644
index 0000000..2ca51f4
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/banner-settings/style.less
@@ -0,0 +1,254 @@
+.banner-settings-page {
+ min-height: calc(100vh - 48px);
+ padding: 18px 20px 24px;
+ background: #f2f5f9;
+}
+
+.banner-settings-card {
+ min-height: 700px;
+ padding: 8px 14px 18px;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.banner-settings-card__title {
+ margin: 0 0 22px;
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.banner-settings-search-card {
+ margin-bottom: 20px;
+}
+
+.banner-settings-search-form {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 18px;
+ align-items: center;
+}
+
+.banner-settings-search-form .ant-form-item {
+ margin-bottom: 0;
+}
+
+.banner-settings-search-form .ant-input,
+.banner-settings-search-form .ant-select {
+ width: 170px;
+}
+
+.banner-settings-search-form__actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.banner-settings-toolbar {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 14px;
+}
+
+.banner-settings-table-card {
+ overflow: hidden;
+ border: 1px solid #e6ebf2;
+ border-radius: 4px;
+}
+
+.banner-settings-table-card .ant-table-thead > tr > th {
+ color: #303133;
+ font-weight: 500;
+ background: #f5f7fa;
+}
+
+.banner-settings-table-card .ant-table-tbody > tr > td {
+ color: #606266;
+ vertical-align: middle;
+}
+
+.banner-settings-table__image {
+ width: 110px;
+ height: 42px;
+ overflow: hidden;
+ border-radius: 2px;
+}
+
+.banner-settings-table__image .ant-image,
+.banner-settings-table__image .ant-image-img {
+ width: 110px;
+ height: 42px;
+}
+
+.banner-settings-table__time {
+ line-height: 20px;
+ word-break: break-all;
+}
+
+.banner-settings-pagination {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 18px;
+ background: #fff;
+}
+
+.banner-settings-modal .ant-modal-content {
+ padding: 18px 24px 20px;
+ border-radius: 8px;
+}
+
+.banner-settings-modal .ant-modal-title {
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.banner-settings-modal .ant-modal-body {
+ padding-top: 12px;
+}
+
+.banner-settings-modal .ant-modal-footer {
+ padding-top: 12px;
+}
+
+.banner-settings-form {
+ padding: 4px 6px 0 0;
+}
+
+.banner-settings-form .ant-form-item {
+ margin-bottom: 18px;
+}
+
+.banner-settings-form .ant-form-item-label > label {
+ color: #303133;
+}
+
+.banner-settings-form .ant-form-item-control-input-content > .ant-input,
+.banner-settings-form .ant-form-item-control-input-content > .ant-picker,
+.banner-settings-form .ant-form-item-control-input-content > .ant-select {
+ width: 100%;
+}
+
+.banner-settings-form .ant-select-selector,
+.banner-settings-form .ant-input,
+.banner-settings-form .ant-picker {
+ min-height: 40px;
+}
+
+.banner-settings-form .ant-input,
+.banner-settings-form .ant-picker-input > input {
+ font-size: 14px;
+}
+
+.banner-settings-form__upload-item {
+ align-items: flex-start;
+}
+
+.banner-settings-form__upload-item .ant-form-item-control {
+ min-height: 96px;
+}
+
+.banner-settings-form__upload-content {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 16px;
+ align-items: flex-start;
+}
+
+.banner-settings-form__upload-box {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 96px;
+ width: 96px;
+ height: 96px;
+ overflow: hidden;
+ background: #fff;
+ border: 1px solid #dcdfe6;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.banner-settings-form__upload-box .ant-image,
+.banner-settings-form__upload-box .ant-image-img {
+ width: 96px;
+ height: 96px;
+}
+
+.banner-settings-form__upload-placeholder {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ align-items: center;
+ color: #909399;
+ font-size: 12px;
+}
+
+.banner-settings-form__upload-tip {
+ flex: 1;
+ min-width: 240px;
+ padding-top: 6px;
+ color: #c0c4cc;
+ font-size: 12px;
+ line-height: 18px;
+ word-break: break-word;
+}
+
+.banner-settings-form__date {
+ width: 100%;
+}
+
+.banner-settings-form__switch-item {
+ margin-top: 8px;
+ margin-bottom: 0;
+}
+
+@media (max-width: 900px) {
+ .banner-settings-page {
+ padding: 12px;
+ }
+
+ .banner-settings-card {
+ min-height: auto;
+ padding: 16px;
+ }
+
+ .banner-settings-search-form {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .banner-settings-search-form .ant-input,
+ .banner-settings-search-form .ant-select {
+ width: 100%;
+ }
+
+ .banner-settings-search-form__actions,
+ .banner-settings-toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .banner-settings-modal {
+ width: calc(100vw - 24px) !important;
+ max-width: calc(100vw - 24px);
+ }
+
+ .banner-settings-modal .ant-modal-content {
+ padding: 16px;
+ }
+
+ .banner-settings-form {
+ padding-right: 0;
+ }
+
+ .banner-settings-form .ant-form-item {
+ margin-bottom: 16px;
+ }
+
+ .banner-settings-form__upload-tip {
+ min-width: 100%;
+ padding-top: 0;
+ }
+}
diff --git a/tcm-training-admin/src/pages/system/basic-settings/index.tsx b/tcm-training-admin/src/pages/system/basic-settings/index.tsx
new file mode 100644
index 0000000..828425a
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/basic-settings/index.tsx
@@ -0,0 +1,201 @@
+import { history, useAccess } from '@umijs/max';
+import { Button, Form, Input, Spin, message } from 'antd';
+import React, { useEffect, useState } from 'react';
+import { getCurrentSystemSettings, saveCurrentSystemSettings } from './service';
+import './style.less';
+
+type SystemSettingsValues = {
+ backupCycleDays?: string;
+ memberExpireRemindDays?: string;
+ serviceOnlineTime?: string;
+ servicePhone?: string;
+};
+
+const validatePositiveInteger = (label: string) => (_: unknown, value: string | undefined) => {
+ if (value === undefined || value === null || String(value).trim() === '') {
+ return Promise.resolve();
+ }
+ if (/^[1-9]\d*$/.test(String(value).trim())) {
+ return Promise.resolve();
+ }
+ return Promise.reject(new Error(`${label}必须为大于0的整数`));
+};
+
+const SystemSettingsPage: React.FC = () => {
+ const access = useAccess();
+ const [form] = Form.useForm<SystemSettingsValues>();
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const canView = access['system:basic-settings:view'];
+ const canEdit = access['system:basic-settings:edit'];
+
+ const loadCurrent = async () => {
+ setLoading(true);
+ try {
+ const res = await getCurrentSystemSettings();
+ const data = res.data;
+ form.resetFields();
+ form.setFieldsValue({
+ servicePhone: data?.servicePhone || '',
+ serviceOnlineTime: data?.serviceOnlineTime || '',
+ backupCycleDays:
+ data?.backupCycleDays === undefined || data?.backupCycleDays === null
+ ? ''
+ : String(data.backupCycleDays),
+ memberExpireRemindDays:
+ data?.memberExpireRemindDays === undefined || data?.memberExpireRemindDays === null
+ ? ''
+ : String(data.memberExpireRemindDays),
+ });
+ } catch (error) {
+ message.error((error as Error)?.message || '系统设置加载失败');
+ form.resetFields();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!canView) {
+ history.replace('/Welcome');
+ return;
+ }
+ loadCurrent();
+ }, [canView]);
+
+ const handleSubmit = async () => {
+ if (!canEdit || submitting) {
+ return;
+ }
+ const values = await form.validateFields();
+ setSubmitting(true);
+ try {
+ await saveCurrentSystemSettings({
+ servicePhone: values.servicePhone?.trim(),
+ serviceOnlineTime: values.serviceOnlineTime?.trim(),
+ backupCycleDays: Number(values.backupCycleDays),
+ memberExpireRemindDays: Number(values.memberExpireRemindDays),
+ });
+ message.success('保存成功');
+ await loadCurrent();
+ } catch (error) {
+ message.error((error as Error)?.message || '保存失败,请稍后重试');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ <div className="system-basic-settings-page">
+ <div className="system-basic-settings-card">
+ <div className="system-basic-settings-card__title">系统设置</div>
+ <Spin spinning={loading}>
+ <Form
+ form={form}
+ layout="horizontal"
+ className="system-basic-settings-form"
+ disabled={loading || !canEdit}
+ labelCol={{ flex: '92px' }}
+ wrapperCol={{ flex: '320px' }}
+ >
+ <div className="system-basic-settings-form__content">
+ <div className="system-basic-settings-form__section">
+ <div className="system-basic-settings-form__section-title">客服设置</div>
+ <div className="system-basic-settings-form__fields">
+ <Form.Item
+ label="客服电话"
+ name="servicePhone"
+ rules={[
+ { required: true, message: '请输入客服电话' },
+ { max: 32, message: '客服电话长度不能超过32个字符' },
+ ]}
+ >
+ <Input
+ placeholder="请输入"
+ maxLength={32}
+ className="system-basic-settings-form__control"
+ />
+ </Form.Item>
+ <Form.Item
+ label="客服在线时间"
+ name="serviceOnlineTime"
+ rules={[
+ { required: true, message: '请输入客服在线时间' },
+ { max: 64, message: '客服在线时间长度不能超过64个字符' },
+ ]}
+ >
+ <Input
+ placeholder="请输入"
+ maxLength={64}
+ className="system-basic-settings-form__control"
+ />
+ </Form.Item>
+ </div>
+ </div>
+
+ <div className="system-basic-settings-form__section">
+ <div className="system-basic-settings-form__section-title">自动备份设置</div>
+ <div className="system-basic-settings-form__fields">
+ <Form.Item label="备份周期" required className="system-basic-settings-form__inline-item">
+ <div className="system-basic-settings-form__inline">
+ <Form.Item
+ name="backupCycleDays"
+ noStyle
+ rules={[
+ { required: true, message: '请输入备份周期' },
+ { validator: validatePositiveInteger('备份周期') },
+ ]}
+ >
+ <Input
+ placeholder="请输入"
+ maxLength={9}
+ className="system-basic-settings-form__inline-input"
+ />
+ </Form.Item>
+ <span className="system-basic-settings-form__inline-unit">天</span>
+ </div>
+ </Form.Item>
+ </div>
+ </div>
+
+ <div className="system-basic-settings-form__section">
+ <div className="system-basic-settings-form__section-title">会员提醒设置</div>
+ <div className="system-basic-settings-form__fields">
+ <Form.Item label="会员到期提醒" required className="system-basic-settings-form__inline-item">
+ <div className="system-basic-settings-form__inline">
+ <span className="system-basic-settings-form__inline-prefix">前</span>
+ <Form.Item
+ name="memberExpireRemindDays"
+ noStyle
+ rules={[
+ { required: true, message: '请输入会员到期提醒天数' },
+ { validator: validatePositiveInteger('会员到期提醒天数') },
+ ]}
+ >
+ <Input
+ placeholder="请输入"
+ maxLength={9}
+ className="system-basic-settings-form__inline-input"
+ />
+ </Form.Item>
+ <span className="system-basic-settings-form__inline-unit">天</span>
+ </div>
+ </Form.Item>
+ </div>
+ </div>
+ </div>
+
+ <div className="system-basic-settings-form__footer">
+ <Button type="primary" disabled={!canEdit} loading={submitting} onClick={handleSubmit}>
+ 保存
+ </Button>
+ </div>
+ </Form>
+ </Spin>
+ </div>
+ </div>
+ );
+};
+
+export default SystemSettingsPage;
diff --git a/tcm-training-admin/src/pages/system/basic-settings/service.ts b/tcm-training-admin/src/pages/system/basic-settings/service.ts
new file mode 100644
index 0000000..2388140
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/basic-settings/service.ts
@@ -0,0 +1,14 @@
+import { request } from '@umijs/max';
+
+export async function getCurrentSystemSettings() {
+ return request('/system-settings/current', {
+ method: 'GET',
+ });
+}
+
+export async function saveCurrentSystemSettings(data: Record<string, any>) {
+ return request('/system-settings/current', {
+ method: 'PUT',
+ data,
+ });
+}
diff --git a/tcm-training-admin/src/pages/system/basic-settings/style.less b/tcm-training-admin/src/pages/system/basic-settings/style.less
new file mode 100644
index 0000000..f178838
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/basic-settings/style.less
@@ -0,0 +1,129 @@
+.system-basic-settings-page {
+ min-height: calc(100vh - 48px);
+ padding: 18px 20px 24px;
+ background: #f2f5f9;
+}
+
+.system-basic-settings-card {
+ min-height: 700px;
+ padding: 8px 14px 18px;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.system-basic-settings-card__title {
+ margin: 0 0 22px;
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.system-basic-settings-form {
+ display: flex;
+ flex-direction: column;
+ min-height: 620px;
+}
+
+.system-basic-settings-form__content {
+ width: 100%;
+}
+
+.system-basic-settings-form__section {
+ margin-bottom: 40px;
+}
+
+.system-basic-settings-form__section-title {
+ position: relative;
+ margin-bottom: 16px;
+ padding-left: 18px;
+ color: #303133;
+ font-weight: 500;
+ font-size: 16px;
+}
+
+.system-basic-settings-form__section-title::before {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ width: 4px;
+ height: 16px;
+ border-radius: 2px;
+ background: #2f7cff;
+ content: '';
+}
+
+.system-basic-settings-form__fields {
+ width: 100%;
+}
+
+.system-basic-settings-form .ant-form-item {
+ margin-bottom: 18px;
+}
+
+.system-basic-settings-form .ant-form-item-label > label {
+ color: #606266;
+}
+
+.system-basic-settings-form__control {
+ width: 180px;
+}
+
+.system-basic-settings-form__inline-item .ant-form-item-control-input {
+ min-height: 32px;
+}
+
+.system-basic-settings-form__inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.system-basic-settings-form__inline-input {
+ width: 122px;
+}
+
+.system-basic-settings-form__inline-prefix,
+.system-basic-settings-form__inline-unit {
+ color: #606266;
+ font-size: 14px;
+}
+
+.system-basic-settings-form__footer {
+ display: flex;
+ justify-content: flex-start;
+ margin-top: auto;
+ padding-top: 220px;
+}
+
+@media (max-width: 900px) {
+ .system-basic-settings-page {
+ padding: 12px;
+ }
+
+ .system-basic-settings-card {
+ min-height: auto;
+ padding: 16px;
+ }
+
+ .system-basic-settings-form {
+ min-height: auto;
+ }
+
+ .system-basic-settings-form__section {
+ margin-bottom: 28px;
+ }
+
+ .system-basic-settings-form__control,
+ .system-basic-settings-form__inline-input {
+ width: 100%;
+ }
+
+ .system-basic-settings-form__inline {
+ width: 100%;
+ }
+
+ .system-basic-settings-form__footer {
+ padding-top: 40px;
+ }
+}
diff --git a/tcm-training-admin/src/pages/system/message-push-settings/index.tsx b/tcm-training-admin/src/pages/system/message-push-settings/index.tsx
new file mode 100644
index 0000000..bb7001e
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/message-push-settings/index.tsx
@@ -0,0 +1,151 @@
+import { history, useAccess } from '@umijs/max';
+import { Button, Checkbox, Spin, Table, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import React, { useEffect, useState } from 'react';
+import { getMessagePushSettings, saveMessagePushSettings } from './service';
+import './style.less';
+
+type MessagePushItem = {
+ id: number;
+ noticeTypeCode: string;
+ noticeTypeName: string;
+ pushEnabled: boolean;
+ smsEnabled: boolean;
+ updatedAt?: string;
+};
+
+const MessagePushSettingsPage: React.FC = () => {
+ const access = useAccess();
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [list, setList] = useState<MessagePushItem[]>([]);
+
+ const canView = access['system:message-push:view'];
+ const canEdit = access['system:message-push:edit'];
+
+ const loadCurrent = async () => {
+ setLoading(true);
+ try {
+ const res = await getMessagePushSettings();
+ setList(res.data || []);
+ } catch (error) {
+ message.error((error as Error)?.message || '消息推送配置加载失败');
+ setList([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!canView) {
+ history.replace('/Welcome');
+ return;
+ }
+ loadCurrent();
+ }, [canView]);
+
+ const handleCheckedChange = (
+ noticeTypeCode: string,
+ field: 'pushEnabled' | 'smsEnabled',
+ checked: boolean,
+ ) => {
+ if (!canEdit) {
+ return;
+ }
+ setList((prev) =>
+ prev.map((item) => (item.noticeTypeCode === noticeTypeCode ? { ...item, [field]: checked } : item)),
+ );
+ };
+
+ const handleSubmit = async () => {
+ if (!canEdit || submitting) {
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await saveMessagePushSettings({
+ items: list.map((item) => ({
+ noticeTypeCode: item.noticeTypeCode,
+ pushEnabled: item.pushEnabled,
+ smsEnabled: item.smsEnabled,
+ })),
+ });
+ message.success('保存成功');
+ await loadCurrent();
+ } catch (error) {
+ message.error((error as Error)?.message || '保存失败,请稍后重试');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const columns: ColumnsType<MessagePushItem> = [
+ {
+ title: '序号',
+ width: 72,
+ render: (_, __, index) => index + 1,
+ },
+ {
+ title: '通知类型',
+ dataIndex: 'noticeTypeName',
+ width: 220,
+ },
+ {
+ title: '通知方式',
+ dataIndex: 'noticeTypeCode',
+ render: (_, record) => (
+ <div className="message-push-settings-table__methods">
+ <Checkbox
+ checked={record.pushEnabled}
+ disabled={loading || submitting || !canEdit}
+ onChange={(event) =>
+ handleCheckedChange(record.noticeTypeCode, 'pushEnabled', event.target.checked)
+ }
+ >
+ PUSH推送
+ </Checkbox>
+ <Checkbox
+ checked={record.smsEnabled}
+ disabled={loading || submitting || !canEdit}
+ onChange={(event) =>
+ handleCheckedChange(record.noticeTypeCode, 'smsEnabled', event.target.checked)
+ }
+ >
+ 短信通知
+ </Checkbox>
+ </div>
+ ),
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updatedAt',
+ width: 180,
+ },
+ ];
+
+ return (
+ <div className="message-push-settings-page">
+ <div className="message-push-settings-card">
+ <div className="message-push-settings-card__title">消息推送配置</div>
+ <Spin spinning={loading}>
+ <div className="message-push-settings-table-card">
+ <Table<MessagePushItem>
+ rowKey="noticeTypeCode"
+ columns={columns}
+ dataSource={list}
+ pagination={false}
+ scroll={{ x: 900 }}
+ />
+ </div>
+ <div className="message-push-settings-footer">
+ <Button type="primary" disabled={!canEdit} loading={submitting} onClick={handleSubmit}>
+ 保存
+ </Button>
+ </div>
+ </Spin>
+ </div>
+ </div>
+ );
+};
+
+export default MessagePushSettingsPage;
diff --git a/tcm-training-admin/src/pages/system/message-push-settings/service.ts b/tcm-training-admin/src/pages/system/message-push-settings/service.ts
new file mode 100644
index 0000000..906a767
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/message-push-settings/service.ts
@@ -0,0 +1,14 @@
+import { request } from '@umijs/max';
+
+export async function getMessagePushSettings() {
+ return request('/message-push-settings', {
+ method: 'GET',
+ });
+}
+
+export async function saveMessagePushSettings(data: Record<string, any>) {
+ return request('/message-push-settings', {
+ method: 'PUT',
+ data,
+ });
+}
diff --git a/tcm-training-admin/src/pages/system/message-push-settings/style.less b/tcm-training-admin/src/pages/system/message-push-settings/style.less
new file mode 100644
index 0000000..1a0eb9e
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/message-push-settings/style.less
@@ -0,0 +1,72 @@
+.message-push-settings-page {
+ min-height: calc(100vh - 48px);
+ padding: 18px 20px 24px;
+ background: #f2f5f9;
+}
+
+.message-push-settings-card {
+ min-height: 700px;
+ padding: 8px 14px 18px;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.message-push-settings-card__title {
+ margin: 0 0 22px;
+ color: #303133;
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.message-push-settings-table-card {
+ overflow: hidden;
+ border: 1px solid #e6ebf2;
+ border-radius: 4px;
+}
+
+.message-push-settings-table-card .ant-table-thead > tr > th {
+ color: #303133;
+ font-weight: 500;
+ background: #f5f7fa;
+}
+
+.message-push-settings-table-card .ant-table-tbody > tr > td {
+ color: #606266;
+}
+
+.message-push-settings-table__methods {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px 48px;
+ align-items: center;
+}
+
+.message-push-settings-table__methods .ant-checkbox-wrapper {
+ color: #606266;
+}
+
+.message-push-settings-footer {
+ display: flex;
+ justify-content: flex-start;
+ padding-top: 160px;
+}
+
+@media (max-width: 900px) {
+ .message-push-settings-page {
+ padding: 12px;
+ }
+
+ .message-push-settings-card {
+ min-height: auto;
+ padding: 16px;
+ }
+
+ .message-push-settings-table__methods {
+ gap: 12px 24px;
+ }
+
+ .message-push-settings-footer {
+ padding-top: 40px;
+ }
+}
diff --git a/tcm-training-admin/src/pages/system/settings-redirect/index.tsx b/tcm-training-admin/src/pages/system/settings-redirect/index.tsx
new file mode 100644
index 0000000..4dc9c32
--- /dev/null
+++ b/tcm-training-admin/src/pages/system/settings-redirect/index.tsx
@@ -0,0 +1,34 @@
+import { history, useAccess } from '@umijs/max';
+import React, { useEffect } from 'react';
+
+const SystemSettingsRedirectPage: React.FC = () => {
+ const access = useAccess();
+
+ useEffect(() => {
+ if (access['system:basic-settings:view']) {
+ history.replace('/system/settings/basic');
+ return;
+ }
+ if (access['system:app-version:view']) {
+ history.replace('/system/settings/app-version');
+ return;
+ }
+ if (access['system:banner:view']) {
+ history.replace('/system/settings/banner');
+ return;
+ }
+ if (access['system:message-push:view']) {
+ history.replace('/system/settings/message-push');
+ return;
+ }
+ if (access['system:points-settings:view']) {
+ history.replace('/system/settings/points');
+ return;
+ }
+ history.replace('/Welcome');
+ }, [access]);
+
+ return null;
+};
+
+export default SystemSettingsRedirectPage;
--
Gitblit v1.7.1