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