feat: 新增APP启动页功能,完善多项业务优化
新增APP启动页全链路支持:
- 添加数据库启动页配置字段、后端接口与VO类,新增数据库升级脚本
- 实现前端App启动页页面、缓存降级逻辑与后台调用接口
- 新增后台管理系统启动页配置项与单图片上传组件
完善诊所、省代用户的Excel导出功能:
- 补充诊所列表的地址、省代字段,新增导出接口与单元测试
- 优化省代导出的表头与字段映射逻辑
优化其他细节:
- 升级应用版本号至1.0.48,新增iOS启动屏配置
- 优化公共请求工具的timeout参数为可配置项
- 新增webview页面APP端返回拦截逻辑
- 调整直播播放器自定义播放按钮样式,暂时注释版本更新弹窗
- 补充各类代码注释与格式化代码
| | |
| | | `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 '更新人', |
| | |
| | | `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 '更新人', |
| New file |
| | |
| | | -- 在目标主库执行;只增加启动页字段,不修改已有业务数据,可重复执行。 |
| | | 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; |
| | |
| | | `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 '更新人', |
| | |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.validation.Valid; |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.IOException; |
| | | |
| | | @Validated |
| | | @RestController |
| | |
| | | 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) { |
| New file |
| | |
| | | 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; |
| | | } |
| | |
| | | |
| | | 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; |
| | |
| | | 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)); |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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 = ""; |
| | | } |
| | |
| | | @ExcelProperty("诊所区域") |
| | | private String regionName; |
| | | |
| | | @ExcelProperty("详细地址") |
| | | private String address; |
| | | |
| | | @ExcelProperty("省代") |
| | | private String agentName; |
| | | |
| | | @ExcelProperty("绑定用户数") |
| | | private Integer userCount; |
| | | |
| | |
| | | private String name; |
| | | private String phone; |
| | | private String regionName; |
| | | private String address; |
| | | private Integer status; |
| | | private String agentName; |
| | | private Integer userCount; |
| | |
| | | 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())); |
| | |
| | | @NotNull(message = "请输入会员到期提醒天数") |
| | | @Min(value = 1, message = "会员到期提醒天数必须大于0") |
| | | private Integer memberExpireRemindDays; |
| | | |
| | | private Boolean startupPageEnabled = false; |
| | | |
| | | @Size(max = 1024, message = "启动页图片地址不能超过1024个字符") |
| | | private String startupPageImageUrl; |
| | | } |
| | |
| | | @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; |
| | | |
| | |
| | | |
| | | @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) |
| | |
| | | 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) { |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | private String serviceOnlineTime; |
| | | private Integer backupCycleDays; |
| | | private Integer memberExpireRemindDays; |
| | | private Boolean startupPageEnabled; |
| | | private String startupPageImageUrl; |
| | | } |
| | |
| | | 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 |
| | |
| | | c.status, |
| | | pa.agent_name, |
| | | c.created_at, |
| | | c.business_license_url |
| | | c.business_license_url, |
| | | c.address |
| | | ORDER BY c.id DESC |
| | | </select> |
| | | |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.MybatisConfiguration; |
| | | import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
| | | import com.tcm.training.admin.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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | |
| | | 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(); |
| | |
| | | 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>(); |
| | |
| | | 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]); |
| | |
| | | <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></>}> |
| | |
| | | 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}); |
| | |
| | | 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'; |
| | |
| | | memberExpireRemindDays?: string; |
| | | serviceOnlineTime?: string; |
| | | servicePhone?: string; |
| | | startupPageEnabled?: boolean; |
| | | startupPageImageUrl?: string; |
| | | }; |
| | | |
| | | const validatePositiveInteger = (label: string) => (_: unknown, value: string | undefined) => { |
| | |
| | | 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']; |
| | |
| | | data?.memberExpireRemindDays === undefined || data?.memberExpireRemindDays === null |
| | | ? '' |
| | | : String(data.memberExpireRemindDays), |
| | | startupPageEnabled: data?.startupPageEnabled ?? false, |
| | | startupPageImageUrl: data?.startupPageImageUrl || '', |
| | | }); |
| | | } catch (error) { |
| | | message.error((error as Error)?.message || '系统设置加载失败'); |
| | |
| | | }, [canView]); |
| | | |
| | | const handleSubmit = async () => { |
| | | if (!canEdit || submitting) { |
| | | if (!canEdit || submitting || uploading) { |
| | | return; |
| | | } |
| | | const values = await form.validateFields(); |
| | |
| | | serviceOnlineTime: values.serviceOnlineTime?.trim(), |
| | | backupCycleDays: Number(values.backupCycleDays), |
| | | memberExpireRemindDays: Number(values.memberExpireRemindDays), |
| | | startupPageEnabled: values.startupPageEnabled ?? false, |
| | | startupPageImageUrl: values.startupPageImageUrl || '', |
| | | }); |
| | | message.success('保存成功'); |
| | | await loadCurrent(); |
| | |
| | | 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"> |
| | |
| | | <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" |
| | |
| | | <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 |
| | |
| | | </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> |
| | |
| | | "name" : "海诊康道", |
| | | "appid" : "__UNI__33EC1E8", |
| | | "description" : "", |
| | | "versionName" : "1.0.47", |
| | | "versionCode" : 147, |
| | | "versionName" : "1.0.48", |
| | | "versionCode" : 148, |
| | | "transformPx" : false, |
| | | /* 5+App特有相关 */ |
| | | "app-plus" : { |
| | |
| | | }, |
| | | "splashscreen" : { |
| | | "androidStyle" : "common", |
| | | "useOriginalMsgbox" : true |
| | | "useOriginalMsgbox" : true, |
| | | "iosStyle" : "common" |
| | | } |
| | | }, |
| | | "nativePlugins" : { |
| | |
| | | // } |
| | | // ], |
| | | "pages": [ |
| | | // H5 仅发布课程分享页,其他平台保留首页入口。 |
| | | // #ifdef APP-PLUS |
| | | { |
| | | "path": "pages/startup/startup", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | // #endif |
| | | // H5 保留首页入口,App 冷启动先进入启动页。 |
| | | { |
| | | "path": "pages/home/home", |
| | | "style": { |
| | |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <update-popup |
| | | <!-- <update-popup |
| | | :show="versionPopupVisible" |
| | | :title="versionPopupTitle" |
| | | :content="versionPopupContent" |
| | |
| | | :submitting="versionUpdating" |
| | | @close="closeVersionPopup" |
| | | @submit="handleUpdateNow"> |
| | | </update-popup> |
| | | </update-popup> --> |
| | | </view> |
| | | </template> |
| | | |
| New file |
| | |
| | | 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) |
| | | } |
| | | } |
| New file |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | 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', |
| | |
| | | |
| | | .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; |
| | |
| | | .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; |
| | |
| | | @canplay="handleVideoReady" |
| | | ></video> |
| | | <view class="back-button" @click="closePlayer"> |
| | | <text class="back-button-text">‹</text> |
| | | <view class="back-button-arrow"></view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | |
| | | }, |
| | | }, |
| | | 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() |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | .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> |
| | |
| | | |
| | | <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 |
| | | |
| | | /** |
| | | * 页面加载时读取外链地址与标题。 |
| | |
| | | |
| | | /** |
| | | * 游客可访问接口专用请求,避免被本地登录态刷新、旧 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', |