zjk
2026-09-18 e429c291995f3f20184079e480031533ae5c03e6
refactor(全项目): 综合优化全端业务与修复各类问题

本次提交包含以下多类改动:
- 后端业务:移除手机号脱敏展示逻辑,重构直播场次自动开播与收敛规则,修复同课程多直播场次冲突问题;重构用户诊所绑定业务,优化校验流程并补充完整单元测试
- 后台管理前端:为诊所选择下拉框添加搜索功能,重构诊所管理页面代码格式与导入顺序
- uniapp移动端项目:优化健康课程页面标签样式,新增H5端vConsole调试面板,调整配置文件与路由适配H5分享场景,修复课程详情页底部布局遮挡问题
- 调整部分环境配置文件,更新安卓业务模块依赖包
1个文件已添加
17个文件已修改
871 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/env.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/clinic/manage.tsx 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/list-page.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/modals.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/config/index.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/main.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/manifest.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/nativeplugins/business-module/android/mylibrary-release.aar 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/courseDetail.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/healthCourses/healthCourses.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/h5-debug.js 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java
@@ -17,7 +17,6 @@
import com.tcm.training.admin.app.register.entity.SysRegion;
import com.tcm.training.admin.app.register.mapper.SysRegionMapper;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
import com.tcm.training.admin.live.entity.Clinic;
import com.tcm.training.admin.live.mapper.ClinicMapper;
import com.tcm.training.admin.utils.VzanHttpUtil;
@@ -188,7 +187,7 @@
        ProvincialAgentListItemVO vo = new ProvincialAgentListItemVO();
        vo.setId(agent.getId());
        vo.setAgentName(agent.getAgentName());
        vo.setContactInfo(PhoneMasker.mask(agent.getContactInfo()));
        vo.setContactInfo(agent.getContactInfo());
        vo.setAddress(agent.getAddress());
        vo.setCreatedAt(agent.getCreatedAt());
        vo.setStatus(agent.getStatus());
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java
@@ -388,26 +388,30 @@
     * 解析同课程时间线下当前有效的直播中场次。
     */
    private LiveSession resolveEffectiveLivingSession(List<LiveSession> sessions, LocalDateTime now) {
        LiveSession latestStartedSession = null;
        LiveSession latestLivingSession = null;
        for (LiveSession session : sessions) {
            if (session == null || session.getStartTime() == null || session.getStartTime().isAfter(now)) {
            if (session == null || safeInt(session.getLiveStatus()) != 1) {
                continue;
            }
            if (latestStartedSession == null || compareTimelineSession(latestStartedSession, session) < 0) {
                latestStartedSession = session;
            LocalDateTime timelineTime = resolveLivingTimelineTime(session);
            if (timelineTime == null || timelineTime.isAfter(now)) {
                continue;
            }
            if (latestLivingSession == null || compareTimelineSession(latestLivingSession, session) < 0) {
                latestLivingSession = session;
            }
        }
        if (latestStartedSession == null || safeInt(latestStartedSession.getLiveStatus()) != 1) {
            return null;
        }
        return latestStartedSession;
        return latestLivingSession;
    }
    /**
     * 比较两场已开始场次在课程时间线中的先后,开始时间更晚者优先。
     */
    private int compareTimelineSession(LiveSession left, LiveSession right) {
        int startTimeCompare = compareSessionTime(left == null ? null : left.getStartTime(), right == null ? null : right.getStartTime());
        int startTimeCompare = compareSessionTime(
                left == null ? null : resolveLivingTimelineTime(left),
                right == null ? null : resolveLivingTimelineTime(right)
        );
        if (startTimeCompare != 0) {
            return startTimeCompare;
        }
@@ -426,6 +430,19 @@
    }
    /**
     * 直播中场次优先按实际开播时间参与收敛,手动提前开播时不会再被计划开播时间误伤。
     */
    private LocalDateTime resolveLivingTimelineTime(LiveSession session) {
        if (session == null) {
            return null;
        }
        if (session.getActualStartTime() != null) {
            return session.getActualStartTime();
        }
        return session.getStartTime();
    }
    /**
     * 比较两个时间值,null 视为更早的时间。
     */
    private int compareSessionTime(LocalDateTime left, LocalDateTime right) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java
@@ -10,8 +10,10 @@
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@@ -46,7 +48,8 @@
                .orderByAsc(LiveSession::getId));
        int startedCount = 0;
        if (dueSessions != null && !dueSessions.isEmpty()) {
            for (LiveSession dueSession : dueSessions) {
            List<LiveSession> effectiveDueSessions = pickEffectiveDueSessions(dueSessions);
            for (LiveSession dueSession : effectiveDueSessions) {
                // 定时任务与人工开播共用同一套状态迁移逻辑,避免状态口径分叉。
                LiveSessionStatusVO statusVO = liveSessionAppService.autoStart(dueSession.getId(), now);
                if (statusVO == null) {
@@ -58,7 +61,8 @@
            }
            if (startedCount > 0) {
                log.info("live session auto start finished, scanned={}, started={}", dueSessions.size(), startedCount);
                log.info("live session auto start finished, scanned={}, effective={}, started={}",
                        dueSessions.size(), effectiveDueSessions.size(), startedCount);
            }
        }
@@ -66,6 +70,63 @@
    }
    /**
     * 同一课程只挑一条最应被自动开播的场次,避免历史过期开播记录被批量拉起后又互相收敛。
     */
    private List<LiveSession> pickEffectiveDueSessions(List<LiveSession> dueSessions) {
        if (dueSessions == null || dueSessions.isEmpty()) {
            return dueSessions;
        }
        Map<Long, LiveSession> effectiveSessionMap = new LinkedHashMap<Long, LiveSession>();
        for (LiveSession dueSession : dueSessions) {
            if (dueSession == null || dueSession.getLiveCourseId() == null) {
                continue;
            }
            LiveSession current = effectiveSessionMap.get(dueSession.getLiveCourseId());
            if (current == null || compareDueSession(current, dueSession) < 0) {
                effectiveSessionMap.put(dueSession.getLiveCourseId(), dueSession);
            }
        }
        return new java.util.ArrayList<LiveSession>(effectiveSessionMap.values());
    }
    /**
     * 自动开播候选按计划开播时间排序,时间更晚者优先;同时间取 ID 更大的最新记录。
     */
    private int compareDueSession(LiveSession left, LiveSession right) {
        LocalDateTime leftTime = left == null ? null : left.getStartTime();
        LocalDateTime rightTime = right == null ? null : right.getStartTime();
        if (leftTime == null && rightTime == null) {
            return compareSessionId(left, right);
        }
        if (leftTime == null) {
            return -1;
        }
        if (rightTime == null) {
            return 1;
        }
        int timeCompare = leftTime.compareTo(rightTime);
        if (timeCompare != 0) {
            return timeCompare;
        }
        return compareSessionId(left, right);
    }
    private int compareSessionId(LiveSession left, LiveSession right) {
        Long leftId = left == null ? null : left.getId();
        Long rightId = right == null ? null : right.getId();
        if (leftId == null && rightId == null) {
            return 0;
        }
        if (leftId == null) {
            return -1;
        }
        if (rightId == null) {
            return 1;
        }
        return leftId.compareTo(rightId);
    }
    /**
     * 定时收敛历史残留的直播中场次,避免同课程出现多条或过期直播中状态。
     */
    private void reconcileLivingCourses(LocalDateTime now) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java
@@ -12,7 +12,6 @@
import com.tcm.training.admin.app.register.mapper.SysRegionMapper;
import com.tcm.training.admin.common.DateTimes;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
import com.tcm.training.admin.live.entity.Clinic;
import com.tcm.training.admin.live.mapper.ClinicMapper;
import com.tcm.training.admin.member.dto.ClinicBankAccountUpdateRequest;
@@ -203,7 +202,7 @@
            ClinicExcelRow excel = new ClinicExcelRow();
            excel.setSerialNo(i + 1);
            excel.setName(defaultText(row.getName()));
            excel.setPhone(defaultText(PhoneMasker.mask(row.getPhone())));
            excel.setPhone(defaultText(row.getPhone()));
            excel.setRegionName(defaultText(row.getRegionName()));
            excel.setUserCount(row.getUserCount() == null ? 0 : row.getUserCount());
            excel.setStatus(statusText(row.getStatus()));
@@ -355,7 +354,7 @@
            ClinicBoundUserItemVO item = new ClinicBoundUserItemVO();
            item.setId(row.getId());
            item.setNickname(defaultText(row.getNickname()));
            item.setPhone(row.getPhone() == null ? "-" : PhoneMasker.mask(row.getPhone()));
            item.setPhone(defaultText(row.getPhone()));
            item.setAvatar(trimToNull(row.getAvatar()));
            item.setLivePoints(row.getLivePoints() == null ? 0 : row.getLivePoints());
            item.setIsMember(row.getIsMember() != null && row.getIsMember() == 1);
@@ -382,7 +381,7 @@
        ClinicListItemVO item = new ClinicListItemVO();
        item.setId(row.getId());
        item.setName(defaultText(row.getName()));
        item.setPhone(row.getPhone() == null ? "-" : PhoneMasker.mask(row.getPhone()));
        item.setPhone(defaultText(row.getPhone()));
        item.setRegionName(defaultText(row.getRegionName()));
        item.setStatus(row.getStatus());
        item.setAgentName(defaultText(row.getAgentName()));
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java
@@ -7,7 +7,6 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tcm.training.admin.common.DateTimes;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
import com.tcm.training.admin.live.entity.Clinic;
import com.tcm.training.admin.live.mapper.ClinicMapper;
import com.tcm.training.admin.member.dto.UserBatchClinicUpdateRequest;
@@ -147,12 +146,8 @@
    /** 更新用户信息。 */
    public UserDetailVO update(Long id, UserUpdateRequest req) {
        AppUser user = requireUser(id);
        if (req.getClinicId() != null) {
            Clinic clinic = clinicMapper.selectById(req.getClinicId());
            if (clinic == null || (clinic.getDeleted() != null && clinic.getDeleted() == 1)) {
                throw new IllegalArgumentException("绑定诊所不存在");
            }
        }
        boolean personalUser = Integer.valueOf(0).equals(user.getType());
        Long clinicUserId = personalUser ? resolveClinicUserId(req.getClinicId()) : null;
        AppUser phoneConflict = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getPhone, req.getPhone().trim())
                .ne(AppUser::getId, id)
@@ -166,9 +161,11 @@
        user.setPhone(req.getPhone().trim());
        user.setAvatar(trimToNull(req.getAvatar()));
        user.setStatus(req.getStatus());
        user.setClinicId(req.getClinicId());
        user.setMemberExpireAt(req.getMemberExpireAt());
        userMapper.updateById(user);
        if (personalUser) {
            updateClinicBinding(java.util.Collections.singletonList(id), req.getClinicId(), clinicUserId);
        }
        return detail(id);
    }
@@ -216,20 +213,48 @@
    @Transactional(rollbackFor = Exception.class)
    /** 批量更新用户诊所归属。 */
    public void batchUpdateClinic(UserBatchClinicUpdateRequest req) {
        Clinic clinic = clinicMapper.selectById(req.getClinicId());
        if (clinic == null || Integer.valueOf(1).equals(clinic.getDeleted())) {
            throw new IllegalArgumentException("诊所不存在");
        }
        Long clinicUserId = resolveClinicUserId(req.getClinicId());
        Long userCount = userMapper.selectCount(new LambdaQueryWrapper<AppUser>()
                .in(AppUser::getId, req.getUserIds())
                .eq(AppUser::getType, 0)
                .eq(AppUser::getDeleted, 0));
        if (userCount != req.getUserIds().size()) {
            throw new IllegalArgumentException("部分用户不存在");
            throw new IllegalArgumentException("部分用户不存在或不是普通用户");
        }
        userMapper.update(null, new LambdaUpdateWrapper<AppUser>()
                .in(AppUser::getId, req.getUserIds())
        updateClinicBinding(req.getUserIds(), req.getClinicId(), clinicUserId);
    }
    /** 诊所 ID 与诊所账号用户 ID 不同,患者查询依赖后者。 */
    private Long resolveClinicUserId(Long clinicId) {
        if (clinicId == null) {
            return null;
        }
        Clinic clinic = clinicMapper.selectById(clinicId);
        if (clinic == null || Integer.valueOf(1).equals(clinic.getDeleted())) {
            throw new IllegalArgumentException("绑定诊所不存在");
        }
        List<AppUser> clinicUsers = userMapper.selectList(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getClinicId, clinicId)
                .eq(AppUser::getType, 1)
                .eq(AppUser::getDeleted, 0)
                .set(AppUser::getClinicId, req.getClinicId()));
                .last("limit 2"));
        if (clinicUsers.isEmpty()) {
            throw new IllegalArgumentException("目标诊所尚未关联诊所账号,无法绑定患者");
        }
        if (clinicUsers.size() > 1) {
            throw new IllegalArgumentException("目标诊所关联多个账号,请先确认患者归属");
        }
        return clinicUsers.get(0).getId();
    }
    /** 同步两个归属字段;显式 set 确保解除绑定时 null 也能写入。 */
    private void updateClinicBinding(List<Long> userIds, Long clinicId, Long clinicUserId) {
        userMapper.update(null, new LambdaUpdateWrapper<AppUser>()
                .in(AppUser::getId, userIds)
                .eq(AppUser::getType, 0)
                .eq(AppUser::getDeleted, 0)
                .set(AppUser::getClinicId, clinicId)
                .set(AppUser::getSuperiorClinicUserId, clinicUserId));
    }
    /** 查询诊所选项。 */
