refactor(全项目): 综合优化全端业务与修复各类问题
本次提交包含以下多类改动:
- 后端业务:移除手机号脱敏展示逻辑,重构直播场次自动开播与收敛规则,修复同课程多直播场次冲突问题;重构用户诊所绑定业务,优化校验流程并补充完整单元测试
- 后台管理前端:为诊所选择下拉框添加搜索功能,重构诊所管理页面代码格式与导入顺序
- uniapp移动端项目:优化健康课程页面标签样式,新增H5端vConsole调试面板,调整配置文件与路由适配H5分享场景,修复课程详情页底部布局遮挡问题
- 调整部分环境配置文件,更新安卓业务模块依赖包
| | |
| | | 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; |
| | |
| | | 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()); |
| | |
| | | * 解析同课程时间线下当前有效的直播中场次。 |
| | | */ |
| | | 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; |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 直播中场次优先按实际开播时间参与收敛,手动提前开播时不会再被计划开播时间误伤。 |
| | | */ |
| | | 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) { |
| | |
| | | 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 |
| | |
| | | .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) { |
| | |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 同一课程只挑一条最应被自动开播的场次,避免历史过期开播记录被批量拉起后又互相收敛。 |
| | | */ |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | 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())); |
| | |
| | | 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); |
| | |
| | | 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())); |
| | |
| | | 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; |
| | |
| | | /** 更新用户信息。 */ |
| | | 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) |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | @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)); |
| | | } |
| | | |
| | | /** 查询诊所选项。 */ |
| | |
| | | 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); |
| | |
| | | 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())); |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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)); |
| | | } |
| | | } |
| | |
| | | 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', |
| | |
| | | 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; |
| | |
| | | <Select |
| | | placeholder="请输入" |
| | | allowClear |
| | | showSearch |
| | | optionFilterProp="label" |
| | | options={clinics.map((item) => ({ label: item.name, value: item.id }))} |
| | | /> |
| | | </Form.Item> |
| | |
| | | <Form.Item label="绑定诊所" name="clinicId"> |
| | | <Select |
| | | allowClear |
| | | showSearch |
| | | optionFilterProp="label" |
| | | placeholder="请选择" |
| | | options={clinics.map((item) => ({ label: item.name, value: item.id }))} |
| | | /> |
| | |
| | | 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", |
| | |
| | | import App from "./App"; |
| | | // #ifdef H5 |
| | | import { initH5DebugPanel } from "@/utils/h5-debug.js"; |
| | | initH5DebugPanel(); |
| | | // #endif |
| | | |
| | | // #ifndef VUE3 |
| | | import Vue from "vue"; |
| | |
| | | Pinia, |
| | | } |
| | | } |
| | | // #endif |
| | | // #endif |
| | |
| | | { |
| | | "sassImplementationName" : "node-sass", |
| | | "name" : "海诊康道", |
| | | "appid" : "__UNI__7B5B0CA", |
| | | "appid" : "__UNI__33EC1E8", |
| | | "description" : "", |
| | | "versionName" : "1.0.0", |
| | | "versionCode" : 100, |
| | |
| | | } |
| | | }, |
| | | "h5" : { |
| | | "router" : { |
| | | "base" : "/share/" |
| | | }, |
| | | "unipush" : { |
| | | "enable" : false |
| | | } |
| | |
| | | } |
| | | } |
| | | /* 快应用特有相关 *//* 小程序特有相关 */ |
| | | |
| | |
| | | "^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" |
| | |
| | | }, |
| | | { |
| | | "path": "pages/articleDetail/articleDetail", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/courseDetail/courseDetail", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | |
| | | </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> |
| | |
| | | </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> |
| | |
| | | </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"> |
| | |
| | | const scrollContentStyle = computed(() => { |
| | | if (isReadonlyDisplayMode) { |
| | | return { |
| | | paddingBottom: 'calc(140rpx + env(safe-area-inset-bottom))', |
| | | paddingBottom: '0px', |
| | | } |
| | | } |
| | | if (!isAppPlus) { |
| | |
| | | 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 { |
| | |
| | | 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; |
| | |
| | | background-color: #E5E6EB; |
| | | color: #86909C; |
| | | } |
| | | |
| | | .expert-avatar { |
| | | width: 100%; |
| | | } |
| | | </style> |
| | |
| | | } |
| | | |
| | | .tag { |
| | | width: 60rpx; |
| | | padding: 0 10rpx; |
| | | height: 30rpx; |
| | | font-size: 22rpx; |
| | | color: #FFF0B5; |
| New file |
| | |
| | | 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) { |
| | | // 忽略存储异常,避免影响页面正常使用。 |
| | | } |
| | | } |