From 4c4b36e7337385ff15ff66a0ad487cdffaf9c86b Mon Sep 17 00:00:00 2001
From: zjk <852185829@qq.com>
Date: 星期四, 24 九月 2026 17:22:50 +0800
Subject: [PATCH] feat: 新增APP启动页功能,完善多项业务优化

---
 tcm-training-admin-backend/sql/update_system_settings_schema.sql                                                    |    2 
 traditionalApp/pages/home/home.vue                                                                                  |    4 
 tcm-training-admin-backend/sql/tcm_training_admin.sql                                                               |    2 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/ClinicListRow.java                       |    1 
 tcm-training-admin-backend/src/main/resources/mapper/ClinicQueryMapper.xml                                          |    4 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java                   |    6 
 tcm-training-admin/src/pages/member/agent/index.tsx                                                                 |   41 +
 traditionalApp/pages/watchLive/watchLive.vue                                                                        |   34 +
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java     |   12 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/ClinicExcelRow.java                    |    6 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java           |    5 
 tcm-training-admin-backend/sql/update_startup_page_settings.sql                                                     |   20 +
 traditionalApp/pages/webview/webview.vue                                                                            |   25 +
 tcm-training-admin-backend/sql/init.sql                                                                             |    2 
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ClinicExportTest.java                |   57 +++
 traditionalApp/pages/startup/startup.vue                                                                            |  173 ++++++++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/controller/AppStartupPageController.java |   33 +
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/SystemSettingsAppServiceTest.java    |   86 +++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java                |    2 
 tcm-training-admin/src/pages/system/basic-settings/index.tsx                                                        |   61 +++
 traditionalApp/pages/startup/service.js                                                                             |   49 ++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java        |   48 ++
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ProvincialAgentExportTest.java       |   72 ++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java                     |    2 
 traditionalApp/pages.json                                                                                           |   10 
 tcm-training-admin/src/pages/member/agent/service.ts                                                                |   19 +
 traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue                                                   |   61 +-
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/excel/ProvincialAgentExcelRow.java            |   26 +
 tcm-training-admin/src/components/SingleImageUpload.tsx                                                             |   70 ++++
 traditionalApp/utils/public-request.js                                                                              |    5 
 traditionalApp/manifest.json                                                                                        |    7 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java        |   11 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/vo/AppStartupPageVO.java                 |   12 
 33 files changed, 908 insertions(+), 60 deletions(-)

diff --git a/tcm-training-admin-backend/sql/init.sql b/tcm-training-admin-backend/sql/init.sql
index 81d8cd8..6828b51 100644
--- a/tcm-training-admin-backend/sql/init.sql
+++ b/tcm-training-admin-backend/sql/init.sql
@@ -1079,6 +1079,8 @@
   `service_online_time` VARCHAR(64) NOT NULL COMMENT '客服在线时间',
   `backup_cycle_days` INT NOT NULL COMMENT '备份周期(天)',
   `member_expire_remind_days` INT NOT NULL COMMENT '会员到期提醒天数',