@@ -281,7 +306,7 @@
                MemberUserExcelRow excel = new MemberUserExcelRow();
                excel.setId(row.getId());
                excel.setNickname(row.getNickname());
                excel.setPhone(PhoneMasker.mask(row.getPhone()));
                excel.setPhone(row.getPhone());
                excel.setMemberStatus(row.getIsMember() != null && row.getIsMember() == 1 ? "生效中" : "已失效");
                excel.setMemberExpireAt(DateTimes.format(row.getMemberExpireAt()));
                memberExcelRows.add(excel);
@@ -297,7 +322,7 @@
            excel.setId(row.getId());
            excel.setNickname(row.getNickname());
            excel.setType(typeText(row.getType()));
            excel.setPhone(PhoneMasker.mask(row.getPhone()));
            excel.setPhone(row.getPhone());
            excel.setClinicName(row.getClinicName() == null ? "-" : row.getClinicName());
            excel.setAge(row.getAge());
            excel.setGender(genderText(row.getGender()));
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java
@@ -6,7 +6,10 @@
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.live.entity.Clinic;
import com.tcm.training.admin.live.mapper.ClinicMapper;
import com.tcm.training.admin.member.dto.UserBatchClinicUpdateRequest;
import com.tcm.training.admin.member.dto.UserUpdateRequest;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.member.row.UserDetailRow;
@@ -17,6 +20,9 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@@ -24,7 +30,9 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -133,4 +141,191 @@
        assertTrue(wrapperCaptor.getValue().getParamNameValuePairs().values().stream().allMatch(value -> value == null));
        assertEquals(Long.valueOf(12L), result.getId());
    }
    @ParameterizedTest
    @CsvSource({"10, 100", "20, 100", "20, "})
    void updateShouldSynchronizeClinicBindingIncludingStaleRelations(Long oldClinicId, Long oldDoctorId) {
        AppUser user = prepareUserUpdate(0);
        user.setClinicId(oldClinicId);
        user.setSuperiorClinicUserId(oldDoctorId);
        prepareClinicAccounts(1);
        userAppService.update(12L, updateRequest(20L));
        verify(userMapper).updateById(user);
        assertClinicBinding(Collections.singletonList(12L), 20L, 200L);
    }
    @Test
    void updateShouldExplicitlyClearBothClinicFields() {
        AppUser user = prepareUserUpdate(0);
        user.setClinicId(10L);
        user.setSuperiorClinicUserId(100L);
        userAppService.update(12L, updateRequest(null));
        assertClinicBinding(Collections.singletonList(12L), null, null);
        verify(clinicMapper, never()).selectById(any());
    }
    @Test
    void updateShouldPreserveDoctorAccountClinic() {
        AppUser user = prepareUserUpdate(1);
        user.setClinicId(10L);
        userAppService.update(12L, updateRequest(null));
        assertEquals(Long.valueOf(10L), user.getClinicId());
        verify(userMapper).updateById(user);
        verify(userMapper, never()).update(any(), any());
    }
    @Test
    void batchUpdateShouldSynchronizeBothClinicFields() {
        prepareClinicAccounts(1);
        when(userMapper.selectCount(any())).thenReturn(2L);
        userAppService.batchUpdateClinic(batchRequest());
        assertClinicBinding(Arrays.asList(12L, 13L), 20L, 200L);
    }
    @ParameterizedTest
    @ValueSource(ints = {0, 2})
    void updateShouldRejectMissingOrAmbiguousClinicAccount(int accountCount) {
        AppUser user = new AppUser();
        user.setId(12L);
        user.setType(0);
        when(userMapper.selectOne(any())).thenReturn(user);
        prepareClinicAccounts(accountCount);
        IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
                () -> userAppService.update(12L, updateRequest(20L)));
        assertEquals(accountCount == 0 ? "目标诊所尚未关联诊所账号,无法绑定患者"
                : "目标诊所关联多个账号,请先确认患者归属", error.getMessage());
        assertNoUserWrite();
    }
    @ParameterizedTest
    @ValueSource(ints = {0, 2})
    void batchUpdateShouldRejectMissingOrAmbiguousClinicAccount(int accountCount) {
        prepareClinicAccounts(accountCount);
        assertThrows(IllegalArgumentException.class,
                () -> userAppService.batchUpdateClinic(batchRequest()));
        assertNoUserWrite();
    }
    @ParameterizedTest
    @ValueSource(booleans = {false, true})
    void batchUpdateShouldRejectMissingOrDeletedClinic(boolean deleted) {
        if (deleted) {
            Clinic clinic = new Clinic();
            clinic.setId(20L);
            clinic.setDeleted(1);
            when(clinicMapper.selectById(20L)).thenReturn(clinic);
        }
        IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
                () -> userAppService.batchUpdateClinic(batchRequest()));
        assertEquals("绑定诊所不存在", error.getMessage());
        assertNoUserWrite();
    }
    @Test
    void batchUpdateShouldRejectUsersOutsidePersonalUserScope() {
        prepareClinicAccounts(1);
        when(userMapper.selectCount(any())).thenAnswer(invocation -> {
            com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AppUser> query =
                    invocation.getArgument(0);
            assertTrue(query.getSqlSegment().contains("type ="));
            assertTrue(query.getParamNameValuePairs().containsValue(0));
            return 1L;
        });
        IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
                () -> userAppService.batchUpdateClinic(batchRequest()));
        assertEquals("部分用户不存在或不是普通用户", error.getMessage());
        assertNoUserWrite();
    }
    private AppUser prepareUserUpdate(int type) {
        AppUser user = new AppUser();
        user.setId(12L);
        user.setType(type);
        user.setDeleted(0);
        // 第一次查询用户,第二次检查手机号冲突。
        when(userMapper.selectOne(any())).thenReturn(user, null);
        UserDetailRow detail = new UserDetailRow();
        detail.setId(12L);
        when(userMapper.selectUserDetail(12L)).thenReturn(detail);
        return user;
    }
    private void prepareClinicAccounts(int count) {
        Clinic clinic = new Clinic();
        clinic.setId(20L);
        clinic.setDeleted(0);
        when(clinicMapper.selectById(20L)).thenReturn(clinic);
        AppUser doctor = new AppUser();
        doctor.setId(200L);
        doctor.setType(1);
        doctor.setClinicId(20L);
        when(userMapper.selectList(any())).thenAnswer(invocation -> {
            com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AppUser> query =
                    invocation.getArgument(0);
            String sql = query.getSqlSegment();
            assertTrue(sql.contains("clinic_id =") && sql.contains("type =") && sql.contains("deleted ="));
            assertTrue(query.getParamNameValuePairs().values().containsAll(Arrays.asList(20L, 1, 0)));
            return Collections.nCopies(count, doctor);
        });
    }
    private UserUpdateRequest updateRequest(Long clinicId) {
        UserUpdateRequest request = new UserUpdateRequest();
        request.setNickname("测试用户");
        request.setPhone("13800000000");
        request.setStatus(1);
        request.setClinicId(clinicId);
        return request;
    }
    private UserBatchClinicUpdateRequest batchRequest() {
        UserBatchClinicUpdateRequest request = new UserBatchClinicUpdateRequest();
        request.setUserIds(Arrays.asList(12L, 13L));
        request.setClinicId(20L);
        return request;
    }
    private void assertNoUserWrite() {
        verify(userMapper, never()).updateById(any(AppUser.class));
        verify(userMapper, never()).update(any(), any());
    }
    private void assertClinicBinding(List<Long> userIds, Long clinicId, Long doctorId) {
        ArgumentCaptor<LambdaUpdateWrapper<AppUser>> captor = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
        verify(userMapper).update(isNull(), captor.capture());
        LambdaUpdateWrapper<AppUser> update = captor.getValue();
        // 按列名核对绑定参数,避免诊所 ID 和诊所账号 ID 被误用。
        String[] assignments = update.getSqlSet().split(",");
        assertEquals(2, assignments.length);
        assertAssignment(update, assignments[0], "clinic_id", clinicId);
        assertAssignment(update, assignments[1], "superior_clinic_user_id", doctorId);
        String sql = update.getSqlSegment();
        assertTrue(sql.contains("id IN") && sql.contains("type =") && sql.contains("deleted ="));
        assertTrue(update.getParamNameValuePairs().values().containsAll(userIds));
        assertTrue(update.getParamNameValuePairs().containsValue(0));
    }
    private void assertAssignment(LambdaUpdateWrapper<AppUser> update, String assignment,
                                  String column, Long expectedValue) {
        assertTrue(assignment.startsWith(column + "="));
        String parameter = assignment.substring(assignment.lastIndexOf('.') + 1, assignment.length() - 1);
        assertTrue(update.getParamNameValuePairs().containsKey(parameter));
        assertEquals(expectedValue, update.getParamNameValuePairs().get(parameter));
    }
}
tcm-training-admin/config/env.ts
@@ -1,7 +1,8 @@
export default {
  dev: {
//     SERVER_URL: 'http://192.168.110.188:48080/api',
    SERVER_URL: 'http://221.182.45.100:48080/api',
    // SERVER_URL: 'http://192.168.110.22:48080/api',
    SERVER_URL: 'http://localhost:48080/api',
    // SERVER_URL: 'http://221.182.45.100:48080/api',
  },
  test: {
    SERVER_URL: '/api',
tcm-training-admin/src/pages/member/clinic/manage.tsx
@@ -1,66 +1,224 @@
import { history, useAccess, useParams } from '@umijs/max';
import RegionCascader from '@/components/RegionCascader';
import { PlusOutlined } from '@ant-design/icons';
import { history, useAccess, useParams } from '@umijs/max';
import { Button, Form, Image, Input, Radio, Select, Upload, message } from 'antd';
import React, { useEffect, useState } from 'react';
import RegionCascader from '@/components/RegionCascader';
import { getClinicDetail, getProvincialAgentOptions, updateClinic, uploadClinicImage } from './service';
import './manage.less';
import {
  getClinicDetail,
  getProvincialAgentOptions,
  updateClinic,
  uploadClinicImage,
} from './service';
type FormValues = { avatar?: string; name?: string; loginAccount?: string; regionCodes?: string[]; address?: string; businessLicenseUrl?: string; status?: number; agentId?: number; bankName?: string; accountHolder?: string; accountNumber?: string; branchName?: string };
type FormValues = {
  avatar?: string;
  name?: string;
  loginAccount?: string;
  regionCodes?: string[];
  address?: string;
  businessLicenseUrl?: string;
  status?: number;
  agentId?: number;
  bankName?: string;
  accountHolder?: string;
  accountNumber?: string;
  branchName?: string;
};
const ClinicManagePage: React.FC = () => {
  const access = useAccess(); const params = useParams<{ id: string }>();
  const [form] = Form.useForm<FormValues>(); const [loading, setLoading] = useState(false);
  const access = useAccess();
  const params = useParams<{ id: string }>();
  const [form] = Form.useForm<FormValues>();
  const [loading, setLoading] = useState(false);
  const [agentOptions, setAgentOptions] = useState<{ label: string; value: number }[]>([]);
  const [avatar, setAvatar] = useState('');
  const [businessLicenseUrl, setBusinessLicenseUrl] = useState('');
  const [uploadingAvatar, setUploadingAvatar] = useState(false);
  const [uploadingLicense, setUploadingLicense] = useState(false);
  useEffect(() => {
    if (!access['member:clinic:edit']) { history.replace('/Welcome'); return; }
    getProvincialAgentOptions().then((response) => setAgentOptions((response.data?.list || []).map((item: any) => ({ label: item.agentName, value: item.id }))));
    if (params.id) getClinicDetail(params.id).then((response) => { const data = response.data || {}; const regionCodes = [data.provinceCode, data.cityCode, data.districtCode].filter(Boolean); form.setFieldsValue({ ...data, regionCodes }); setAvatar(data.avatar || ''); setBusinessLicenseUrl(data.businessLicenseUrl || ''); });
    if (!access['member:clinic:edit']) {
      history.replace('/Welcome');
      return;
    }
    getProvincialAgentOptions().then((response) =>
      setAgentOptions(
        (response.data?.list || []).map((item: any) => ({ label: item.agentName, value: item.id })),
      ),
    );
    if (params.id)
      getClinicDetail(params.id).then((response) => {
        const data = response.data || {};
        const regionCodes = [data.provinceCode, data.cityCode, data.districtCode].filter(Boolean);
        form.setFieldsValue({ ...data, regionCodes });
        setAvatar(data.avatar || '');
        setBusinessLicenseUrl(data.businessLicenseUrl || '');
      });
  }, [params.id]);
  const submit = async () => {
    const values = await form.validateFields(); if (!params.id) return; setLoading(true);
    try { const [provinceCode, cityCode, districtCode] = values.regionCodes || []; await updateClinic(params.id, { ...values, provinceCode, cityCode, districtCode }); message.success('保存成功'); history.push(`/member/user/clinic/${params.id}`); }
    finally { setLoading(false); }
    const values = await form.validateFields();
    if (!params.id) return;
    setLoading(true);
    try {
      const [provinceCode, cityCode, districtCode] = values.regionCodes || [];
      await updateClinic(params.id, { ...values, provinceCode, cityCode, districtCode });
      message.success('保存成功');
      history.push(`/member/user/clinic/${params.id}`);
    } finally {
      setLoading(false);
    }
  };
  return <div className="clinic-edit-page"><div className="clinic-edit-panel"><h2>编辑诊所</h2>
    <Form form={form} layout="vertical" className="clinic-edit-form">
      <Form.Item label="头像" className="clinic-edit-upload-item">
        <Form.Item name="avatar" hidden><Input /></Form.Item>
        <Upload accept="image/*" showUploadList={false} customRequest={async (options) => {
          setUploadingAvatar(true);
          try { const response = await uploadClinicImage(options.file as File); const url = response.data?.url || response.data; if (!url) throw new Error("upload failed"); setAvatar(url); form.setFieldsValue({ avatar: url }); options.onSuccess?.(response); } catch (error) { message.error("头像上传失败"); options.onError?.(error as Error); } finally { setUploadingAvatar(false); }
        }}>
          <div className="clinic-edit-upload clinic-edit-upload--avatar">{uploadingAvatar ? <span>上传中...</span> : avatar ? <Image src={avatar} width={104} height={104} preview={false} /> : <div><PlusOutlined /><span>上传头像</span></div>}</div>
        </Upload>
      </Form.Item>
  return (
    <div className="clinic-edit-page">
      <div className="clinic-edit-panel">
        <h2>编辑诊所</h2>
        <Form form={form} layout="vertical" className="clinic-edit-form">
          <Form.Item label="头像" className="clinic-edit-upload-item">
            <Form.Item name="avatar" hidden>
              <Input />
            </Form.Item>
            <Upload
              accept="image/*"
              showUploadList={false}
              customRequest={async (options) => {
                setUploadingAvatar(true);
                try {
                  const response = await uploadClinicImage(options.file as File);
                  const url = response.data?.url || response.data;
                  if (!url) throw new Error('upload failed');
                  setAvatar(url);
                  form.setFieldsValue({ avatar: url });
                  options.onSuccess?.(response);
                } catch (error) {
                  message.error('头像上传失败');
                  options.onError?.(error as Error);
                } finally {
                  setUploadingAvatar(false);
                }
              }}
            >
              <div className="clinic-edit-upload clinic-edit-upload--avatar">
                {uploadingAvatar ? (
                  <span>上传中...</span>
                ) : avatar ? (
                  <Image src={avatar} width={104} height={104} preview={false} />
                ) : (
                  <div>
                    <PlusOutlined />
                    <span>上传头像</span>
                  </div>
                )}
              </div>
            </Upload>
          </Form.Item>
      <div className="clinic-edit-form__grid">
      <Form.Item name="name" label="诊所名称" rules={[{ required: true, message: "请输入诊所名称" }]}><Input maxLength={100} placeholder="请输入诊所名称" /></Form.Item>
      <Form.Item name="loginAccount" label="登录账号" rules={[{ required: true, message: "请输入登录账号" }]}><Input maxLength={50} placeholder="请输入登录账号" /></Form.Item>
      <Form.Item name="regionCodes" label="诊所地址" rules={[{ required: true, message: "请选择诊所地址" }]}><RegionCascader placeholder="请选择省/市/区" /></Form.Item>
      <Form.Item name="address" label="详细地址" rules={[{ required: true, message: "请输入详细地址" }]}><Input maxLength={255} placeholder="请输入详细地址" /></Form.Item>
      <Form.Item label="营业执照" className="clinic-edit-upload-item">
        <Form.Item name="businessLicenseUrl" hidden><Input /></Form.Item>
        <Upload accept="image/*" showUploadList={false} customRequest={async (options) => {
          setUploadingLicense(true);
          try { const response = await uploadClinicImage(options.file as File); const url = response.data?.url || response.data; if (!url) throw new Error("upload failed"); setBusinessLicenseUrl(url); form.setFieldsValue({ businessLicenseUrl: url }); options.onSuccess?.(response); } catch (error) { message.error("营业执照上传失败"); options.onError?.(error as Error); } finally { setUploadingLicense(false); }
        }}>
          <div className="clinic-edit-upload clinic-edit-upload--license">{uploadingLicense ? <span>上传中...</span> : businessLicenseUrl ? <Image src={businessLicenseUrl} width={208} height={128} preview={false} /> : <div><PlusOutlined /><span>上传营业执照</span></div>}</div>
        </Upload>
      </Form.Item>
      <Form.Item name="status" label="状态"><Radio.Group options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} /></Form.Item>
          <div className="clinic-edit-form__grid">
            <Form.Item
              name="name"
              label="诊所名称"
              rules={[{ required: true, message: '请输入诊所名称' }]}
            >
              <Input maxLength={100} placeholder="请输入诊所名称" />
            </Form.Item>
            <Form.Item
              name="loginAccount"
              label="登录账号"
              rules={[{ required: true, message: '请输入登录账号' }]}
            >
              <Input maxLength={50} placeholder="请输入登录账号" />
            </Form.Item>
            <Form.Item
              name="regionCodes"
              label="诊所地址"
              rules={[{ required: true, message: '请选择诊所地址' }]}
            >
              <RegionCascader placeholder="请选择省/市/区" />
            </Form.Item>
            <Form.Item
              name="address"
              label="详细地址"
              rules={[{ required: true, message: '请输入详细地址' }]}
            >
              <Input maxLength={255} placeholder="请输入详细地址" />
            </Form.Item>
            <Form.Item label="营业执照" className="clinic-edit-upload-item">
              <Form.Item name="businessLicenseUrl" hidden>
                <Input />
              </Form.Item>
              <Upload
                accept="image/*"
                showUploadList={false}
                customRequest={async (options) => {
                  setUploadingLicense(true);
                  try {
                    const response = await uploadClinicImage(options.file as File);
                    const url = response.data?.url || response.data;
                    if (!url) throw new Error('upload failed');
                    setBusinessLicenseUrl(url);
                    form.setFieldsValue({ businessLicenseUrl: url });
                    options.onSuccess?.(response);
                  } catch (error) {
                    message.error('营业执照上传失败');
                    options.onError?.(error as Error);
                  } finally {
                    setUploadingLicense(false);
                  }
                }}
              >
                <div className="clinic-edit-upload clinic-edit-upload--license">
                  {uploadingLicense ? (
                    <span>上传中...</span>
                  ) : businessLicenseUrl ? (
                    <Image src={businessLicenseUrl} width={208} height={128} preview={false} />
                  ) : (
                    <div>
                      <PlusOutlined />
                      <span>上传营业执照</span>
                    </div>
                  )}
                </div>
              </Upload>
            </Form.Item>
            <Form.Item name="status" label="状态">
              <Radio.Group
                options={[
                  { label: '启用', value: 1 },
                  { label: '禁用', value: 0 },
                ]}
              />
            </Form.Item>
      <Form.Item name="bankName" label="银行名称"><Input maxLength={100} placeholder="请输入银行名称" /></Form.Item>
      <Form.Item name="accountHolder" label="账户名称"><Input maxLength={50} placeholder="请输入账户名称" /></Form.Item>
      <Form.Item name="accountNumber" label="账号"><Input maxLength={30} placeholder="请输入账号" /></Form.Item>
      <Form.Item name="branchName" label="支行名称"><Input maxLength={100} placeholder="请输入支行名称" /></Form.Item>
            <Form.Item name="bankName" label="银行名称">
              <Input maxLength={100} placeholder="请输入银行名称" />
            </Form.Item>
            <Form.Item name="accountHolder" label="账户名称">
              <Input maxLength={50} placeholder="请输入账户名称" />
            </Form.Item>
            <Form.Item name="accountNumber" label="账号">
              <Input maxLength={30} placeholder="请输入账号" />
            </Form.Item>
            <Form.Item name="branchName" label="支行名称">
              <Input maxLength={100} placeholder="请输入支行名称" />
            </Form.Item>
          </div>
          <Form.Item name="agentId" label="所属区域代理" className="clinic-edit-form__single">
            <Select
              allowClear
              showSearch
              optionFilterProp="label"
              options={agentOptions}
              placeholder="请选择所属区域代理"
            />
          </Form.Item>
          <div className="clinic-edit-form__footer">
            <Button onClick={() => history.back()}>取消</Button>
            <Button type="primary" loading={loading} onClick={submit}>
              保存
            </Button>
          </div>
        </Form>
      </div>
      <Form.Item name="agentId" label="所属区域代理" className="clinic-edit-form__single"><Select allowClear showSearch optionFilterProp="label" options={agentOptions} placeholder="请选择所属区域代理" /></Form.Item>
      <div className="clinic-edit-form__footer"><Button onClick={() => history.back()}>取消</Button><Button type="primary" loading={loading} onClick={submit}>保存</Button></div>
    </Form></div></div>;
    </div>
  );
};
export default ClinicManagePage;
tcm-training-admin/src/pages/member/user/list-page.tsx
@@ -402,6 +402,8 @@
                <Select
                  placeholder="请输入"
                  allowClear
                  showSearch
                  optionFilterProp="label"
                  options={clinics.map((item) => ({ label: item.name, value: item.id }))}
                />
              </Form.Item>
tcm-training-admin/src/pages/member/user/modals.tsx
@@ -180,6 +180,8 @@
          <Form.Item label="绑定诊所" name="clinicId">
            <Select
              allowClear
              showSearch
              optionFilterProp="label"
              placeholder="请选择"
              options={clinics.map((item) => ({ label: item.name, value: item.id }))}
            />
traditionalApp/config/index.js
@@ -1,6 +1,7 @@
export default {
//     BASE_URL: 'http://221.182.45.100:48080',
    // BASE_URL: 'http://192.168.110.22:48080',
    // BASE_URL: 'http://192.168.110.150:48080',
    BASE_URL: 'https://haizhentong.com',
    OPENINSTALL_URL: 'https://m.malink.cn/s/7bIzeu',
    uploadUrl: "https://haizhentong.com/api/open/file/upload",
traditionalApp/main.js
@@ -1,4 +1,8 @@
import App from "./App";
// #ifdef H5
import { initH5DebugPanel } from "@/utils/h5-debug.js";
initH5DebugPanel();
// #endif
// #ifndef VUE3
import Vue from "vue";
@@ -29,4 +33,4 @@
        Pinia,
    }
}
// #endif
// #endif
traditionalApp/manifest.json
@@ -1,7 +1,7 @@
{
    "sassImplementationName" : "node-sass",
    "name" : "海诊康道",
    "appid" : "__UNI__7B5B0CA",
    "appid" : "__UNI__33EC1E8",
    "description" : "",
    "versionName" : "1.0.0",
    "versionCode" : 100,
@@ -367,6 +367,9 @@
        }
    },
    "h5" : {
        "router" : {
            "base" : "/share/"
        },
        "unipush" : {
            "enable" : false
        }
@@ -379,4 +382,3 @@
    }
}
/* 快应用特有相关 *//* 小程序特有相关 */
traditionalApp/nativeplugins/business-module/android/mylibrary-release.aar
Binary files differ
traditionalApp/pages.json
@@ -5,13 +5,31 @@
            "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"
        }
    },
    "pages": [{
    // H5 仅发布课程分享页,其他平台保留首页入口。
    // "pages": [
    //     {
    //         "path": "pages/courseDetail/courseDetail",
    //         "style": {
    //             "navigationStyle": "custom"
    //         }
    //     }
    // ],
    "pages": [
        // H5 仅发布课程分享页,其他平台保留首页入口。
        {
            "path": "pages/home/home",
            "style": {
                "navigationStyle": "custom",
                "enablePullDownRefresh": true
            }
        }, {
        },
        {
            "path": "pages/courseDetail/courseDetail",
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/login/login",
            "style": {
                "navigationStyle": "custom"
@@ -236,12 +254,6 @@
        },
        {
            "path": "pages/articleDetail/articleDetail",
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/courseDetail/courseDetail",
            "style": {
                "navigationStyle": "custom"
            }
traditionalApp/pages/courseDetail/courseDetail.vue
@@ -128,18 +128,18 @@
                </view>
                <!-- 适用人群模块 -->
                <view v-if="courseDetail.targetUsers" class="module-section">
                           <view v-if="courseDetail.targetUsers" class="module-section module-section--with-padding">
                    <view class="module-title">适用人群</view>
                    <view class="module-desc">{{ courseDetail.targetUsers }}</view>
                </view>
                <!-- 授课专家模块 -->
                <view v-if="courseDetail.expert" class="module-section" @tap="goToExpertDetail">
                           <view v-if="courseDetail.expert" class="module-section module-section--with-padding" @tap="goToExpertDetail">
                    <view class="module-title">授课专家</view>
                    <view class="expert-card">
                        <image class="expert-avatar"
                            :src="courseDetail.expert.avatar || 'http://obs.haizhentong.com/file/avatr-b79ceea5dbfa4c51b247305c38a8471c.png'"
                            mode="aspectFill">
                            mode="widthFix">
                        </image>
                        <view class="expert-info">
                            <view class="expert-name">{{ courseDetail.expert.name || '专家老师' }}</view>
@@ -149,7 +149,7 @@
                </view>
                <!-- 课程详情模块 -->
                <view class="module-section last-module">
                           <view class="module-section module-section--with-padding last-module">
                    <view class="module-title">课程详情</view>
                    <rich-text :nodes="courseContentNodes" class="detail-content rich-detail-content"></rich-text>
                </view>
@@ -214,12 +214,16 @@
            </template>
            <view v-if="loading" class="loading-text">加载中...</view>
            <view v-if="isReadonlyDisplayMode" class="open-app-banner" @tap="openApp">
                <view class="app-logo-placeholder"></view>
                <text class="banner-text">打开APP,了解更多</text>
                <view class="open-btn">立即打开</view>
            </view>
                        <!-- 占位随内容滚动,避免底部提示条遮挡最后一项。 -->
                        <view v-if="isReadonlyDisplayMode" class="share-bottom-spacer"></view>
        </scroll-view>
                <!-- 固定提示条放在滚动容器外,避免 iOS 滚动层裁剪。 -->
                <view v-if="isReadonlyDisplayMode" class="open-app-banner" @tap="openApp">
                        <view class="app-logo-placeholder"></view>
                        <text class="banner-text">打开APP,了解更多</text>
                        <view class="open-btn">立即打开</view>
                </view>
        <!-- 底部悬浮按钮 -->
        <view v-if="!isReadonlyDisplayMode" class="bottom-bar">
@@ -512,7 +516,7 @@
    const scrollContentStyle = computed(() => {
        if (isReadonlyDisplayMode) {
            return {
                paddingBottom: 'calc(140rpx + env(safe-area-inset-bottom))',
                                paddingBottom: '0px',
            }
        }
        if (!isAppPlus) {
@@ -1567,6 +1571,17 @@
        display: flex;
        flex-direction: column;
        overflow: hidden;
                /* #ifdef H5 */
                /* 贴合微信可视区域;旧版浏览器使用上下边界约束高度。 */
                position: fixed;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;
                height: auto;
                min-height: 0;
                height: 100dvh;
                /* #endif */
    }
    .nav-bar {
@@ -1933,10 +1948,24 @@
        color: #999;
    }
    .open-app-banner {
  .module-section--with-padding {
          padding-left: 32rpx;
          padding-right: 32rpx;
          box-sizing: border-box;
  }
        .share-bottom-spacer {
                height: 140rpx;
                height: calc(140rpx + constant(safe-area-inset-bottom));
                height: calc(140rpx + env(safe-area-inset-bottom));
        }
        .open-app-banner {
        position: fixed;
        left: 32rpx;
        right: 32rpx;
                bottom: 32rpx;
                bottom: calc(32rpx + constant(safe-area-inset-bottom));
        bottom: calc(32rpx + env(safe-area-inset-bottom));
        height: 96rpx;
        background-color: #FFF1F0;
@@ -2141,4 +2170,8 @@
        background-color: #E5E6EB;
        color: #86909C;
    }
    .expert-avatar {
        width: 100%;
    }
</style>
traditionalApp/pages/healthCourses/healthCourses.vue
@@ -501,7 +501,7 @@
}
.tag {
    width: 60rpx;
    padding: 0 10rpx;
    height: 30rpx;
    font-size: 22rpx;
    color: #FFF0B5;
traditionalApp/utils/h5-debug.js
New file
@@ -0,0 +1,148 @@
const VCONSOLE_QUERY_KEY = 'vconsole'
const VCONSOLE_STORAGE_KEY = '__ENABLE_VCONSOLE__'
const VCONSOLE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/vconsole@3.15.1/dist/vconsole.min.js'
let vConsoleBootstrapped = false
/**
 * 仅在 H5 环境按需启用 vConsole,方便 iOS 真机排查分享页问题。
 * 开启方式:
 * 1. 链接追加 ?vconsole=1 或 &vconsole=1
 * 2. 开启后会写入 localStorage,后续刷新页面仍保持开启
 * 3. 链接追加 ?vconsole=0 可关闭
 */
export function initH5DebugPanel() {
    if (typeof window === 'undefined' || vConsoleBootstrapped) {
        return
    }
    const debugFlag = resolveDebugFlag()
    if (!debugFlag) {
        return
    }
    vConsoleBootstrapped = true
    bindGlobalDebugEvents()
    loadVConsoleScript()
}
function resolveDebugFlag() {
    const queryValue = getQueryParamValue(VCONSOLE_QUERY_KEY)
    if (queryValue === '1') {
        safeSetStorage(VCONSOLE_STORAGE_KEY, '1')
        return true
    }
    if (queryValue === '0') {
        safeRemoveStorage(VCONSOLE_STORAGE_KEY)
        return false
    }
    return safeGetStorage(VCONSOLE_STORAGE_KEY) === '1'
}
function bindGlobalDebugEvents() {
    window.addEventListener('error', (event) => {
        const error = event.error
        console.error('[h5-debug] window error:', {
            message: event.message,
            source: event.filename,
            line: event.lineno,
            column: event.colno,
            stack: error && error.stack ? error.stack : '',
        })
    })
    window.addEventListener('unhandledrejection', (event) => {
        console.error('[h5-debug] unhandled rejection:', event.reason)
    })
}
function loadVConsoleScript() {
    if (window.VConsole) {
        createVConsoleInstance()
        return
    }
    const existingScript = document.querySelector(`script[data-debug-tool="${VCONSOLE_QUERY_KEY}"]`)
    if (existingScript) {
        existingScript.addEventListener('load', createVConsoleInstance, { once: true })
        return
    }
    const script = document.createElement('script')
    script.src = VCONSOLE_SCRIPT_URL
    script.async = true
    script.setAttribute('data-debug-tool', VCONSOLE_QUERY_KEY)
    script.onload = createVConsoleInstance
    script.onerror = () => {
        console.error('[h5-debug] vConsole 加载失败:', VCONSOLE_SCRIPT_URL)
    }
    document.head.appendChild(script)
}
function createVConsoleInstance() {
    if (!window.VConsole || window.__h5VConsoleInstance) {
        return
    }
    window.__h5VConsoleInstance = new window.VConsole()
    console.log('[h5-debug] vConsole 已启用')
}
function getQueryParamValue(key) {
    const searchValue = readQueryValue(window.location.search, key)
    if (searchValue) {
        return searchValue
    }
    const hashQuery = String(window.location.hash || '').split('?')[1] || ''
    return readQueryValue(hashQuery, key)
}
function readQueryValue(queryText, key) {
    if (typeof queryText !== 'string' || !queryText) {
        return ''
    }
    const normalizedQuery = queryText.replace(/^[?#&]/, '')
    if (!normalizedQuery) {
        return ''
    }
    const pairs = normalizedQuery.split('&')
    for (const pair of pairs) {
        if (!pair) {
            continue
        }
        const splitIndex = pair.indexOf('=')
        const rawKey = splitIndex >= 0 ? pair.slice(0, splitIndex) : pair
        if (safeDecode(rawKey) !== key) {
            continue
        }
        const rawValue = splitIndex >= 0 ? pair.slice(splitIndex + 1) : ''
        return safeDecode(rawValue).trim()
    }
    return ''
}
function safeDecode(value) {
    try {
        return decodeURIComponent(String(value || '').replace(/\+/g, ' '))
    } catch (error) {
        return String(value || '')
    }
}
function safeGetStorage(key) {
    try {
        return window.localStorage.getItem(key)
    } catch (error) {
        return ''
    }
}
function safeSetStorage(key, value) {
    try {
        window.localStorage.setItem(key, value)
    } catch (error) {
        // 忽略存储异常,避免影响页面正常使用。
    }
}
function safeRemoveStorage(key) {
    try {
        window.localStorage.removeItem(key)
    } catch (error) {
        // 忽略存储异常,避免影响页面正常使用。
    }
}