From 2a3c3cdbf2398695d61633e072f98ee2757e9e27 Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期二, 11 八月 2026 11:20:26 +0800
Subject: [PATCH] feat(appointment): 添加访客预约系统解除绑定功能

---
 yuyueMiniProgram/src/api/auth.ts                                                          |    4 
 frontend-h5/src/pages.json                                                                |   11 
 frontend-h5/src/uni-pages.d.ts                                                            |    1 
 backend/src/test/java/com/sinata/appointment/service/UploadStreamCloseTest.java           |  129 +++++++++
 backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java           |    7 
 yuyueMiniProgram/src/static/mine/unbind.png                                               |    0 
 frontend-admin/src/views/DashboardView.vue                                                |   55 ++++
 frontend-h5/src/pages/manualQr/index.vue                                                  |  263 ++++++++++++++++++++
 frontend-admin/src/views/AppointmentListView.vue                                          |   19 +
 backend/src/test/java/com/sinata/appointment/service/ExportStreamingTest.java             |   77 +++++
 yuyueMiniProgram/src/auto-imports.d.ts                                                    |    2 
 backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java     |   14 
 backend/src/test/java/com/sinata/appointment/service/IntegrationHikvisionServiceTest.java |  108 ++++++++
 backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java      |   16 +
 yuyueMiniProgram/src/pages/mine/index.vue                                                 |   41 +++
 15 files changed, 737 insertions(+), 10 deletions(-)

diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
index ac80551..ec57064 100644
--- a/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
@@ -105,6 +105,13 @@
         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());
diff --git a/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java b/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java
index 2f6cade..379fed5 100644
--- a/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java
@@ -92,9 +92,7 @@
         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));
@@ -154,9 +152,15 @@
 
     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;
     }
 
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
index 905f21e..64c9a4d 100644
--- a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
@@ -37,6 +37,7 @@
     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;
@@ -263,7 +264,8 @@
         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())
@@ -437,6 +439,18 @@
         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()
diff --git a/backend/src/test/java/com/sinata/appointment/service/ExportStreamingTest.java b/backend/src/test/java/com/sinata/appointment/service/ExportStreamingTest.java
new file mode 100644
index 0000000..b62c21f
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/ExportStreamingTest.java
@@ -0,0 +1,77 @@
+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'});
+    }
+}
diff --git a/backend/src/test/java/com/sinata/appointment/service/IntegrationHikvisionServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/IntegrationHikvisionServiceTest.java
new file mode 100644
index 0000000..bc6dca4
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/IntegrationHikvisionServiceTest.java
@@ -0,0 +1,108 @@
+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);
+    }
+}
diff --git a/backend/src/test/java/com/sinata/appointment/service/UploadStreamCloseTest.java b/backend/src/test/java/com/sinata/appointment/service/UploadStreamCloseTest.java
new file mode 100644
index 0000000..4bfde53
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/UploadStreamCloseTest.java
@@ -0,0 +1,129 @@
+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();
+        }
+    }
+}
diff --git a/frontend-admin/src/views/AppointmentListView.vue b/frontend-admin/src/views/AppointmentListView.vue
index 20e7078..a084345 100644
--- a/frontend-admin/src/views/AppointmentListView.vue
+++ b/frontend-admin/src/views/AppointmentListView.vue
@@ -344,7 +344,8 @@
 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[]>([]);
@@ -396,6 +397,20 @@
     || (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;
@@ -507,7 +522,7 @@
   filters.approvalStatus = "";
   filters.visitorKeyword = "";
   filters.visitReasonCode = "";
-  visitDateRange.value = [];
+  visitDateRange.value = createDefaultVisitDateRange();
   page.value = 1;
   load();
 }
diff --git a/frontend-admin/src/views/DashboardView.vue b/frontend-admin/src/views/DashboardView.vue
index c9e42ab..1155b86 100644
--- a/frontend-admin/src/views/DashboardView.vue
+++ b/frontend-admin/src/views/DashboardView.vue
@@ -44,8 +44,10 @@
               start-placeholder="开始日期"
               end-placeholder="结束日期"
               value-format="YYYY-MM-DD"