+  `startup_page_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启启动页',
+  `startup_page_image_url` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '启动页图片地址',
   `deleted` TINYINT(1) NOT NULL DEFAULT 0,
   `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
   `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
diff --git a/tcm-training-admin-backend/sql/tcm_training_admin.sql b/tcm-training-admin-backend/sql/tcm_training_admin.sql
index 36cd9bb..1d4caff 100644
--- a/tcm-training-admin-backend/sql/tcm_training_admin.sql
+++ b/tcm-training-admin-backend/sql/tcm_training_admin.sql
@@ -5508,6 +5508,8 @@
   `service_online_time` varchar(64) NOT NULL COMMENT '客服在线时间',
   `backup_cycle_days` int NOT NULL COMMENT '备份周期(天)',
   `member_expire_remind_days` int NOT NULL COMMENT '会员到期提醒天数',
+  `startup_page_enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否开启启动页',
+  `startup_page_image_url` varchar(1024) NOT NULL DEFAULT '' COMMENT '启动页图片地址',
   `deleted` tinyint(1) NOT NULL DEFAULT '0',
   `created_by` varchar(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
   `updated_by` varchar(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
diff --git a/tcm-training-admin-backend/sql/update_startup_page_settings.sql b/tcm-training-admin-backend/sql/update_startup_page_settings.sql
new file mode 100644
index 0000000..5b17bc8
--- /dev/null
+++ b/tcm-training-admin-backend/sql/update_startup_page_settings.sql
@@ -0,0 +1,20 @@
+-- 在目标主库执行;只增加启动页字段,不修改已有业务数据,可重复执行。
+SET @ddl = IF(
+    (SELECT COUNT(*) FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tcm_system_settings'
+       AND COLUMN_NAME = 'startup_page_enabled') = 0,
+    'ALTER TABLE tcm_system_settings ADD COLUMN startup_page_enabled TINYINT(1) NOT NULL DEFAULT 0',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = IF(
+    (SELECT COUNT(*) FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tcm_system_settings'
+       AND COLUMN_NAME = 'startup_page_image_url') = 0,
+    'ALTER TABLE tcm_system_settings ADD COLUMN startup_page_image_url VARCHAR(1024) NOT NULL DEFAULT ''''',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/tcm-training-admin-backend/sql/update_system_settings_schema.sql b/tcm-training-admin-backend/sql/update_system_settings_schema.sql
index 5345e95..9a613a9 100644
--- a/tcm-training-admin-backend/sql/update_system_settings_schema.sql
+++ b/tcm-training-admin-backend/sql/update_system_settings_schema.sql
@@ -4,6 +4,8 @@
   `service_online_time` VARCHAR(64) NOT NULL COMMENT '客服在线时间',
   `backup_cycle_days` INT NOT NULL COMMENT '备份周期(天)',
   `member_expire_remind_days` INT NOT NULL COMMENT '会员到期提醒天数',
+  `startup_page_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启启动页',
+  `startup_page_image_url` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '启动页图片地址',
   `deleted` TINYINT(1) NOT NULL DEFAULT 0,
   `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
   `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java
index 2c8819a..2fe4b81 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java
@@ -11,6 +11,8 @@
 import org.springframework.web.bind.annotation.*;
 
 import javax.validation.Valid;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
 
 @Validated
 @RestController
@@ -28,6 +30,16 @@
         return ApiResponse.ok(service.page(page, pageSize, regionCode, clinicName, contactInfo, status));
     }
 
+    @GetMapping("/provincial-agents/export")
+    @SaCheckPermission("member:agent:view")
+    public void export(@RequestParam(required = false) String regionCode,
+                       @RequestParam(required = false) String clinicName,
+                       @RequestParam(required = false) String contactInfo,
+                       @RequestParam(required = false) Integer status,
+                       HttpServletResponse response) throws IOException {
+        service.export(regionCode, clinicName, contactInfo, status, response);
+    }
+
     @GetMapping("/provincial-agents/{id}")
     @SaCheckPermission("member:agent:detail")
     public ApiResponse<ProvincialAgentDetailVO> detail(@PathVariable Long id) {
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/excel/ProvincialAgentExcelRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/excel/ProvincialAgentExcelRow.java
new file mode 100644
index 0000000..595bea8
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/excel/ProvincialAgentExcelRow.java
@@ -0,0 +1,26 @@
+package com.tcm.training.admin.agent.excel;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ProvincialAgentExcelRow {
+    @ExcelProperty("序号")
+    private Long serialNo;
+    @ExcelProperty("负责人")
+    private String agentName;
+    @ExcelProperty("电话")
+    private String contactInfo;
+    @ExcelProperty("负责省份")
+    private String regionNames;
+    @ExcelProperty("详细地址")
+    private String address;
+    @ExcelProperty("下级诊所数")
+    private Long clinicCount;
+    @ExcelProperty("状态")
+    private String status;
+    @ExcelProperty("创建时间")
+    private String createdAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java
index 9ccb5e1..a1372e1 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java
@@ -2,6 +2,10 @@
 
 import cn.hutool.json.JSONUtil;
 import cn.idev.excel.FastExcel;
+import cn.idev.excel.ExcelWriter;
+import cn.idev.excel.write.metadata.WriteSheet;
+import com.tcm.training.admin.agent.excel.ProvincialAgentExcelRow;
+import com.tcm.training.admin.common.DateTimes;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -94,6 +98,50 @@
         return PageResponse.of(new ArrayList<ProvincialAgentListItemVO>(), 0, page, pageSize);
     }
 
+    /** 分批复用列表查询,确保导出筛选、区域名称及排序与页面一致。 */
+    public void export(String regionCode, String clinicName, String contactInfo, Integer status,
+                       HttpServletResponse response) throws IOException {
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding("utf-8");
+        String filename = URLEncoder.encode("省代用户列表", "UTF-8").replace("+", "%20");
+        response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx");
+        final int batchSize = 200;
+        long serialNo = 0;
+        try (ExcelWriter writer = FastExcel.write(response.getOutputStream(), ProvincialAgentExcelRow.class).build()) {
+            WriteSheet sheet = FastExcel.writerSheet("省代用户列表").build();
+            for (int pageNumber = 1; ; pageNumber++) {
+                PageResponse<ProvincialAgentListItemVO> result =
+                        page(pageNumber, batchSize, regionCode, clinicName, contactInfo, status);
+                List<ProvincialAgentExcelRow> rows = new ArrayList<>();
+                for (ProvincialAgentListItemVO item : result.getList()) {
+                    rows.add(toExcelRow(item, ++serialNo));
+                }
+                writer.write(rows, sheet);
+                if (result.getList().isEmpty() || serialNo >= result.getTotal()) {
+                    break;
+                }
+            }
+        }
+    }
+
+    private ProvincialAgentExcelRow toExcelRow(ProvincialAgentListItemVO item, long serialNo) {
+        ProvincialAgentExcelRow row = new ProvincialAgentExcelRow();
+        row.setSerialNo(serialNo);
+        row.setAgentName(exportText(item.getAgentName()));
+        row.setContactInfo(exportText(item.getContactInfo()));
+        row.setAddress(exportText(item.getAddress()));
+        row.setRegionNames(exportText(String.join("、", item.getRegionNames())));
+        row.setClinicCount(item.getClinicCount() == null ? 0L : item.getClinicCount());
+        row.setStatus(Integer.valueOf(1).equals(item.getStatus()) ? "启用"
+                : Integer.valueOf(2).equals(item.getStatus()) ? "注销" : "禁用");
+        row.setCreatedAt(DateTimes.format(item.getCreatedAt()));
+        return row;
+    }
+
+    private String exportText(String value) {
+        return trim(value) == null ? "-" : value;
+    }
+
     /** 查询省代理详情。 */
     public ProvincialAgentDetailVO detail(Long id) {
         return toDetail(require(id));
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/controller/AppStartupPageController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/controller/AppStartupPageController.java
new file mode 100644
index 0000000..3c1d571
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/controller/AppStartupPageController.java
@@ -0,0 +1,33 @@
+package com.tcm.training.admin.app.system.controller;
+
+import com.tcm.training.admin.app.system.vo.AppStartupPageVO;
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.system.service.SystemSettingsAppService;
+import com.tcm.training.admin.system.vo.SystemSettingsVO;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 游客启动接口,不依赖登录态。 */
+@RestController
+@RequestMapping({"/api/app/startup-page", "/api/v1/app/startup-page"})
+public class AppStartupPageController {
+    private final SystemSettingsAppService settingsService;
+
+    public AppStartupPageController(SystemSettingsAppService settingsService) {
+        this.settingsService = settingsService;
+    }
+
+    @GetMapping
+    public ApiResponse<AppStartupPageVO> current() {
+        SystemSettingsVO settings = settingsService.current();
+        AppStartupPageVO result = new AppStartupPageVO();
+        if (settings != null && Boolean.TRUE.equals(settings.getStartupPageEnabled())
+                && settings.getStartupPageImageUrl() != null
+                && !settings.getStartupPageImageUrl().trim().isEmpty()) {
+            result.setEnabled(true);
+            result.setImageUrl(settings.getStartupPageImageUrl().trim());
+        }
+        return ApiResponse.ok(result);
+    }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/vo/AppStartupPageVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/vo/AppStartupPageVO.java
new file mode 100644
index 0000000..ce1a279
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/vo/AppStartupPageVO.java
@@ -0,0 +1,12 @@
+package com.tcm.training.admin.app.system.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/** 启动配置只返回展示所需字段,未配置时默认关闭。 */
+@Getter
+@Setter
+public class AppStartupPageVO {
+    private boolean enabled;
+    private String imageUrl = "";
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/ClinicExcelRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/ClinicExcelRow.java
index 8e5c8fe..ab6855d 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/ClinicExcelRow.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/ClinicExcelRow.java
@@ -20,6 +20,12 @@
     @ExcelProperty("诊所区域")
     private String regionName;
 
+    @ExcelProperty("详细地址")
+    private String address;
+
+    @ExcelProperty("省代")
+    private String agentName;
+
     @ExcelProperty("绑定用户数")
     private Integer userCount;
 
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/ClinicListRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/ClinicListRow.java
index 5d65cdd..914327b 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/ClinicListRow.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/ClinicListRow.java
@@ -15,6 +15,7 @@
     private String name;
     private String phone;
     private String regionName;
+    private String address;
     private Integer status;
     private String agentName;
     private Integer userCount;
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java
index 43c7ef8..9196ef6 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java
@@ -204,6 +204,8 @@
             excel.setName(defaultText(row.getName()));
             excel.setPhone(defaultText(row.getPhone()));
             excel.setRegionName(defaultText(row.getRegionName()));
+            excel.setAddress(defaultText(row.getAddress()));
+            excel.setAgentName(defaultText(row.getAgentName()));
             excel.setUserCount(row.getUserCount() == null ? 0 : row.getUserCount());
             excel.setStatus(statusText(row.getStatus()));
             excel.setCreatedAt(DateTimes.format(row.getCreatedAt()));
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java
index 462927f..4230b5d 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java
@@ -29,4 +29,9 @@
     @NotNull(message = "请输入会员到期提醒天数")
     @Min(value = 1, message = "会员到期提醒天数必须大于0")
     private Integer memberExpireRemindDays;
+
+    private Boolean startupPageEnabled = false;
+
+    @Size(max = 1024, message = "启动页图片地址不能超过1024个字符")
+    private String startupPageImageUrl;
 }
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java
index 53c3566..2391ff5 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java
@@ -33,6 +33,12 @@
     @TableField("member_expire_remind_days")
     private Integer memberExpireRemindDays;
 
+    @TableField("startup_page_enabled")
+    private Boolean startupPageEnabled;
+
+    @TableField("startup_page_image_url")
+    private String startupPageImageUrl;
+
     @TableLogic
     private Integer deleted;
 
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java
index fc1c45d..37177be 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java
@@ -27,6 +27,11 @@
 
     @Transactional
     public SystemSettingsVO saveCurrent(SystemSettingsSaveRequest request) {
+        // 开启时必须提供图片;服务层校验同时覆盖非 HTTP 调用。
+        if (Boolean.TRUE.equals(request.getStartupPageEnabled())
+                && (request.getStartupPageImageUrl() == null || request.getStartupPageImageUrl().trim().isEmpty())) {
+            throw new IllegalArgumentException("开启启动页后,请上传启动页图片");
+        }
         SystemSettings config = systemSettingsMapper.selectOne(new LambdaQueryWrapper<SystemSettings>()
                 .eq(SystemSettings::getDeleted, 0)
                 .orderByDesc(SystemSettings::getId)
@@ -47,6 +52,10 @@
         config.setServiceOnlineTime(request.getServiceOnlineTime().trim());
         config.setBackupCycleDays(request.getBackupCycleDays());
         config.setMemberExpireRemindDays(request.getMemberExpireRemindDays());
+        config.setStartupPageEnabled(Boolean.TRUE.equals(request.getStartupPageEnabled()));
+        // 空串也会写入,避免 updateById 忽略 null 导致删除图片后仍保留旧值。
+        config.setStartupPageImageUrl(request.getStartupPageImageUrl() == null
+                ? "" : request.getStartupPageImageUrl().trim());
     }
 
     private SystemSettingsVO toVO(SystemSettings config) {
@@ -59,6 +68,8 @@
         vo.setServiceOnlineTime(config.getServiceOnlineTime());
         vo.setBackupCycleDays(config.getBackupCycleDays());
         vo.setMemberExpireRemindDays(config.getMemberExpireRemindDays());
+        vo.setStartupPageEnabled(Boolean.TRUE.equals(config.getStartupPageEnabled()));
+        vo.setStartupPageImageUrl(config.getStartupPageImageUrl());
         return vo;
     }
 }
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java
index 70e20af..8c8e9ac 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java
@@ -14,4 +14,6 @@
     private String serviceOnlineTime;
     private Integer backupCycleDays;
     private Integer memberExpireRemindDays;
+    private Boolean startupPageEnabled;
+    private String startupPageImageUrl;
 }
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/ClinicQueryMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/ClinicQueryMapper.xml
index 0ccde7d..99ba683 100644
--- a/tcm-training-admin-backend/src/main/resources/mapper/ClinicQueryMapper.xml
+++ b/tcm-training-admin-backend/src/main/resources/mapper/ClinicQueryMapper.xml
@@ -73,6 +73,7 @@
         SELECT
             c.id,
             c.name,
+            c.address,
             c.contact_phone AS phone,
             CASE
                 WHEN c.province_name IS NOT NULL AND c.province_name != '' THEN
@@ -102,7 +103,8 @@
             c.status,
             pa.agent_name,
             c.created_at,
-            c.business_license_url
+            c.business_license_url,
+            c.address
         ORDER BY c.id DESC
     </select>
 
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ClinicExportTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ClinicExportTest.java
new file mode 100644
index 0000000..1ef04b4
--- /dev/null
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ClinicExportTest.java
@@ -0,0 +1,57 @@
+package com.tcm.training.admin.member.service;
+
+import cn.idev.excel.FastExcel;
+import com.tcm.training.admin.member.mapper.ClinicQueryMapper;
+import com.tcm.training.admin.member.row.ClinicListRow;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.io.ByteArrayInputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ClinicExportTest {
+
+    @Test
+    void shouldExportAddressAgentAndPlainPhone() throws Exception {
+        ClinicListRow row = new ClinicListRow();
+        row.setPhone("13800138000");
+        row.setAddress("人民路88号");
+        row.setAgentName("测试省代");
+
+        List<Map<Integer, String>> sheet = exportSheet(row);
+
+        assertEquals(Arrays.asList("序号", "诊所", "电话", "诊所区域", "详细地址",
+                "省代", "绑定用户数", "状态", "注册时间"),
+                Arrays.asList(sheet.get(0).values().toArray(new String[0])));
+        assertEquals("13800138000", sheet.get(1).get(2));
+        assertEquals("人民路88号", sheet.get(1).get(4));
+        assertEquals("测试省代", sheet.get(1).get(5));
+    }
+
+    @Test
+    void shouldUsePlaceholderForMissingAddressAndAgent() throws Exception {
+        List<Map<Integer, String>> sheet = exportSheet(new ClinicListRow());
+
+        assertEquals("-", sheet.get(1).get(4));
+        assertEquals("-", sheet.get(1).get(5));
+    }
+
+    /** 读取实际生成的 Excel,同时验证表头顺序和字段映射。 */
+    private List<Map<Integer, String>> exportSheet(ClinicListRow row) throws Exception {
+        ClinicQueryMapper mapper = mock(ClinicQueryMapper.class);
+        when(mapper.selectClinicList(null, null, null, null, null, null, null))
+                .thenReturn(Collections.singletonList(row));
+        ClinicAppService service = new ClinicAppService(mapper, null, null, null, null, null);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        service.export(null, null, null, null, null, null, null, response);
+        return FastExcel.read(new ByteArrayInputStream(response.getContentAsByteArray()))
+                .headRowNumber(0).sheet().doReadSync();
+    }
+}
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ProvincialAgentExportTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ProvincialAgentExportTest.java
new file mode 100644
index 0000000..490e365
--- /dev/null
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ProvincialAgentExportTest.java
@@ -0,0 +1,72 @@
+package com.tcm.training.admin.member.service;
+
+import cn.idev.excel.FastExcel;
+import com.tcm.training.admin.agent.service.ProvincialAgentAppService;
+import com.tcm.training.admin.agent.vo.ProvincialAgentListItemVO;
+import com.tcm.training.admin.common.PageResponse;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.io.ByteArrayInputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+class ProvincialAgentExportTest {
+    private final ProvincialAgentAppService service =
+            spy(new ProvincialAgentAppService(null, null, null, null, null));
+
+    @Test
+    void shouldExportAllBatchesWithFiltersAndPlainPhone() throws Exception {
+        ProvincialAgentListItemVO item = new ProvincialAgentListItemVO();
+        item.setAgentName("省代测试");
+        item.setContactInfo("13800138000");
+        item.setRegionNames(Arrays.asList("广东省", "湖南省"));
+        item.setAddress("测试路88号");
+        item.setStatus(1);
+        item.setClinicCount(3L);
+        doReturn(PageResponse.of(Collections.nCopies(200, item), 201, 1, 200))
+                .when(service).page(1, 200, "44", "诊所", "138", 1);
+        doReturn(PageResponse.of(Collections.singletonList(item), 201, 2, 200))
+                .when(service).page(2, 200, "44", "诊所", "138", 1);
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        service.export("44", "诊所", "138", 1, response);
+        List<Map<Integer, String>> rows = readSheet(response);
+
+        assertEquals(202, rows.size());
+        assertEquals("负责人", rows.get(0).get(1));
+        assertEquals("详细地址", rows.get(0).get(4));
+        assertEquals("13800138000", rows.get(1).get(2));
+        assertEquals("广东省、湖南省", rows.get(1).get(3));
+        assertEquals("测试路88号", rows.get(1).get(4));
+        assertEquals("3", rows.get(1).get(5));
+        assertEquals("启用", rows.get(1).get(6));
+        assertEquals("201", rows.get(201).get(0));
+        verify(service).page(2, 200, "44", "诊所", "138", 1);
+    }
+
+    @Test
+    void shouldExportHeadersWhenNoMatches() throws Exception {
+        doReturn(PageResponse.of(Collections.emptyList(), 0, 1, 200))
+                .when(service).page(1, 200, null, null, null, null);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        service.export(null, null, null, null, response);
+
+        List<Map<Integer, String>> rows = readSheet(response);
+        assertEquals(1, rows.size());
+        assertEquals(8, rows.get(0).size());
+    }
+
+    private List<Map<Integer, String>> readSheet(MockHttpServletResponse response) {
+        return FastExcel.read(new ByteArrayInputStream(response.getContentAsByteArray()))
+                .headRowNumber(0).sheet().doReadSync();
+    }
+}
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/SystemSettingsAppServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/SystemSettingsAppServiceTest.java
new file mode 100644
index 0000000..f3b0b94
--- /dev/null
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/SystemSettingsAppServiceTest.java
@@ -0,0 +1,86 @@
+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.app.system.controller.AppStartupPageController;
+import com.tcm.training.admin.app.system.vo.AppStartupPageVO;
+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 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.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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SystemSettingsAppServiceTest {
+
+    @BeforeAll
+    static void initLambdaCache() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SystemSettings.class);
+    }
+
+    @Mock
+    private SystemSettingsMapper mapper;
+
+    @InjectMocks
+    private SystemSettingsAppService service;
+
+    @Test
+    void publicStartupPageShouldDefaultToDisabledWithoutSettingsOrImage() {
+        AppStartupPageController controller = new AppStartupPageController(service);
+        AppStartupPageVO empty = controller.current().getData();
+        assertFalse(empty.isEnabled());
+        assertEquals("", empty.getImageUrl());
+
+        SystemSettings settings = new SystemSettings();
+        settings.setStartupPageEnabled(true);
+        settings.setStartupPageImageUrl("  ");
+        when(mapper.selectOne(any())).thenReturn(settings);
+        AppStartupPageVO missingImage = controller.current().getData();
+        assertFalse(missingImage.isEnabled());
+        assertEquals("", missingImage.getImageUrl());
+
+        settings.setStartupPageImageUrl(" https://example.com/start.png ");
+        AppStartupPageVO enabled = controller.current().getData();
+        assertTrue(enabled.isEnabled());
+        assertEquals("https://example.com/start.png", enabled.getImageUrl());
+    }
+
+    @Test
+    void saveShouldRejectEnabledPageWithoutImage() {
+        SystemSettingsSaveRequest request = new SystemSettingsSaveRequest();
+        request.setStartupPageEnabled(true);
+        request.setStartupPageImageUrl("  ");
+        assertThrows(IllegalArgumentException.class, () -> service.saveCurrent(request));
+        verify(mapper, never()).insert(any());
+    }
+
+    @Test
+    void saveShouldPersistDisabledDefaultAndEmptyImage() {
+        SystemSettingsSaveRequest request = new SystemSettingsSaveRequest();
+        request.setServicePhone(" 123 ");
+        request.setServiceOnlineTime(" 09:00-18:00 ");
+        request.setBackupCycleDays(7);
+        request.setMemberExpireRemindDays(3);
+
+        service.saveCurrent(request);
+
+        org.mockito.ArgumentCaptor<SystemSettings> captor = org.mockito.ArgumentCaptor.forClass(SystemSettings.class);
+        verify(mapper).insert(captor.capture());
+        assertFalse(captor.getValue().getStartupPageEnabled());
+        assertEquals("", captor.getValue().getStartupPageImageUrl());
+    }
+}
diff --git a/tcm-training-admin/src/components/SingleImageUpload.tsx b/tcm-training-admin/src/components/SingleImageUpload.tsx
new file mode 100644
index 0000000..40518fc
--- /dev/null
+++ b/tcm-training-admin/src/components/SingleImageUpload.tsx
@@ -0,0 +1,70 @@
+import { request } from '@umijs/max';
+import { Button, Image, Space, Upload, message } from 'antd';
+import type { UploadProps } from 'antd';
+import React, { useState } from 'react';
+
+type SingleImageUploadProps = {
+    value?: string;
+    onChange?: (value: string) => void;
+    disabled?: boolean;
+    onUploadingChange?: (uploading: boolean) => void;
+};
+
+/** 使用字符串地址作为表单值,让上传控件参与 Form 必填校验。 */
+const SingleImageUpload: React.FC<SingleImageUploadProps> = ({
+    value, onChange, disabled, onUploadingChange,
+}) => {
+    const [uploading, setUploading] = useState(false);
+
+    const beforeUpload: UploadProps['beforeUpload'] = (file) => {
+        if (!['image/jpeg', 'image/png'].includes(file.type)) {
+            message.error('请上传 JPG 或 PNG 图片');
+            return Upload.LIST_IGNORE;
+        }
+        // 启动页图片在 App 冷启动时下载,限制 2M 以缩短弱网下的加载时间
+        if (file.size > 2 * 1024 * 1024) {
+            message.error('图片不能超过 2M');
+            return Upload.LIST_IGNORE;
+        }
+        return true;
+    };
+
+    const upload: UploadProps['customRequest'] = async (options) => {
+        setUploading(true);
+        onUploadingChange?.(true);
+        try {
+            const data = new FormData();
+            data.append('file', options.file as File);
+            // 与项目其他上传一致,必须声明 form,否则 FormData 会被序列化为 JSON 导致上传失败。
+            const response = await request('/upload/image', { method: 'POST', data, requestType: 'form' });
+            const url = response.data?.url;
+            if (!url) {
+                throw new Error('服务器未返回图片地址');
+            }
+            onChange?.(url);
+            options.onSuccess?.(response);
+        } catch (error) {
+            console.error('[启动页图片上传失败]', error);
+            message.error((error as Error).message || '图片上传失败');
+            options.onError?.(error as Error);
+        } finally {
+            setUploading(false);
+            onUploadingChange?.(false);
+        }
+    };
+
+    return (
+        <Space direction="vertical">
+            {value && <Image src={value} width={120} style={{ maxHeight: 220, objectFit: 'contain' }} />}
+            <Space>
+                <Upload accept="image/png,image/jpeg" showUploadList={false}
+                    disabled={disabled || uploading} beforeUpload={beforeUpload} customRequest={upload}>
+                    <Button disabled={disabled} loading={uploading}>{value ? '更换图片' : '上传图片'}</Button>
+                </Upload>
+                {value && <Button disabled={disabled || uploading} onClick={() => onChange?.('')}>移除</Button>}
+            </Space>
+        </Space>
+    );
+};
+
+export default SingleImageUpload;
diff --git a/tcm-training-admin/src/pages/member/agent/index.tsx b/tcm-training-admin/src/pages/member/agent/index.tsx
index e64bb1c..1267c77 100644
--- a/tcm-training-admin/src/pages/member/agent/index.tsx
+++ b/tcm-training-admin/src/pages/member/agent/index.tsx
@@ -1,15 +1,25 @@
 import RegionCascader from '@/components/RegionCascader';
-import { PlusOutlined } from '@ant-design/icons';
+import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
 import { history, useAccess } from '@umijs/max';
 import { Button, Descriptions, Form, Input, Modal, Pagination, Select, Space, Table, message } from 'antd';
 import React, { useEffect, useState } from 'react';
-import { createAgent, deleteAgent, getAgentDetail, getAgentPage, updateAgent } from './service';
+import { createAgent, deleteAgent, exportAgents, getAgentDetail, getAgentPage, updateAgent } from './service';
+import { downloadBlob } from '../../trade/points-exchange/shared';
 
 type RegionValue = { regionCode?: string[] };
 type SearchValues = { regionPath?: string[]; clinicName?: string; contactInfo?: string; status?: number };
 type AgentItem = { id: number; agentName: string; contactInfo?: string; address?: string; regionNames?: string[]; clinicCount?: number; status?: number; createdAt?: string };
 const statusOptions = [{ label: '启用', value: 1 }, { label: '禁用', value: 0 }, { label: '注销', value: 2 }];
 const statusText = (value?: number) => value === 1 ? '启用' : value === 2 ? '注销' : '禁用';
+
+function buildSearchParams(search: SearchValues) {
+    return {
+        regionCode: search.regionPath?.[search.regionPath.length - 1],
+        clinicName: search.clinicName?.trim() || undefined,
+        contactInfo: search.contactInfo?.trim() || undefined,
+        status: search.status,
+    };
+}
 
 const AgentPage: React.FC = () => {
   const access = useAccess();
@@ -18,6 +28,7 @@
   const [list, setList] = useState<AgentItem[]>([]);
   const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
   const [loading, setLoading] = useState(false);
+  const [exporting, setExporting] = useState(false);
   const [open, setOpen] = useState(false);
   const [detail, setDetail] = useState<any>();
   const [editingId, setEditingId] = useState<number>();
@@ -29,14 +40,25 @@
     try {
       const response = await getAgentPage({
         page, pageSize,
-        regionCode: search.regionPath?.[search.regionPath.length - 1],
-        clinicName: search.clinicName?.trim() || undefined,
-        contactInfo: search.contactInfo?.trim() || undefined,
-        status: search.status,
+        ...buildSearchParams(search),
       });
       setList(response.data?.list || []);
       setPagination({ page: Number(response.data?.page || page), pageSize: Number(response.data?.pageSize || pageSize), total: Number(response.data?.total || 0) });
     } finally { setLoading(false); }
+  };
+
+  const handleExport = async () => {
+      setExporting(true);
+      try {
+          const blob = await exportAgents(buildSearchParams(searchForm.getFieldsValue()));
+          downloadBlob(blob, '省代用户列表.xlsx');
+          message.success('导出成功');
+      } catch (error) {
+          console.error('省代用户导出失败', error);
+          message.error((error as Error)?.message || '导出失败');
+      } finally {
+          setExporting(false);
+      }
   };
 
   useEffect(() => { if (!canView) history.replace('/Welcome'); else void load(); }, [canView]);
@@ -85,7 +107,10 @@
       <Button onClick={() => { searchForm.resetFields(); void load(1); }}>重置</Button>
       <Button type="primary" htmlType="submit" style={{ marginLeft: 8 }}>查询</Button>
     </Form>
-    <div style={{ margin: '16px 0' }}>{access['member:agent:add'] && <Button type="primary" icon={<PlusOutlined />} onClick={() => openForm()}>新增</Button>}</div>
+    <Space style={{ margin: '16px 0' }}>
+        {access['member:agent:add'] && <Button type="primary" icon={<PlusOutlined />} onClick={() => openForm()}>新增</Button>}
+        {canView && <Button  type="primary"  loading={exporting} onClick={handleExport}>导出</Button>}
+    </Space>
     <Table rowKey="id" columns={columns as any} dataSource={list} loading={loading} pagination={false} scroll={{ x: 1200 }} />
     <Pagination style={{ marginTop: 16, textAlign: 'right' }} current={pagination.page} pageSize={pagination.pageSize} total={pagination.total} showSizeChanger onChange={load} />
     <Modal open={open} title={detail ? '省代理详情' : editingId ? '编辑省代理' : '新增省代理'} width={760} onCancel={() => setOpen(false)} footer={detail ? null : <><Button onClick={() => setOpen(false)}>取消</Button><Button type="primary" onClick={submit}>确定</Button></>}>
@@ -113,4 +138,4 @@
     </Modal>
   </div>;
 };
-export default AgentPage;
\ No newline at end of file
+export default AgentPage;
diff --git a/tcm-training-admin/src/pages/member/agent/service.ts b/tcm-training-admin/src/pages/member/agent/service.ts
index 3277465..599fe71 100644
--- a/tcm-training-admin/src/pages/member/agent/service.ts
+++ b/tcm-training-admin/src/pages/member/agent/service.ts
@@ -1,4 +1,23 @@
 import { request } from '@umijs/max';
+import { decryptData } from '@/utils/utils';
+
+export async function exportAgents(params: Record<string, string | number | undefined>) {
+    const query = new URLSearchParams();
+    Object.entries(params).forEach(([key, value]) => {
+        if (value !== undefined && value !== '') query.append(key, String(value));
+    });
+    const response = await fetch(`${BASE_URL}/provincial-agents/export?${query.toString()}`, {
+        headers: { Authorization: decryptData(localStorage.getItem('token')) || '' },
+    });
+    // 鉴权失败可能返回 HTTP 200 的 JSON,不能误下载为 Excel。
+    if (response.headers.get('content-type')?.includes('application/json')) {
+        const result = await response.json();
+        throw new Error(result.msg || '导出失败,请检查登录状态和权限');
+    }
+    if (!response.ok) throw new Error('导出失败,请稍后重试');
+    return response.blob();
+}
+
 export const getAgentPage=(params:any)=>request('/provincial-agents',{method:'GET',params});
 export const getAgentDetail=(id:number|string)=>request(`/provincial-agents/${id}`,{method:'GET'});
 export const createAgent=(data:any)=>request('/provincial-agents',{method:'POST',data});
diff --git a/tcm-training-admin/src/pages/system/basic-settings/index.tsx b/tcm-training-admin/src/pages/system/basic-settings/index.tsx
index 828425a..a8d329b 100644
--- a/tcm-training-admin/src/pages/system/basic-settings/index.tsx
+++ b/tcm-training-admin/src/pages/system/basic-settings/index.tsx
@@ -1,5 +1,6 @@
+import SingleImageUpload from '@/components/SingleImageUpload';
 import { history, useAccess } from '@umijs/max';
-import { Button, Form, Input, Spin, message } from 'antd';
+import { Button, Form, Input, Spin, Switch, message } from 'antd';
 import React, { useEffect, useState } from 'react';
 import { getCurrentSystemSettings, saveCurrentSystemSettings } from './service';
 import './style.less';
@@ -9,6 +10,8 @@
   memberExpireRemindDays?: string;
   serviceOnlineTime?: string;
   servicePhone?: string;
+  startupPageEnabled?: boolean;
+  startupPageImageUrl?: string;
 };
 
 const validatePositiveInteger = (label: string) => (_: unknown, value: string | undefined) => {
@@ -26,6 +29,8 @@
   const [form] = Form.useForm<SystemSettingsValues>();
   const [loading, setLoading] = useState(false);
   const [submitting, setSubmitting] = useState(false);
+  const [uploading, setUploading] = useState(false);
+  const startupPageEnabled = Form.useWatch('startupPageEnabled', form);
 
   const canView = access['system:basic-settings:view'];
   const canEdit = access['system:basic-settings:edit'];
@@ -47,6 +52,8 @@
           data?.memberExpireRemindDays === undefined || data?.memberExpireRemindDays === null
             ? ''
             : String(data.memberExpireRemindDays),
+        startupPageEnabled: data?.startupPageEnabled ?? false,
+        startupPageImageUrl: data?.startupPageImageUrl || '',
       });
     } catch (error) {
       message.error((error as Error)?.message || '系统设置加载失败');
@@ -65,7 +72,7 @@
   }, [canView]);
 
   const handleSubmit = async () => {
-    if (!canEdit || submitting) {
+    if (!canEdit || submitting || uploading) {
       return;
     }
     const values = await form.validateFields();
@@ -76,6 +83,8 @@
         serviceOnlineTime: values.serviceOnlineTime?.trim(),
         backupCycleDays: Number(values.backupCycleDays),
         memberExpireRemindDays: Number(values.memberExpireRemindDays),
+        startupPageEnabled: values.startupPageEnabled ?? false,
+        startupPageImageUrl: values.startupPageImageUrl || '',
       });
       message.success('保存成功');
       await loadCurrent();
@@ -98,6 +107,7 @@
             disabled={loading || !canEdit}
             labelCol={{ flex: '92px' }}
             wrapperCol={{ flex: '320px' }}
+            initialValues={{ startupPageEnabled: false, startupPageImageUrl: '' }}
           >
             <div className="system-basic-settings-form__content">
               <div className="system-basic-settings-form__section">
@@ -137,7 +147,11 @@
               <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">
+                  <Form.Item
+                    label="备份周期"
+                    required
+                    className="system-basic-settings-form__inline-item"
+                  >
                     <div className="system-basic-settings-form__inline">
                       <Form.Item
                         name="backupCycleDays"
@@ -162,7 +176,11 @@
               <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">
+                  <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
@@ -184,10 +202,43 @@
                   </Form.Item>
                 </div>
               </div>
+
+              <div className="system-basic-settings-form__section">
+                <div className="system-basic-settings-form__section-title">APP启动页设置</div>
+                <div className="system-basic-settings-form__fields">
+                  <Form.Item label="启用启动页" name="startupPageEnabled" valuePropName="checked">
+                    <Switch />
+                  </Form.Item>
+                  <Form.Item
+                    label="启动页图片"
+                    name="startupPageImageUrl"
+                    dependencies={['startupPageEnabled']}
+                    required={!!startupPageEnabled}
+                    rules={[
+                      ({ getFieldValue }) => ({
+                        validator(_, value) {
+                          // 关闭时允许清空图片,开启时必须有已上传的图片地址。
+                          if (!getFieldValue('startupPageEnabled') || value?.trim()) {
+                            return Promise.resolve();
+                          }
+                          return Promise.reject(new Error('请上传启动页图片'));
+                        },
+                      }),
+                    ]}
+                  >
+                    <SingleImageUpload onUploadingChange={setUploading} />
+                  </Form.Item>
+                </div>
+              </div>
             </div>
 
             <div className="system-basic-settings-form__footer">
-              <Button type="primary" disabled={!canEdit} loading={submitting} onClick={handleSubmit}>
+              <Button
+                type="primary"
+                disabled={!canEdit || uploading}
+                loading={submitting}
+                onClick={handleSubmit}
+              >
                 保存
               </Button>
             </div>
diff --git a/traditionalApp/manifest.json b/traditionalApp/manifest.json
index a8a0802..f2f3287 100644
--- a/traditionalApp/manifest.json
+++ b/traditionalApp/manifest.json
@@ -3,8 +3,8 @@
     "name" : "海诊康道",
     "appid" : "__UNI__33EC1E8",
     "description" : "",
-    "versionName" : "1.0.47",
-    "versionCode" : 147,
+    "versionName" : "1.0.48",
+    "versionCode" : 148,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {
@@ -128,7 +128,8 @@
             },
             "splashscreen" : {
                 "androidStyle" : "common",
-                "useOriginalMsgbox" : true
+                "useOriginalMsgbox" : true,
+                "iosStyle" : "common"
             }
         },
         "nativePlugins" : {
diff --git a/traditionalApp/pages.json b/traditionalApp/pages.json
index 051ae39..d608faf 100644
--- a/traditionalApp/pages.json
+++ b/traditionalApp/pages.json
@@ -15,7 +15,15 @@
 	// 	}
 	// ],
 	"pages": [
-        // H5 仅发布课程分享页,其他平台保留首页入口。
+        // #ifdef APP-PLUS
+        {
+            "path": "pages/startup/startup",
+            "style": {
+                "navigationStyle": "custom"
+            }
+        },
+        // #endif
+        // H5 保留首页入口,App 冷启动先进入启动页。
         {
 			"path": "pages/home/home",
 			"style": {
diff --git a/traditionalApp/pages/home/home.vue b/traditionalApp/pages/home/home.vue
index 4d31829..a9d29e3 100644
--- a/traditionalApp/pages/home/home.vue
+++ b/traditionalApp/pages/home/home.vue
@@ -135,7 +135,7 @@
 				</view>
 			</view>
 		</view>
-        <update-popup
+        <!-- <update-popup
             :show="versionPopupVisible"
             :title="versionPopupTitle"
             :content="versionPopupContent"
@@ -144,7 +144,7 @@
             :submitting="versionUpdating"
             @close="closeVersionPopup"
             @submit="handleUpdateNow">
-        </update-popup>
+        </update-popup> -->
 	</view>
 </template>
 
diff --git a/traditionalApp/pages/startup/service.js b/traditionalApp/pages/startup/service.js
new file mode 100644
index 0000000..f45645a
--- /dev/null
+++ b/traditionalApp/pages/startup/service.js
@@ -0,0 +1,49 @@
+import { publicGet } from '@/utils/public-request.js'
+
+/** 启动页配置缓存 key;弱网时先用上次结果渲染,避免长时间白屏 */
+const CACHE_KEY = 'startup_page_settings'
+/** 启动配置请求超时(毫秒);比常规接口短,超时就走缓存或直接进首页,不拖慢冷启动 */
+const REQUEST_TIMEOUT = 3000
+
+/**
+ * 获取 APP 启动页配置。
+ * 启动页在登录前展示,属于游客可见接口,走 publicGet 避免被登录态拦截。
+ * 返回结构:{ code, msg, data: { enabled, imageUrl } }
+ */
+export const getStartupPageSettings = () => {
+	return publicGet('/api/app/startup-page', {}, REQUEST_TIMEOUT)
+}
+
+/**
+ * 读取上次成功获取的启动页配置,用于弱网下立即渲染。
+ */
+export function readCachedStartupSettings() {
+	try {
+		return uni.getStorageSync(CACHE_KEY) || null
+	} catch (error) {
+		console.warn('[startup] 读取启动页配置缓存失败:', error)
+		return null
+	}
+}
+
+/**
+ * 缓存本次成功的启动页配置,供下次冷启动立即渲染。
+ */
+export function cacheStartupSettings(settings) {
+	try {
+		uni.setStorageSync(CACHE_KEY, settings)
+	} catch (error) {
+		console.warn('[startup] 写入启动页配置缓存失败:', error)
+	}
+}
+
+/**
+ * 后台关闭启动页或清空图片时删除缓存,避免下次冷启动展示过期图片。
+ */
+export function clearCachedStartupSettings() {
+	try {
+		uni.removeStorageSync(CACHE_KEY)
+	} catch (error) {
+		console.warn('[startup] 清除启动页配置缓存失败:', error)
+	}
+}
diff --git a/traditionalApp/pages/startup/startup.vue b/traditionalApp/pages/startup/startup.vue
new file mode 100644
index 0000000..36f8137
--- /dev/null
+++ b/traditionalApp/pages/startup/startup.vue
@@ -0,0 +1,173 @@
+<template>
+    <!-- 启动页整屏铺满;未配置图片时保持白底,避免出现闪黑 -->
+    <view class="startup-page">
+        <image v-if="imageUrl" class="startup-image" :src="imageUrl" mode="aspectFill"
+            @load="handleImageLoad" @error="goHome" />
+    </view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
+import {
+    getStartupPageSettings,
+    readCachedStartupSettings,
+    cacheStartupSettings,
+    clearCachedStartupSettings,
+} from './service.js'
+
+/** 图片加载超时时间(毫秒);弱网下最多等这么久就进首页,避免长时间白屏 */
+const IMAGE_LOAD_TIMEOUT = 5000
+/** 启动页停留时长(毫秒),图片展示满 3 秒后自动跳首页 */
+const PAGE_DISPLAY_DURATION = 3000
+
+/** 启动页图片地址,由后台配置接口下发;为空表示无需展示启动页 */
+const imageUrl = ref('')
+/** 图片是否已绘制完成,只有绘制完成才证明用户真正看到图片,此时才开始计时 */
+let imageLoaded = false
+/** 页面是否处于前台;切到后台时不计时,避免回到前台直接被跳走 */
+let active = false
+/** 3 秒自动跳转首页的定时器 */
+let timer = null
+/** 图片加载超时兜底定时器 */
+let imageTimeout = null
+/** 是否已触发过跳转,保证跳转逻辑只执行一次 */
+let finished = false
+
+/**
+ * 清理自动跳转定时器。
+ */
+function clearTimer() {
+    if (timer) {
+        clearTimeout(timer)
+        timer = null
+    }
+}
+
+/**
+ * 清理图片加载超时定时器。
+ */
+function clearImageTimeout() {
+    if (imageTimeout) {
+        clearTimeout(imageTimeout)
+        imageTimeout = null
+    }
+}
+
+/**
+ * 判断启动页是否仍处于页面栈顶。
+ * 例如被推广链接拉起注册页时栈顶不是启动页,此时不能抢跳首页。
+ */
+function isCurrentPage() {
+    const pages = getCurrentPages()
+    return pages.length && pages[pages.length - 1].route === 'pages/startup/startup'
+}
+
+/**
+ * 进入首页。
+ * 已跳转过、页面在后台或已不在栈顶时直接返回,避免覆盖用户的正常跳转。
+ */
+function goHome() {
+    if (finished || !active || !isCurrentPage()) return
+    finished = true
+    clearTimer()
+    clearImageTimeout()
+    // 首页为 tabBar 页面,switchTab 失败时用 reLaunch 兜底,避免停留在空白启动页。
+    uni.switchTab({
+        url: '/pages/home/home',
+        fail: () => uni.reLaunch({ url: '/pages/home/home' })
+    })
+}
+
+/**
+ * 应用一份启动页配置。
+ * 地址没变化时保持当前渲染,避免图片重复加载;换了图片则按新图片重新计时,
+ * 防止旧图片的计时提前把页面跳走。
+ */
+function applySettings(settings) {
+    if (!settings?.enabled || !settings?.imageUrl) return
+    if (imageUrl.value === settings.imageUrl) return
+    imageUrl.value = settings.imageUrl
+    imageLoaded = false
+    clearTimer()
+    clearImageTimeout()
+    imageTimeout = setTimeout(goHome, IMAGE_LOAD_TIMEOUT)
+}
+
+/**
+ * 图片绘制完成:停掉超时兜底,并开始计算 3 秒展示时间。
+ */
+function handleImageLoad() {
+    clearImageTimeout()
+    imageLoaded = true
+    if (active && !timer) timer = setTimeout(goHome, PAGE_DISPLAY_DURATION)
+}
+
+/**
+ * 拉取后台启动页配置。
+ * 先用缓存渲染再看接口结果,弱网下也能尽快出图;接口未开启、超时或失败时按降级规则进首页。
+ */
+onLoad(async () => {
+    // 先用上次成功的配置渲染,弱网下不必等接口返回,减少白屏时间
+    applySettings(readCachedStartupSettings())
+    try {
+        const response = await getStartupPageSettings()
+        const settings = response?.code === 0 ? response.data : null
+        if (!settings?.enabled || !settings?.imageUrl) {
+            // 后台已关闭或未配图:清缓存并立即进首页,不再等满 3 秒
+            clearCachedStartupSettings()
+            goHome()
+            return
+        }
+        cacheStartupSettings(settings)
+        applySettings(settings)
+    } catch (error) {
+        console.warn('[startup] 启动页配置加载失败:', error)
+        // 弱网请求失败时保留缓存展示;没有缓存则直接进首页,避免卡在空白页
+        if (!imageUrl.value) goHome()
+    }
+})
+
+/**
+ * 页面显示时恢复计时。
+ * 配置已在 onShow 之前返回、或从后台切回前台,都在这里补上计时。
+ */
+onShow(() => {
+    active = true
+    if (imageLoaded && !timer && !finished) timer = setTimeout(goHome, PAGE_DISPLAY_DURATION)
+    if (imageUrl.value && !imageLoaded && !imageTimeout && !finished) imageTimeout = setTimeout(goHome, IMAGE_LOAD_TIMEOUT)
+})
+
+/**
+ * 页面隐藏:清掉所有计时,避免在后台期间触发跳转。
+ */
+onHide(() => {
+    active = false
+    clearTimer()
+    clearImageTimeout()
+})
+
+/**
+ * 页面销毁:兜底清理定时器,防止内存泄漏。
+ */
+onUnload(() => {
+    active = false
+    clearTimer()
+    clearImageTimeout()
+})
+</script>
+
+<style scoped>
+.startup-page {
+    position: fixed;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    background: #fff;
+}
+
+.startup-image {
+    width: 100%;
+    height: 100%;
+}
+</style>
diff --git a/traditionalApp/pages/watchLive/watchLive.vue b/traditionalApp/pages/watchLive/watchLive.vue
index ed8672b..c7f6379 100644
--- a/traditionalApp/pages/watchLive/watchLive.vue
+++ b/traditionalApp/pages/watchLive/watchLive.vue
@@ -36,10 +36,12 @@
 						</cover-view>
 					</cover-view>
 				</cover-view>
-				<cover-view v-if="showCustomPlayButton" class="video-cover-play-button"
+                                <!-- 暂时改用原生播放按钮,保留自定义结构便于恢复。
+                                <cover-view v-if="showCustomPlayButton" class="video-cover-play-button"
 					:class="{ 'video-cover-play-button--plain': isIosApp }" @tap="startManualPlayback">
-					▶
+                                        <cover-view class="video-cover-play-icon">▶</cover-view>
 				</cover-view>
+                                -->
 				<cover-image v-if="showEnterFullscreenButton" class="video-cover-fullscreen-btn video-cover-fullscreen-btn--icon"
 					src="/static/image/live-fullscreen.png" @tap="toggleFullscreen" @click="toggleFullscreen"></cover-image>
 			</view>
@@ -530,7 +532,7 @@
 	const pageTitle = computed(() => liveDetail.value.title || '直播观看')
 	const isReadonlyDisplayMode = isH5
 	const isLoaderGatedPlayback = computed(() => isReadonlyDisplayMode)
-	const showNativeCenterPlayButton = computed(() => false)
+        const showNativeCenterPlayButton = computed(() => !isReadonlyDisplayMode)
 	const videoPoster = computed(() => liveDetail.value.displayImage || liveDetail.value.coverImage || defaultVideoPoster)
 	const videoSectionStyle = computed(() => ({
 		height: showAnswer.value ? '500rpx' : '1234rpx',
@@ -2035,15 +2037,24 @@
 
 		.video-cover-play-button {
 			position: absolute;
-			left: 315rpx;
+                        left: 50%;
 			top: 50%;
 			z-index: 101;
 			width: 120rpx;
 			height: 120rpx;
-			line-height: 120rpx;
+                        margin-left: -60rpx;
 			margin-top: -60rpx;
 			border-radius: 60rpx;
 			background-color: rgba(0, 0, 0, 0.68);
+                }
+
+                .video-cover-play-icon {
+                        position: absolute;
+                        left: 8rpx;
+                        top: 34rpx;
+                        width: 112rpx;
+                        height: 52rpx;
+                        line-height: 52rpx;
 			color: #ffffff;
 			font-size: 52rpx;
 			font-weight: 600;
@@ -2053,10 +2064,15 @@
 		.video-cover-play-button--plain {
 			background-color: transparent;
 			border-radius: 0;
-			color: #ffffff;
-			font-size: 72rpx;
-			font-weight: 400;
-			text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.48);
+
+                        .video-cover-play-icon {
+                                top: 24rpx;
+                                height: 72rpx;
+                                line-height: 72rpx;
+                                font-size: 72rpx;
+                                font-weight: 400;
+                                text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.48);
+                        }
 		}
 
 		.video-cover-loader-mask {
diff --git a/traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue b/traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue
index 248d20d..9368bb2 100644
--- a/traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue
+++ b/traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue
@@ -19,7 +19,7 @@
 			@canplay="handleVideoReady"
 		></video>
 		<view class="back-button" @click="closePlayer">
-			<text class="back-button-text">‹</text>
+                        <view class="back-button-arrow"></view>
 		</view>
 	</view>
 </template>
@@ -54,20 +54,20 @@
 		},
 	},
 	onLoad(options) {
-		const systemInfo = uni.getSystemInfoSync()
-		this.windowWidth = systemInfo.windowWidth || this.windowWidth
-		this.windowHeight = systemInfo.windowHeight || this.windowHeight
+                this.refreshWindowSize()
+                this.lockLandscape()
 		this.videoUrl = decodeRouteText(options && options.videoUrl)
 		this.poster = decodeRouteText(options && options.poster)
 		this.initialTime = normalizeNumber(options && options.initialTime)
 	},
 	onReady() {
 		this.playerContext = uni.createVideoContext(PLAYER_ID, this)
-		this.lockLandscape()
-		setTimeout(() => {
-			this.refreshWindowSize()
-			this.playerContext && this.playerContext.play()
-		}, 180)
+                this.refreshWindowSize()
+                this.playerContext.play()
+        },
+        onResize(event) {
+                // 横屏旋转是异步的,以窗口事件为准,避免固定延时读到竖屏尺寸。
+                this.refreshWindowSize(event && event.size)
 	},
 	onUnload() {
 		this.playerContext && this.playerContext.pause()
@@ -79,10 +79,10 @@
 		return false
 	},
 	methods: {
-		refreshWindowSize() {
-			const systemInfo = uni.getSystemInfoSync()
-			this.windowWidth = systemInfo.windowWidth || this.windowWidth
-			this.windowHeight = systemInfo.windowHeight || this.windowHeight
+                refreshWindowSize(size) {
+                        const windowInfo = size || uni.getSystemInfoSync()
+                        this.windowWidth = windowInfo.windowWidth || this.windowWidth
+                        this.windowHeight = windowInfo.windowHeight || this.windowHeight
 		},
 		handleVideoReady(event) {
 			if (this.hasPrepared) {
@@ -149,22 +149,27 @@
 }
 
 .back-button {
-	position: absolute;
-	left: 24rpx;
-	top: 16rpx;
-	z-index: 9999;
-	width: 64rpx;
-	height: 64rpx;
-	border-radius: 32rpx;
-	background-color: rgba(0, 0, 0, 0.72);
-	align-items: center;
-	justify-content: center;
+    /* 横屏使用固定尺寸,避免 rpx 随窗口宽度放大按钮。 */
+    position: absolute;
+    left: 32px;
+    top: 36px;
+    z-index: 9999;
+    width: 44px;
+    height: 44px;
+    border-radius: 22px;
+    background-color: rgba(0, 0, 0, 0.45);
+    align-items: center;
+    justify-content: center;
 }
 
-.back-button-text {
-	color: #ffffff;
-	font-size: 52rpx;
-	font-weight: 400;
-	text-align: center;
+.back-button-arrow {
+    width: 12px;
+    height: 12px;
+    margin-left: 5px;
+    border-left-width: 2px;
+    border-bottom-width: 2px;
+    border-style: solid;
+    border-color: #ffffff;
+    transform: rotate(45deg);
 }
 </style>
diff --git a/traditionalApp/pages/webview/webview.vue b/traditionalApp/pages/webview/webview.vue
index a5d53c4..7e2d7ed 100644
--- a/traditionalApp/pages/webview/webview.vue
+++ b/traditionalApp/pages/webview/webview.vue
@@ -9,9 +9,32 @@
 
 <script setup lang="ts">
 import { ref } from 'vue'
-import { onLoad } from '@dcloudio/uni-app'
+import { onBackPress, onLoad } from '@dcloudio/uni-app'
 
 const webViewUrl = ref('')
+let isLeavingPage = false
+
+// #ifdef APP-PLUS
+onBackPress((event) => {
+    // 放行主动发起的页面返回,避免 navigateBack 再次触发拦截。
+    if (event.from === 'navigateBack') {
+        return false
+    }
+    if (!isLeavingPage) {
+        isLeavingPage = true
+        uni.navigateBack({
+            delta: 1,
+            fail(error) {
+                isLeavingPage = false
+                console.error('[webview] 返回 App 页面失败', error)
+                uni.showToast({ title: '返回失败,请重试', icon: 'none' })
+            },
+        })
+    }
+    // 消费导航栏和系统返回事件,不再回退第三方 H5 的浏览历史。
+    return true
+})
+// #endif
 
 /**
  * 页面加载时读取外链地址与标题。
diff --git a/traditionalApp/utils/public-request.js b/traditionalApp/utils/public-request.js
index 8f6e7b6..89c8bfd 100644
--- a/traditionalApp/utils/public-request.js
+++ b/traditionalApp/utils/public-request.js
@@ -2,15 +2,16 @@
 
 /**
  * 游客可访问接口专用请求,避免被本地登录态刷新、旧 token、请求队列影响。
+ * timeout 默认 10 秒,冷启动等对速度敏感的接口可传入更短超时。
  */
-export function publicGet(url, data = {}) {
+export function publicGet(url, data = {}, timeout = 10000) {
 	const requestUrl = `${config.BASE_URL}${url}`
 	return new Promise((resolve, reject) => {
 		uni.request({
 			url: requestUrl,
 			method: 'GET',
 			data,
-			timeout: 10000,
+			timeout,
 			header: {
 				'content-type': 'application/json',
 				lang: uni.getStorageSync('locale') === 'zh-Hans' ? 'zh_CN' : 'zh-tw',

--
Gitblit v1.7.1