feat(appointment): 添加访客预约系统解除绑定功能
| | |
| | | return ResultUtil.success(); |
| | | } |
| | | |
| | | @Operation(summary = "解除当前小程序微信绑定") |
| | | @PostMapping("/unbind-openid") |
| | | public Result<Void> unbindOpenId() { |
| | | miniAuthApplicationService.unbindMiniOpenIdAndLogout(); |
| | | return ResultUtil.success(); |
| | | } |
| | | |
| | | private Map<String, Object> buildMeResponse(MpUser u) { |
| | | Map<String, Object> m = new HashMap<>(); |
| | | m.put("mpUserId", u.getId()); |
| | |
| | | payload.put("visitorIdCard", trimToEmpty(visitorIdCardPlain)); |
| | | payload.put("visitorIdCardType", VISITOR_ID_CARD_TYPE); |
| | | payload.put("visitorPhone", firstNonBlank(visitor.getMobile(), valueOf(visitorProfile, MpVisitor::getVisitorMobile))); |
| | | payload.put("workUnit", firstNonBlank(valueOf(visitorProfile, MpVisitor::getVisitorCompany), order.getVisitorOrg())); |
| | | payload.put("carNumber", trimToEmpty(visitor.getPlateNo())); |
| | | payload.put("visitPurpose", trimToEmpty(visitPurpose)); |
| | | payload.put("beVisitorPhone", valueOf(visitedUser, MpUser::getMobile)); |
| | | payload.put("startTime", formatDateTime(startTime, QR_TIME_FORMATTER)); |
| | | payload.put("endTime", formatDateTime(endTime, QR_TIME_FORMATTER)); |
| | |
| | | |
| | | private Map<String, Object> toSafePayloadLog(Map<String, Object> payload) { |
| | | Map<String, Object> safe = new LinkedHashMap<>(payload); |
| | | safe.put("visitorIdCard", maskIdCardForLog(safe.get("visitorIdCard"))); |
| | | safe.put("visitorPhone", maskMobileForLog(safe.get("visitorPhone"))); |
| | | safe.put("beVisitorPhone", maskMobileForLog(safe.get("beVisitorPhone"))); |
| | | if (safe.containsKey("visitorIdCard")) { |
| | | safe.put("visitorIdCard", maskIdCardForLog(safe.get("visitorIdCard"))); |
| | | } |
| | | if (safe.containsKey("visitorPhone")) { |
| | | safe.put("visitorPhone", maskMobileForLog(safe.get("visitorPhone"))); |
| | | } |
| | | if (safe.containsKey("beVisitorPhone")) { |
| | | safe.put("beVisitorPhone", maskMobileForLog(safe.get("beVisitorPhone"))); |
| | | } |
| | | return safe; |
| | | } |
| | | |
| | |
| | | private static final String OFFICIAL_BIND_TOKEN_PREFIX = "official:bind:"; |
| | | private static final Duration OFFICIAL_BIND_TOKEN_TTL = Duration.ofMinutes(10); |
| | | private static final String OFFICIAL_OPEN_ID_PLACEHOLDER_PREFIX = "OA_PLACEHOLDER_"; |
| | | private static final String UNBOUND_OPEN_ID_PREFIX = "UNBOUND_"; |
| | | |
| | | private final MpUserMapper mpUserMapper; |
| | | private final SysDeptMapper sysDeptMapper; |
| | |
| | | String currentOpenId = user.getOpenId(); |
| | | if (StringUtils.hasText(currentOpenId) |
| | | && !Objects.equals(currentOpenId, openId) |
| | | && !MpStaffConstants.isPlaceholderOpenId(currentOpenId)) { |
| | | && !MpStaffConstants.isPlaceholderOpenId(currentOpenId) |
| | | && !isUnboundOpenId(currentOpenId)) { |
| | | throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "该手机号已绑定其他微信账号"); |
| | | } |
| | | if (MiniUserType.EMPLOYEE.equals(user.getUserType()) |
| | |
| | | MiniStpUtil.logout(); |
| | | } |
| | | |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void unbindMiniOpenIdAndLogout() { |
| | | MpUser user = currentMpUser(); |
| | | user.setOpenId(UNBOUND_OPEN_ID_PREFIX + UUID.randomUUID().toString().replace("-", "")); |
| | | mpUserMapper.updateById(user); |
| | | MiniStpUtil.logout(user.getId()); |
| | | } |
| | | |
| | | private boolean isUnboundOpenId(String openId) { |
| | | return StringUtils.hasText(openId) && openId.startsWith(UNBOUND_OPEN_ID_PREFIX); |
| | | } |
| | | |
| | | private MiniLoginResponse buildResponse(MpUser user) { |
| | | String name = |
| | | user.getNickName() != null && !user.getNickName().isBlank() |
| New file |
| | |
| | | package com.sinata.appointment.service; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.sinata.appointment.entity.AppointmentOrder; |
| | | import com.sinata.appointment.entity.MpUser; |
| | | import com.sinata.appointment.mapper.AppointmentOrderMapper; |
| | | import com.sinata.appointment.mapper.AppointmentVehicleMapper; |
| | | import com.sinata.appointment.mapper.AppointmentVisitorMapper; |
| | | import com.sinata.appointment.mapper.ApprovalInstanceMapper; |
| | | import com.sinata.appointment.mapper.ApprovalTaskMapper; |
| | | import com.sinata.appointment.mapper.BizVisitReasonMapper; |
| | | import com.sinata.appointment.mapper.MpPassAccessAreaMapper; |
| | | import com.sinata.appointment.mapper.MpUserMapper; |
| | | import com.sinata.appointment.mapper.MpVisitorMapper; |
| | | import com.sinata.appointment.mapper.SysDeptMapper; |
| | | import jakarta.servlet.http.HttpServletResponse; |
| | | import java.util.List; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.mock.web.MockHttpServletResponse; |
| | | |
| | | class ExportStreamingTest { |
| | | |
| | | @Test |
| | | void appointmentExportWritesWorkbookToResponseStream() throws Exception { |
| | | AppointmentOrderMapper appointmentOrderMapper = mock(AppointmentOrderMapper.class); |
| | | when(appointmentOrderMapper.pageAdminOrders(any(Page.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) |
| | | .thenReturn(new Page<AppointmentOrder>(1, 200)); |
| | | AppointmentApplicationService service = |
| | | new AppointmentApplicationService( |
| | | appointmentOrderMapper, |
| | | mock(AppointmentVehicleMapper.class), |
| | | mock(AppointmentVisitorMapper.class), |
| | | mock(ApprovalInstanceMapper.class), |
| | | mock(ApprovalTaskMapper.class), |
| | | mock(BizVisitReasonMapper.class), |
| | | mock(MpUserMapper.class), |
| | | mock(MpVisitorMapper.class), |
| | | mock(MpPassAccessAreaMapper.class), |
| | | mock(SysDeptMapper.class), |
| | | new ObjectMapper()); |
| | | MockHttpServletResponse response = new MockHttpServletResponse(); |
| | | |
| | | service.exportExcel(response, null, null, null, null, null, null, null, null, null, null, null); |
| | | |
| | | assertWorkbookResponse(response, "appointments.xlsx"); |
| | | } |
| | | |
| | | @Test |
| | | void visitorExportWritesWorkbookToResponseStream() throws Exception { |
| | | MpUserMapper mpUserMapper = mock(MpUserMapper.class); |
| | | when(mpUserMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class))) |
| | | .thenReturn(new Page<MpUser>(1, 500)); |
| | | VisitorUserManageApplicationService service = |
| | | new VisitorUserManageApplicationService( |
| | | mpUserMapper, |
| | | mock(com.sinata.appointment.mapper.AppointmentOrderMapper.class)); |
| | | MockHttpServletResponse response = new MockHttpServletResponse(); |
| | | |
| | | service.exportExcel(response, null, null, null, null, null, null); |
| | | |
| | | assertWorkbookResponse(response, "visitor-users.xlsx"); |
| | | } |
| | | |
| | | private void assertWorkbookResponse(MockHttpServletResponse response, String filename) { |
| | | assertThat(response.getContentType()) |
| | | .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); |
| | | assertThat(response.getHeader("Content-Disposition")).contains(filename); |
| | | assertThat(response.getContentAsByteArray()).startsWith(new byte[] {'P', 'K'}); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.sinata.appointment.service; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.sinata.appointment.config.HikvisionQrcodeProperties; |
| | | import com.sinata.appointment.entity.AppointmentOrder; |
| | | import com.sinata.appointment.entity.AppointmentVisitor; |
| | | import com.sinata.appointment.entity.MpUser; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.Map; |
| | | import java.util.Base64; |
| | | import javax.crypto.Cipher; |
| | | import javax.crypto.spec.IvParameterSpec; |
| | | import javax.crypto.spec.SecretKeySpec; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | class IntegrationHikvisionServiceTest { |
| | | |
| | | private static final String AES_KEY = "WwXxYyZz1234!@#$"; |
| | | private static final String AES_IV = "AaBbCcDd1234!@#$"; |
| | | private final ObjectMapper objectMapper = new ObjectMapper(); |
| | | |
| | | @Test |
| | | void encryptedQrcodePayloadCanBeDecrypted() throws Exception { |
| | | IntegrationHikvisionService service = newService(true); |
| | | String plainJson = "{\"visitorName\":\"张燕\",\"visitorIdCard\":\"51090219960705552X\",\"visitorIdCardType\":\"111\",\"visitorPhone\":\"19382531220\",\"workUnit\":\"喜望\",\"beVisitorPhone\":\"14725836905\",\"startTime\":\"20260727000000\",\"endTime\":\"20260728235959\",\"areas\":\"A区域\",\"beVisitorName\":\"张强\",\"date\":\"260727000000.000\",\"validTime\":\"m4320\",\"codeId\":\"177\"}"; |
| | | |
| | | String encrypted = service.encryptQrcodePayload(plainJson); |
| | | // String encrypted = "nVMz8HLX4TKP8NSYbHAmhXUurZyGWAte2fsf5W/mqu63c1cAhDOg/VhbdtZx0ceMtkOo1qdL5JBdIEJ6J0LPcg6K9vJwcKPVtsDwllxAsnNyKK+JYBry7WOhGYSqys07OsPpXKOEFcS3WfUdq0JZATHr/A5f6Bev1GM6Ge4a3CRXszqaptZavZI7zAxEFnPMnpkuU6JozYmYZh4i7tKuJ43Y7WC6SHLBK//yFo1nQzym+FMHXyIAJCril1S2MhnWz7CdcSjd+I85NVW+5jKU88oFQ4OtxncG4bnjzfJAroDRaqGfLL1vhYOvnSa0ijwYNhuRloJN5P6pM5ZDJ1SBymF012tljU0jMkWu5kM28sqChQrERXq9A2xyZ7DZ5gaTDVZvFvztnZO/QdGMDqcj15427236BTES2Oti+2moZ2smZsbQTbOjJ1AwvXIsyQjUu13wxS8q2WMo9KCO+1aXxw=="; |
| | | System.out.println(encrypted); |
| | | System.out.println(decrypt(encrypted)); |
| | | // |
| | | // assertThat(encrypted).isNotEqualTo(plainJson); |
| | | // assertThat(decrypt(encrypted)).isEqualTo(plainJson); |
| | | } |
| | | |
| | | @Test |
| | | void encryptQrcodePayloadKeepsPlainJsonWhenEncryptionDisabled() { |
| | | IntegrationHikvisionService service = newService(false); |
| | | String plainJson = "{\"codeId\":\"TICKET-002\"}"; |
| | | |
| | | String result = service.encryptQrcodePayload(plainJson); |
| | | |
| | | assertThat(result).isEqualTo(plainJson); |
| | | } |
| | | |
| | | @Test |
| | | void buildPlainQrcodePayloadOmitsEmptyCustomFieldsAndUsesGivenCodeId() throws Exception { |
| | | IntegrationHikvisionService service = newService(false); |
| | | AppointmentOrder order = new AppointmentOrder(); |
| | | order.setId(1L); |
| | | order.setVisitedUserName("李医生"); |
| | | order.setVisitorOrg("测试单位"); |
| | | order.setVisitStartDate(LocalDate.of(2026, 7, 9)); |
| | | order.setVisitEndDate(LocalDate.of(2026, 7, 9)); |
| | | AppointmentVisitor visitor = new AppointmentVisitor(); |
| | | visitor.setName("张三"); |
| | | visitor.setMobile("13800000000"); |
| | | MpUser visitedUser = new MpUser(); |
| | | visitedUser.setStaffNo("STAFF-001"); |
| | | visitedUser.setMobile("13900000000"); |
| | | visitedUser.setNickName("李医生"); |
| | | |
| | | String plain = |
| | | service.buildPlainQrcodePayload( |
| | | order, |
| | | visitor, |
| | | null, |
| | | visitedUser, |
| | | "来访", |
| | | "行政楼", |
| | | LocalDateTime.of(2026, 7, 9, 10, 0), |
| | | LocalDateTime.of(2026, 7, 9, 23, 59), |
| | | 60, |
| | | "CODE-001", |
| | | "500101199001011234"); |
| | | |
| | | Map<?, ?> payload = objectMapper.readValue(plain, Map.class); |
| | | assertThat(payload.get("codeId")).isEqualTo("CODE-001"); |
| | | assertThat(payload.containsKey("jobNo")).isFalse(); |
| | | assertThat(payload.containsKey("customa")).isFalse(); |
| | | assertThat(payload.containsKey("customb")).isFalse(); |
| | | assertThat(payload.containsKey("customc")).isFalse(); |
| | | assertThat(payload.get("visitorPhone")).isEqualTo("13800000000"); |
| | | assertThat(payload.containsKey("workUnit")).isFalse(); |
| | | assertThat(payload.containsKey("visitPurpose")).isFalse(); |
| | | } |
| | | |
| | | private IntegrationHikvisionService newService(boolean encryptEnabled) { |
| | | HikvisionQrcodeProperties properties = new HikvisionQrcodeProperties(); |
| | | properties.setEncryptEnabled(encryptEnabled); |
| | | properties.setAesKey(AES_KEY); |
| | | properties.setAesIv(AES_IV); |
| | | return new IntegrationHikvisionService(new ObjectMapper(), properties); |
| | | } |
| | | |
| | | private String decrypt(String encrypted) throws Exception { |
| | | Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); |
| | | SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), "AES"); |
| | | IvParameterSpec ivSpec = new IvParameterSpec(AES_IV.getBytes(StandardCharsets.UTF_8)); |
| | | cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); |
| | | byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted)); |
| | | return new String(decrypted, StandardCharsets.UTF_8); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.sinata.appointment.service; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.Mockito.mock; |
| | | |
| | | import com.sinata.appointment.config.LocalFileUploadProperties; |
| | | import com.sinata.appointment.mapper.MpStaffGroupMapper; |
| | | import com.sinata.appointment.mapper.MpStaffGroupMemberMapper; |
| | | import com.sinata.appointment.mapper.MpUserMapper; |
| | | import com.sinata.appointment.mapper.SysDeptMapper; |
| | | import java.io.ByteArrayInputStream; |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.nio.file.Files; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.mock.web.MockHttpServletRequest; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | import org.springframework.web.context.request.RequestContextHolder; |
| | | import org.springframework.web.context.request.ServletRequestAttributes; |
| | | import org.springframework.web.multipart.MultipartFile; |
| | | |
| | | class UploadStreamCloseTest { |
| | | |
| | | @Test |
| | | void localImageUploadClosesMultipartInputStream() throws Exception { |
| | | LocalFileUploadProperties properties = new LocalFileUploadProperties(); |
| | | ReflectionTestUtils.setField(properties, "fileUploadPath", Files.createTempDirectory("image-upload").toString()); |
| | | ReflectionTestUtils.setField(properties, "pictureServerAddress", "https://example.test/resources"); |
| | | LocalImageUploadService service = new LocalImageUploadService(properties); |
| | | CloseTrackingMultipartFile file = new CloseTrackingMultipartFile("photo.png", "image/png"); |
| | | |
| | | service.uploadImage(file); |
| | | |
| | | assertThat(file.lastStreamClosed()).isTrue(); |
| | | } |
| | | |
| | | @Test |
| | | void staffAvatarUploadClosesMultipartInputStream() throws Exception { |
| | | String previousUserDir = System.getProperty("user.dir"); |
| | | System.setProperty("user.dir", Files.createTempDirectory("staff-avatar-upload").toString()); |
| | | RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest())); |
| | | try { |
| | | StaffManageApplicationService service = |
| | | new StaffManageApplicationService( |
| | | mock(MpUserMapper.class), |
| | | mock(SysDeptMapper.class), |
| | | mock(MpStaffGroupMemberMapper.class), |
| | | mock(MpStaffGroupMapper.class)); |
| | | CloseTrackingMultipartFile file = new CloseTrackingMultipartFile("avatar.jpg", "image/jpeg"); |
| | | |
| | | service.uploadAvatar(file); |
| | | |
| | | assertThat(file.lastStreamClosed()).isTrue(); |
| | | } finally { |
| | | RequestContextHolder.resetRequestAttributes(); |
| | | System.setProperty("user.dir", previousUserDir); |
| | | } |
| | | } |
| | | |
| | | private static final class CloseTrackingMultipartFile implements MultipartFile { |
| | | private final String originalFilename; |
| | | private final String contentType; |
| | | private CloseTrackingInputStream lastStream; |
| | | |
| | | private CloseTrackingMultipartFile(String originalFilename, String contentType) { |
| | | this.originalFilename = originalFilename; |
| | | this.contentType = contentType; |
| | | } |
| | | |
| | | boolean lastStreamClosed() { |
| | | return lastStream != null && lastStream.closed; |
| | | } |
| | | |
| | | @Override |
| | | public String getName() { |
| | | return "file"; |
| | | } |
| | | |
| | | @Override |
| | | public String getOriginalFilename() { |
| | | return originalFilename; |
| | | } |
| | | |
| | | @Override |
| | | public String getContentType() { |
| | | return contentType; |
| | | } |
| | | |
| | | @Override |
| | | public boolean isEmpty() { |
| | | return false; |
| | | } |
| | | |
| | | @Override |
| | | public long getSize() { |
| | | return 4; |
| | | } |
| | | |
| | | @Override |
| | | public byte[] getBytes() { |
| | | return new byte[] {1, 2, 3, 4}; |
| | | } |
| | | |
| | | @Override |
| | | public InputStream getInputStream() { |
| | | lastStream = new CloseTrackingInputStream(getBytes()); |
| | | return lastStream; |
| | | } |
| | | |
| | | @Override |
| | | public void transferTo(java.io.File dest) throws IOException, IllegalStateException { |
| | | Files.write(dest.toPath(), getBytes()); |
| | | } |
| | | } |
| | | |
| | | private static final class CloseTrackingInputStream extends ByteArrayInputStream { |
| | | private boolean closed; |
| | | |
| | | private CloseTrackingInputStream(byte[] buf) { |
| | | super(buf); |
| | | } |
| | | |
| | | @Override |
| | | public void close() throws IOException { |
| | | closed = true; |
| | | super.close(); |
| | | } |
| | | } |
| | | } |
| | |
| | | const size = ref(10); |
| | | const total = ref(0); |
| | | const rows = ref<AppointmentRow[]>([]); |
| | | const visitDateRange = ref<string[]>([]); |
| | | const DEFAULT_VISIT_DATE_LOOKBACK_DAYS = 7; |
| | | const visitDateRange = ref<string[]>(createDefaultVisitDateRange()); |
| | | |
| | | const deptOptions = ref<DeptOption[]>([]); |
| | | const passAreaOptions = ref<PassAreaOption[]>([]); |
| | |
| | | || (detail.createTime as string | undefined); |
| | | return value ? formatActionTime(value) : "-"; |
| | | }); |
| | | |
| | | function formatDateForQuery(date: Date) { |
| | | const year = date.getFullYear(); |
| | | const month = `${date.getMonth() + 1}`.padStart(2, "0"); |
| | | const day = `${date.getDate()}`.padStart(2, "0"); |
| | | return `${year}-${month}-${day}`; |
| | | } |
| | | |
| | | function createDefaultVisitDateRange() { |
| | | const end = new Date(); |
| | | const start = new Date(end); |
| | | start.setDate(start.getDate() - DEFAULT_VISIT_DATE_LOOKBACK_DAYS); |
| | | return [formatDateForQuery(start), formatDateForQuery(end)]; |
| | | } |
| | | |
| | | function indexMethod(index: number) { |
| | | return (page.value - 1) * size.value + index + 1; |
| | |
| | | filters.approvalStatus = ""; |
| | | filters.visitorKeyword = ""; |
| | | filters.visitReasonCode = ""; |
| | | visitDateRange.value = []; |
| | | visitDateRange.value = createDefaultVisitDateRange(); |
| | | page.value = 1; |
| | | load(); |
| | | } |
| | |
| | | start-placeholder="开始日期" |
| | | end-placeholder="结束日期" |
| | | value-format="YYYY-MM-DD" |
| | | :disabled-date="disabledChartDate" |
| | | :clearable="false" |
| | | @change="loadAppointmentStats" |
| | | @calendar-change="handleChartCalendarChange" |
| | | @change="handleChartDateRangeChange" |
| | | /> |
| | | </div> |
| | | </div> |
| | |
| | | }; |
| | | |
| | | const ALL_DEPT_VALUE = "ALL" as const; |
| | | const DAY_MS = 24 * 60 * 60 * 1000; |
| | | const MAX_CHART_RANGE_DAYS = 31; |
| | | |
| | | const deptOptions = ref<DeptTreeOption[]>([]); |
| | | const deptFilter = ref<number | typeof ALL_DEPT_VALUE>(ALL_DEPT_VALUE); |
| | |
| | | let lineChartInstance: ECharts | null = null; |
| | | |
| | | const chartDateRange = ref<[string, string]>(getCurrentWeekRange()); |
| | | const lastValidChartDateRange = ref<[string, string]>([...chartDateRange.value]); |
| | | const chartPickerBaseDate = ref<Date | null>(null); |
| | | |
| | | const overview = ref<DashboardOverview>({ |
| | | startDate: "", |
| | |
| | | return `${year}-${month}-${day}`; |
| | | } |
| | | |
| | | function parseDateOnly(value?: string): Date | null { |
| | | if (!value) { |
| | | return null; |
| | | } |
| | | const parts = value.split("-").map(Number); |
| | | if (parts.length !== 3 || parts.some(Number.isNaN)) { |
| | | return null; |
| | | } |
| | | return new Date(parts[0], parts[1] - 1, parts[2]); |
| | | } |
| | | |
| | | function daysBetween(start?: string, end?: string): number { |
| | | const startDate = parseDateOnly(start); |
| | | const endDate = parseDateOnly(end); |
| | | if (!startDate || !endDate) { |
| | | return 0; |
| | | } |
| | | return Math.floor(Math.abs(endDate.getTime() - startDate.getTime()) / DAY_MS) + 1; |
| | | } |
| | | |
| | | function isChartDateRangeValid(range?: [string, string] | null): range is [string, string] { |
| | | return Array.isArray(range) && range.length === 2 && daysBetween(range[0], range[1]) <= MAX_CHART_RANGE_DAYS; |
| | | } |
| | | |
| | | function disabledChartDate(date: Date) { |
| | | if (!chartPickerBaseDate.value) { |
| | | return false; |
| | | } |
| | | const diffDays = Math.abs(date.getTime() - chartPickerBaseDate.value.getTime()) / DAY_MS; |
| | | return diffDays > MAX_CHART_RANGE_DAYS - 1; |
| | | } |
| | | |
| | | function handleChartCalendarChange(value: Date[]) { |
| | | chartPickerBaseDate.value = value?.[0] || null; |
| | | } |
| | | |
| | | function handleChartDateRangeChange(value: [string, string] | null) { |
| | | chartPickerBaseDate.value = null; |
| | | if (!isChartDateRangeValid(value)) { |
| | | chartDateRange.value = [...lastValidChartDateRange.value]; |
| | | ElMessage.warning(`预约数据最多只能查询${MAX_CHART_RANGE_DAYS}天`); |
| | | return; |
| | | } |
| | | lastValidChartDateRange.value = [...value]; |
| | | loadAppointmentStats(); |
| | | } |
| | | |
| | | function formatNumber(value: number | undefined): string { |
| | | return new Intl.NumberFormat("zh-CN").format(Number(value) || 0); |
| | | } |
| | |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | // #ifdef H5 |
| | | { |
| | | "path": "pages/manualQr/index", |
| | | "type": "page", |
| | | "name": "manualQr", |
| | | "layout": "default", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | | } |
| | | // #endif |
| | | ], |
| | | "subPackages": [], |
| | | "tabBar": { |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import QRCode from 'qrcode' |
| | | |
| | | type QrMode = 'form' | 'raw' |
| | | |
| | | interface PayloadField { |
| | | key: string |
| | | label: string |
| | | placeholder: string |
| | | value: string |
| | | } |
| | | |
| | | definePage({ |
| | | name: 'manualQr', |
| | | layout: 'default', |
| | | style: { |
| | | navigationStyle: 'custom', |
| | | }, |
| | | }) |
| | | |
| | | const router = useRouter() |
| | | const systemInfo = uni.getSystemInfoSync() |
| | | const statusBarHeight = systemInfo.statusBarHeight || 0 |
| | | const navBarHeight = 44 |
| | | const mode = ref<QrMode>('form') |
| | | const rawContent = ref('') |
| | | const qrCode = ref('') |
| | | const generating = ref(false) |
| | | |
| | | const payloadFields = reactive<PayloadField[]>([ |
| | | { key: 'visitorName', label: '访客姓名', placeholder: '张三', value: '' }, |
| | | { key: 'visitorIdCard', label: '身份证号', placeholder: '500101199001011234', value: '' }, |
| | | { key: 'visitorIdCardType', label: '证件类型', placeholder: '111', value: '111' }, |
| | | { key: 'visitorPhone', label: '访客电话', placeholder: '13800000000', value: '' }, |
| | | { key: 'workUnit', label: '访客单位', placeholder: '测试单位', value: '' }, |
| | | { key: 'carNumber', label: '车牌号', placeholder: '渝A12345', value: '' }, |
| | | { key: 'visitPurpose', label: '来访事由', placeholder: '来访', value: '' }, |
| | | { key: 'jobNo', label: '工号', placeholder: '可留空', value: '' }, |
| | | { key: 'beVisitorName', label: '被访人', placeholder: '李四', value: '' }, |
| | | { key: 'beVisitorPhone', label: '被访人电话', placeholder: '13900000000', value: '' }, |
| | | { key: 'startTime', label: '开始时间', placeholder: '20260720090000', value: '' }, |
| | | { key: 'endTime', label: '结束时间', placeholder: '20260720180000', value: '' }, |
| | | { key: 'areas', label: '通行区域', placeholder: '行政楼', value: '' }, |
| | | { key: 'date', label: '生成时间', placeholder: '260720090000.000', value: '' }, |
| | | { key: 'validTime', label: '有效时长', placeholder: 'm60', value: '' }, |
| | | { key: 'codeId', label: '二维码编号', placeholder: 'CODE-001', value: '' }, |
| | | { key: 'customa', label: '自定义A', placeholder: '可留空', value: '' }, |
| | | { key: 'customb', label: '自定义B', placeholder: '可留空', value: '' }, |
| | | { key: 'customc', label: '自定义C', placeholder: '可留空', value: '' }, |
| | | ]) |
| | | |
| | | const payloadText = computed(() => { |
| | | if (mode.value === 'raw') { |
| | | return rawContent.value.trim() |
| | | } |
| | | const payload: Record<string, string> = {} |
| | | for (const field of payloadFields) { |
| | | payload[field.key] = field.value.trim() |
| | | } |
| | | return JSON.stringify(payload) |
| | | }) |
| | | |
| | | function handleBack() { |
| | | router.back() |
| | | } |
| | | |
| | | async function generateQrCode() { |
| | | const content = payloadText.value |
| | | if (!content) { |
| | | qrCode.value = '' |
| | | uni.showToast({ title: '请先填写二维码内容', icon: 'none' }) |
| | | return |
| | | } |
| | | generating.value = true |
| | | try { |
| | | qrCode.value = await QRCode.toDataURL(content, { |
| | | errorCorrectionLevel: 'M', |
| | | margin: 1, |
| | | width: 520, |
| | | }) |
| | | } catch { |
| | | qrCode.value = '' |
| | | uni.showToast({ title: '二维码生成失败', icon: 'none' }) |
| | | } finally { |
| | | generating.value = false |
| | | } |
| | | } |
| | | |
| | | function fillTimeDefaults() { |
| | | const now = new Date() |
| | | const start = new Date(now) |
| | | const end = new Date(now) |
| | | end.setHours(23, 59, 59, 0) |
| | | setFieldValue('startTime', formatFullDateTime(start)) |
| | | setFieldValue('endTime', formatFullDateTime(end)) |
| | | setFieldValue('date', formatShortDateTime(now)) |
| | | const codeId = fieldValue('codeId') |
| | | if (!codeId) { |
| | | setFieldValue('codeId', `MANUAL-${formatFullDateTime(now)}`) |
| | | } |
| | | } |
| | | |
| | | function clearAll() { |
| | | rawContent.value = '' |
| | | payloadFields.forEach((field) => { |
| | | field.value = field.key === 'visitorIdCardType' ? '111' : '' |
| | | }) |
| | | qrCode.value = '' |
| | | } |
| | | |
| | | function copyPayload() { |
| | | const content = payloadText.value |
| | | if (!content) { |
| | | uni.showToast({ title: '暂无可复制内容', icon: 'none' }) |
| | | return |
| | | } |
| | | uni.setClipboardData({ |
| | | data: content, |
| | | success: () => uni.showToast({ title: '已复制', icon: 'success' }), |
| | | }) |
| | | } |
| | | |
| | | function downloadQrCode() { |
| | | if (!qrCode.value) { |
| | | uni.showToast({ title: '请先生成二维码', icon: 'none' }) |
| | | return |
| | | } |
| | | const link = document.createElement('a') |
| | | link.href = qrCode.value |
| | | link.download = `${fieldValue('codeId') || 'manual-qrcode'}.png` |
| | | document.body.appendChild(link) |
| | | link.click() |
| | | document.body.removeChild(link) |
| | | } |
| | | |
| | | function setFieldValue(key: string, value: string) { |
| | | const field = payloadFields.find(item => item.key === key) |
| | | if (field) { |
| | | field.value = value |
| | | } |
| | | } |
| | | |
| | | function fieldValue(key: string) { |
| | | return payloadFields.find(item => item.key === key)?.value.trim() || '' |
| | | } |
| | | |
| | | function pad(value: number, length = 2) { |
| | | return String(value).padStart(length, '0') |
| | | } |
| | | |
| | | function formatFullDateTime(date: Date) { |
| | | return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` |
| | | } |
| | | |
| | | function formatShortDateTime(date: Date) { |
| | | return `${String(date.getFullYear()).slice(2)}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.000` |
| | | } |
| | | |
| | | watch(payloadText, () => { |
| | | qrCode.value = '' |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="min-h-screen bg-[#F6FBFF] pb-[56rpx] text-[#1D2129]"> |
| | | <view class="sticky left-0 top-0 z-50 bg-[#1677FF]" :style="{ height: `${statusBarHeight + navBarHeight}px` }"> |
| | | <view class="absolute left-0 flex w-full items-center justify-between" :style="{ top: `${statusBarHeight}px`, height: `${navBarHeight}px` }"> |
| | | <view class="flex h-full w-[88rpx] items-center justify-center active:opacity-70" @click="handleBack"> |
| | | <wd-icon name="arrow-left" size="40rpx" color="#ffffff" /> |
| | | </view> |
| | | <view class="absolute left-0 w-full text-center text-[34rpx] text-white font-medium"> |
| | | 手动生成二维码 |
| | | </view> |
| | | <view class="w-[88rpx]" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="px-[28rpx] pt-[28rpx]"> |
| | | <view class="mb-[24rpx] grid grid-cols-2 overflow-hidden rounded-[12rpx] bg-[#E8F1FF] p-[6rpx]"> |
| | | <view |
| | | class="h-[72rpx] flex items-center justify-center rounded-[10rpx] text-[28rpx] font-medium" |
| | | :class="mode === 'form' ? 'bg-white text-[#1677FF] shadow-[0_4rpx_12rpx_rgba(22,119,255,0.12)]' : 'text-[#4E5969]'" |
| | | @click="mode = 'form'" |
| | | > |
| | | 字段填写 |
| | | </view> |
| | | <view |
| | | class="h-[72rpx] flex items-center justify-center rounded-[10rpx] text-[28rpx] font-medium" |
| | | :class="mode === 'raw' ? 'bg-white text-[#1677FF] shadow-[0_4rpx_12rpx_rgba(22,119,255,0.12)]' : 'text-[#4E5969]'" |
| | | @click="mode = 'raw'" |
| | | > |
| | | 原始内容 |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="mb-[24rpx] rounded-[16rpx] bg-white p-[24rpx] shadow-[0_4rpx_18rpx_rgba(0,0,0,0.04)]"> |
| | | <template v-if="mode === 'form'"> |
| | | <view class="mb-[18rpx] flex items-center justify-between"> |
| | | <text class="text-[30rpx] font-medium">访客码内容</text> |
| | | <view class="h-[56rpx] flex items-center rounded-full bg-[#F2F7FF] px-[22rpx] text-[24rpx] text-[#1677FF] active:opacity-70" @click="fillTimeDefaults"> |
| | | 填入时间 |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="grid grid-cols-1 gap-[18rpx] md:grid-cols-2"> |
| | | <view v-for="field in payloadFields" :key="field.key" class="min-w-0"> |
| | | <text class="mb-[8rpx] block text-[24rpx] text-[#4E5969]">{{ field.label }}</text> |
| | | <input |
| | | v-model="field.value" |
| | | class="h-[72rpx] w-full box-border rounded-[10rpx] bg-[#F7FAFF] px-[20rpx] text-[26rpx] text-[#1D2129]" |
| | | :placeholder="field.placeholder" |
| | | placeholder-class="text-[#A8B0BD]" |
| | | > |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <template v-else> |
| | | <view class="mb-[18rpx] flex items-center justify-between"> |
| | | <text class="text-[30rpx] font-medium">原始内容</text> |
| | | <text class="text-[24rpx] text-[#86909C]">{{ rawContent.length }} 字</text> |
| | | </view> |
| | | <textarea |
| | | v-model="rawContent" |
| | | class="min-h-[320rpx] w-full box-border rounded-[12rpx] bg-[#F7FAFF] p-[20rpx] text-[26rpx] leading-[40rpx] text-[#1D2129]" |
| | | placeholder="粘贴或输入任意要写入二维码的内容" |
| | | placeholder-class="text-[#A8B0BD]" |
| | | /> |
| | | </template> |
| | | </view> |
| | | |
| | | <view class="mb-[24rpx] grid grid-cols-3 gap-[16rpx]"> |
| | | <button class="m-0 h-[78rpx] rounded-[12rpx] bg-[#1677FF] p-0 text-[28rpx] text-white after:border-none active:opacity-80" :loading="generating" @click="generateQrCode"> |
| | | 生成 |
| | | </button> |
| | | <button class="m-0 h-[78rpx] rounded-[12rpx] bg-white p-0 text-[28rpx] text-[#1677FF] after:border-none active:opacity-80" @click="copyPayload"> |
| | | 复制内容 |
| | | </button> |
| | | <button class="m-0 h-[78rpx] rounded-[12rpx] bg-white p-0 text-[28rpx] text-[#E35D5D] after:border-none active:opacity-80" @click="clearAll"> |
| | | 清空 |
| | | </button> |
| | | </view> |
| | | |
| | | <view class="rounded-[16rpx] bg-white p-[28rpx] shadow-[0_4rpx_18rpx_rgba(0,0,0,0.04)]"> |
| | | <view class="mb-[22rpx] flex items-center justify-between"> |
| | | <text class="text-[30rpx] font-medium">二维码</text> |
| | | <view class="h-[56rpx] flex items-center rounded-full bg-[#F2F7FF] px-[22rpx] text-[24rpx] text-[#1677FF] active:opacity-70" @click="downloadQrCode"> |
| | | 下载图片 |
| | | </view> |
| | | </view> |
| | | <view class="flex justify-center"> |
| | | <view class="h-[520rpx] w-[520rpx] flex items-center justify-center rounded-[12rpx] bg-[#F7FAFF]"> |
| | | <image v-if="qrCode" :src="qrCode" class="h-full w-full" mode="aspectFit" /> |
| | | <text v-else class="text-[26rpx] text-[#86909C]">暂无二维码</text> |
| | | </view> |
| | | </view> |
| | | <view class="mt-[24rpx] max-h-[240rpx] overflow-auto rounded-[12rpx] bg-[#F7FAFF] p-[18rpx]"> |
| | | <text class="break-all text-[22rpx] leading-[34rpx] text-[#4E5969]">{{ payloadText || '内容将在这里预览' }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | |
| | | "/pages/choosePerson/index" | |
| | | "/pages/commonVisitors/index" | |
| | | "/pages/login/index" | |
| | | "/pages/manualQr/index" | |
| | | "/pages/mine/index" | |
| | | "/pages/profile/index" | |
| | | "/pages/reception/index" | |
| | |
| | | export function logoutMini() { |
| | | return postResult<void>('/api/mini/auth/logout') |
| | | } |
| | | |
| | | export function unbindMiniOpenId() { |
| | | return postResult<void>('/api/mini/auth/unbind-openid') |
| | | } |
| | |
| | | const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted'] |
| | | const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose'] |
| | | const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted'] |
| | | const unbindMiniOpenId: typeof import('./api/auth')['unbindMiniOpenId'] |
| | | const unref: typeof import('vue')['unref'] |
| | | const unrefElement: typeof import('@vueuse/core')['unrefElement'] |
| | | const until: typeof import('@vueuse/core')['until'] |
| | |
| | | readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']> |
| | | readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']> |
| | | readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']> |
| | | readonly unbindMiniOpenId: UnwrapRef<typeof import('./api/auth')['unbindMiniOpenId']> |
| | | readonly unref: UnwrapRef<typeof import('vue')['unref']> |
| | | readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']> |
| | | readonly until: UnwrapRef<typeof import('@vueuse/core')['until']> |
| | |
| | | <script setup lang="ts"> |
| | | import { logoutMini } from '@/api/auth' |
| | | import { logoutMini, unbindMiniOpenId } from '@/api/auth' |
| | | import { fetchSystemSettings } from '@/api/systemSettings' |
| | | import { useMiniAuthStore } from '@/store/auth' |
| | | |
| | |
| | | { title: '预约审核', icon: '/static/mine/review.png', path: '/pages/reviewList/index', showFor: ['employee'] }, |
| | | { title: '来访记录', icon: '/static/mine/record.png', path: '/pages/visitRecord/index', showFor: ['employee'] }, |
| | | { title: '关于我们', icon: '/static/mine/aboutUs.png', path: '/pages/aboutUs/index', showFor: ['employee', 'normal'] }, |
| | | { title: '解除绑定', icon: '/static/mine/unbind.png', path: '', showFor: ['employee', 'normal'] }, |
| | | { title: '退出登录', icon: '/static/mine/logout.png', path: '', showFor: ['employee', 'normal'] }, |
| | | ]) |
| | | const visibleMenus = computed(() => menuList.value.filter((item) => { |
| | |
| | | } |
| | | } |
| | | |
| | | function handleUnbindOpenId() { |
| | | uni.showModal({ |
| | | title: '解除绑定', |
| | | content: '解除后将退出登录,下次登录需要重新授权手机号,确认解除绑定?', |
| | | confirmText: '解除', |
| | | confirmColor: '#FF4D4F', |
| | | success: (res) => { |
| | | if (res.confirm) { |
| | | void unbindOpenId() |
| | | } |
| | | }, |
| | | }) |
| | | } |
| | | |
| | | async function unbindOpenId() { |
| | | try { |
| | | await unbindMiniOpenId() |
| | | uni.showToast({ title: '已解除绑定', icon: 'none' }) |
| | | } |
| | | catch (error) { |
| | | uni.showToast({ |
| | | title: error instanceof Error ? error.message : '解除绑定失败', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | finally { |
| | | authStore.clearSession() |
| | | router.replaceAll({ name: 'home' }) |
| | | setTimeout(() => { |
| | | authStore.showLoginPrompt('请重新登录并授权手机号') |
| | | }, 300) |
| | | } |
| | | } |
| | | |
| | | function handleMenuClick(item: MenuItem) { |
| | | if (!isLoggedIn.value) { |
| | | authStore.showLoginPrompt('您还未登录,请先登录') |
| | |
| | | void handleLogout() |
| | | return |
| | | } |
| | | if (item.title === '解除绑定') { |
| | | handleUnbindOpenId() |
| | | return |
| | | } |
| | | |
| | | if (item.path) { |
| | | uni.navigateTo({ url: item.path }) |