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