longz
2026-08-11 2a3c3cdbf2398695d61633e072f98ee2757e9e27
feat(appointment): 添加访客预约系统解除绑定功能
5个文件已添加
10个文件已修改
747 ■■■■■ 已修改文件
backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/ExportStreamingTest.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/IntegrationHikvisionServiceTest.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/UploadStreamCloseTest.java 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/AppointmentListView.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/DashboardView.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages.json 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages/manualQr/index.vue 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/uni-pages.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/api/auth.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/auto-imports.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/mine/index.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/static/mine/unbind.png 补丁 | 查看 | 原始文档 | blame | 历史
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());
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;
    }
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()
backend/src/test/java/com/sinata/appointment/service/ExportStreamingTest.java
New file
@@ -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'});
    }
}
backend/src/test/java/com/sinata/appointment/service/IntegrationHikvisionServiceTest.java
New file
@@ -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);
    }
}
backend/src/test/java/com/sinata/appointment/service/UploadStreamCloseTest.java
New file
@@ -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();
        }
    }
}
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();
}
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);
}
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": {
frontend-h5/src/pages/manualQr/index.vue
New file
@@ -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>
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" |
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')
}
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']>
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 })
yuyueMiniProgram/src/static/mine/unbind.png