zjk
2026-09-24 4c4b36e7337385ff15ff66a0ad487cdffaf9c86b
feat: 新增APP启动页功能,完善多项业务优化

新增APP启动页全链路支持:
- 添加数据库启动页配置字段、后端接口与VO类,新增数据库升级脚本
- 实现前端App启动页页面、缓存降级逻辑与后台调用接口
- 新增后台管理系统启动页配置项与单图片上传组件

完善诊所、省代用户的Excel导出功能:
- 补充诊所列表的地址、省代字段,新增导出接口与单元测试
- 优化省代导出的表头与字段映射逻辑

优化其他细节:
- 升级应用版本号至1.0.48,新增iOS启动屏配置
- 优化公共请求工具的timeout参数为可配置项
- 新增webview页面APP端返回拦截逻辑
- 调整直播播放器自定义播放按钮样式,暂时注释版本更新弹窗
- 补充各类代码注释与格式化代码
10个文件已添加
23个文件已修改
948 ■■■■■ 已修改文件
tcm-training-admin-backend/sql/init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/tcm_training_admin.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/update_startup_page_settings.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/update_system_settings_schema.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/excel/ProvincialAgentExcelRow.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/controller/AppStartupPageController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/vo/AppStartupPageVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/ClinicExcelRow.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/ClinicListRow.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/SystemSettingsSaveRequest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/entity/SystemSettings.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/SystemSettingsAppService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/SystemSettingsVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/mapper/ClinicQueryMapper.xml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ClinicExportTest.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ProvincialAgentExportTest.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/SystemSettingsAppServiceTest.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/components/SingleImageUpload.tsx 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/agent/index.tsx 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/agent/service.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/basic-settings/index.tsx 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/manifest.json 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/startup/service.js 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/startup/startup.vue 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/webview/webview.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/public-request.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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 '更新人',
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 '更新人',
tcm-training-admin-backend/sql/update_startup_page_settings.sql
New file
@@ -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;
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 '更新人',
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) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/excel/ProvincialAgentExcelRow.java
New file
@@ -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;
}
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));
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/controller/AppStartupPageController.java
New file
@@ -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);
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/system/vo/AppStartupPageVO.java
New file
@@ -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 = "";
}
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;
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;
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()));
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;
}
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;
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;
    }
}
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;
}
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>
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ClinicExportTest.java
New file
@@ -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();
    }
}
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/ProvincialAgentExportTest.java
New file
@@ -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();
    }
}
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/SystemSettingsAppServiceTest.java
New file
@@ -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());
    }
}
tcm-training-admin/src/components/SingleImageUpload.tsx
New file
@@ -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;
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></>}>
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});
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>
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" : {
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": {
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>
traditionalApp/pages/startup/service.js
New file
@@ -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)
    }
}
traditionalApp/pages/startup/startup.vue
New file
@@ -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>
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"
                    :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,11 +2064,16 @@
        .video-cover-play-button--plain {
            background-color: transparent;
            border-radius: 0;
            color: #ffffff;
                        .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 {
            position: absolute;
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.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 {
    /* 横屏使用固定尺寸,避免 rpx 随窗口宽度放大按钮。 */
    position: absolute;
    left: 24rpx;
    top: 16rpx;
    left: 32px;
    top: 36px;
    z-index: 9999;
    width: 64rpx;
    height: 64rpx;
    border-radius: 32rpx;
    background-color: rgba(0, 0, 0, 0.72);
    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>
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
/**
 * 页面加载时读取外链地址与标题。
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',