+              :disabled-date="disabledChartDate"
               :clearable="false"
-              @change="loadAppointmentStats"
+              @calendar-change="handleChartCalendarChange"
+              @change="handleChartDateRangeChange"
             />
           </div>
         </div>
@@ -158,6 +160,8 @@
 };
 
 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);
@@ -167,6 +171,8 @@
 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: "",
@@ -236,6 +242,53 @@
   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);
 }
diff --git a/frontend-h5/src/pages.json b/frontend-h5/src/pages.json
index 9b60a48..a1bc252 100644
--- a/frontend-h5/src/pages.json
+++ b/frontend-h5/src/pages.json
@@ -179,7 +179,18 @@
       "style": {
         "navigationStyle": "custom"
       }
+    },
+    // #ifdef H5
+    {
+      "path": "pages/manualQr/index",
+      "type": "page",
+      "name": "manualQr",
+      "layout": "default",
+      "style": {
+        "navigationStyle": "custom"
+      }
     }
+    // #endif
   ],
   "subPackages": [],
   "tabBar": {
diff --git a/frontend-h5/src/pages/manualQr/index.vue b/frontend-h5/src/pages/manualQr/index.vue
new file mode 100644
index 0000000..daa1ea7
--- /dev/null
+++ b/frontend-h5/src/pages/manualQr/index.vue
@@ -0,0 +1,263 @@
+<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>
diff --git a/frontend-h5/src/uni-pages.d.ts b/frontend-h5/src/uni-pages.d.ts
index 43f7d4c..8f0e8d2 100644
--- a/frontend-h5/src/uni-pages.d.ts
+++ b/frontend-h5/src/uni-pages.d.ts
@@ -11,6 +11,7 @@
   "/pages/choosePerson/index" |
   "/pages/commonVisitors/index" |
   "/pages/login/index" |
+  "/pages/manualQr/index" |
   "/pages/mine/index" |
   "/pages/profile/index" |
   "/pages/reception/index" |
diff --git a/yuyueMiniProgram/src/api/auth.ts b/yuyueMiniProgram/src/api/auth.ts
index b505b1c..139b316 100644
--- a/yuyueMiniProgram/src/api/auth.ts
+++ b/yuyueMiniProgram/src/api/auth.ts
@@ -52,3 +52,7 @@
 export function logoutMini() {
   return postResult<void>('/api/mini/auth/logout')
 }
+
+export function unbindMiniOpenId() {
+  return postResult<void>('/api/mini/auth/unbind-openid')
+}
diff --git a/yuyueMiniProgram/src/auto-imports.d.ts b/yuyueMiniProgram/src/auto-imports.d.ts
index b0fc008..2d4b7c6 100644
--- a/yuyueMiniProgram/src/auto-imports.d.ts
+++ b/yuyueMiniProgram/src/auto-imports.d.ts
@@ -203,6 +203,7 @@
   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']
@@ -610,6 +611,7 @@
     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']>
diff --git a/yuyueMiniProgram/src/pages/mine/index.vue b/yuyueMiniProgram/src/pages/mine/index.vue
index eb07f9f..30e700d 100644
--- a/yuyueMiniProgram/src/pages/mine/index.vue
+++ b/yuyueMiniProgram/src/pages/mine/index.vue
@@ -1,5 +1,5 @@
 <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'
 
@@ -57,6 +57,7 @@
   { 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) => {
@@ -102,6 +103,40 @@
   }
 }
 
+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('您还未登录,请先登录')
@@ -111,6 +146,10 @@
     void handleLogout()
     return
   }
+  if (item.title === '解除绑定') {
+    handleUnbindOpenId()
+    return
+  }
 
   if (item.path) {
     uni.navigateTo({ url: item.path })
diff --git a/yuyueMiniProgram/src/static/mine/unbind.png b/yuyueMiniProgram/src/static/mine/unbind.png
new file mode 100644
index 0000000..52b5b1b
--- /dev/null
+++ b/yuyueMiniProgram/src/static/mine/unbind.png
Binary files differ

--
Gitblit v1.7.1