From 25df88d0ab9d7444ed1d162b9efa1259ac2f6f66 Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期一, 27 四月 2026 10:13:53 +0800
Subject: [PATCH] 小程序对接finish

---
 yuyueMiniProgram/src/pages/visitorCode/index.vue |  346 ++++++++++++++++++++++++++++++++++++++++++++++++---------
 1 files changed, 289 insertions(+), 57 deletions(-)

diff --git a/yuyueMiniProgram/src/pages/visitorCode/index.vue b/yuyueMiniProgram/src/pages/visitorCode/index.vue
index 6b54325..4f544fe 100644
--- a/yuyueMiniProgram/src/pages/visitorCode/index.vue
+++ b/yuyueMiniProgram/src/pages/visitorCode/index.vue
@@ -1,5 +1,11 @@
 <script setup lang="ts">
-import { ref } from 'vue'
+import QRCode from 'qrcode'
+import { fetchSharedAppointmentDetail, fetchSharedVisitorCodes } from '@/api/appointment'
+import type { MiniMyVisitorCodeOrderItem, MiniMyVisitorCodeTicketItem, MiniSharedAppointmentDetailItem } from '@/types/appointment'
+
+interface VisitorCodeDisplayTicket extends MiniMyVisitorCodeTicketItem {
+  qrCode: string
+}
 
 definePage({
   name: 'visitorCode',
@@ -13,89 +19,315 @@
 const sysInfo = uni.getSystemInfoSync()
 const statusBarHeight = sysInfo.statusBarHeight || 0
 const navBarHeight = 44
+const orderUuid = ref('')
+const detail = ref<MiniSharedAppointmentDetailItem | null>(null)
+const order = ref<MiniMyVisitorCodeOrderItem | null>(null)
+const qrCodes = ref<VisitorCodeDisplayTicket[]>([])
+const currentTicketIndex = ref(0)
+const loading = ref(false)
+
+const currentTicket = computed(() => qrCodes.value[currentTicketIndex.value] || null)
+const canSlideTickets = computed(() => qrCodes.value.length > 1)
 
 function handleBack() {
   router.back()
 }
+
+function formatDateTime(value?: string | null) {
+  if (!value) {
+    return ''
+  }
+  return value.replace('T', ' ').slice(0, 16)
+}
+
+function ticketTimeText() {
+  return detail.value?.visitTimeText || order.value?.visitTimeText || ''
+}
+
+async function createQrImage(payload: string) {
+  // #ifdef H5
+  return QRCode.toDataURL(payload, {
+    errorCorrectionLevel: 'M',
+    margin: 1,
+    width: 400,
+  })
+  // #endif
+  const qr = QRCode.create(payload, {
+    errorCorrectionLevel: 'M',
+    margin: 1,
+  })
+  const size = 400
+  const moduleCount = qr.modules.size
+  const tileSize = size / moduleCount
+  const canvasId = 'sharedVisitorQrCanvas'
+
+  return new Promise<string>((resolve, reject) => {
+    const context = uni.createCanvasContext(canvasId)
+    context.setFillStyle('#FFFFFF')
+    context.fillRect(0, 0, size, size)
+    context.setFillStyle('#000000')
+
+    for (let row = 0; row < moduleCount; row += 1) {
+      for (let col = 0; col < moduleCount; col += 1) {
+        const index = row * moduleCount + col
+        if (!qr.modules.data[index]) {
+          continue
+        }
+        const x = Math.round(col * tileSize)
+        const y = Math.round(row * tileSize)
+        const width = Math.ceil((col + 1) * tileSize) - x
+        const height = Math.ceil((row + 1) * tileSize) - y
+        context.fillRect(x, y, width, height)
+      }
+    }
+
+    context.draw(false, () => {
+      uni.canvasToTempFilePath({
+        canvasId,
+        width: size,
+        height: size,
+        destWidth: size * 2,
+        destHeight: size * 2,
+        success: res => resolve(res.tempFilePath),
+        fail: reject,
+      })
+    })
+  })
+}
+
+async function loadData() {
+  if (!orderUuid.value) {
+    uni.showToast({ title: '缺少分享参数', icon: 'none' })
+    return
+  }
+  loading.value = true
+  try {
+    const [detailData, codeData] = await Promise.all([
+      fetchSharedAppointmentDetail(orderUuid.value),
+      fetchSharedVisitorCodes(orderUuid.value),
+    ])
+    detail.value = detailData
+    order.value = codeData
+    qrCodes.value = await Promise.all((codeData.qrCodes || []).map(async ticket => ({
+      ...ticket,
+      qrCode: await createQrImage(ticket.payload),
+    })))
+    currentTicketIndex.value = 0
+  }
+  catch (error) {
+    detail.value = null
+    order.value = null
+    qrCodes.value = []
+    uni.showToast({
+      title: error instanceof Error ? error.message : '加载访客码失败',
+      icon: 'none',
+    })
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+function handlePrevTicket() {
+  if (currentTicketIndex.value <= 0) {
+    return
+  }
+  currentTicketIndex.value -= 1
+}
+
+function handleNextTicket() {
+  if (currentTicketIndex.value >= qrCodes.value.length - 1) {
+    return
+  }
+  currentTicketIndex.value += 1
+}
+
+function resolveSaveFilePath(src: string) {
+  return new Promise<string>((resolve, reject) => {
+    if (!src) {
+      reject(new Error('访客码生成中'))
+      return
+    }
+    if (src.startsWith('http://') || src.startsWith('https://')) {
+      uni.downloadFile({
+        url: src,
+        success: (res) => {
+          if (res.statusCode === 200 && res.tempFilePath) {
+            resolve(res.tempFilePath)
+            return
+          }
+          reject(new Error('下载图片失败'))
+        },
+        fail: () => reject(new Error('下载图片失败')),
+      })
+      return
+    }
+    resolve(src)
+  })
+}
+
+function saveImageToAlbum(filePath: string) {
+  uni.saveImageToPhotosAlbum({
+    filePath,
+    success: () => {
+      uni.hideLoading()
+      uni.showToast({ title: '保存成功', icon: 'success' })
+    },
+    fail: (err) => {
+      uni.hideLoading()
+      const errMsg = err?.errMsg || ''
+      if (errMsg.includes('auth deny') || errMsg.includes('authorize no response')) {
+        uni.showModal({
+          title: '提示',
+          content: '需要您授权保存相册',
+          success(res) {
+            if (res.confirm) {
+              uni.openSetting()
+            }
+          },
+        })
+        return
+      }
+      uni.showToast({ title: '保存失败', icon: 'none' })
+    },
+  })
+}
+
+function handleDownloadVisitorCode() {
+  if (!currentTicket.value?.qrCode) {
+    uni.showToast({ title: '访客码生成中', icon: 'none' })
+    return
+  }
+  uni.showLoading({ title: '保存中...' })
+  void resolveSaveFilePath(currentTicket.value.qrCode)
+    .then(filePath => saveImageToAlbum(filePath))
+    .catch((error) => {
+      uni.hideLoading()
+      uni.showToast({
+        title: error instanceof Error ? error.message : '保存失败',
+        icon: 'none',
+      })
+    })
+}
+
+function buildSharePath() {
+  return orderUuid.value ? `/pages/visitorCode/index?uuid=${encodeURIComponent(orderUuid.value)}` : '/pages/index/index'
+}
+
+onShareAppMessage(() => ({
+  title: detail.value?.visitedUserDisplay ? `${detail.value.visitedUserDisplay}的访客码` : '访客码分享',
+  path: buildSharePath(),
+}))
+
+onLoad((options) => {
+  orderUuid.value = options?.uuid ? decodeURIComponent(options.uuid) : ''
+  void loadData()
+})
 </script>
 
 <template>
   <view class="min-h-screen w-full relative box-border flex flex-col pb-[100rpx]">
-    <!-- 整个页面的背景图 -->
-    <view class="fixed left-0 top-0 z-0 w-full h-full">
-      <image src="/static/viewCode/bg@2x.png" class="w-full h-full" mode="scaleToFill" />
+    <view class="fixed left-0 top-0 z-0 h-full w-full">
+      <image src="/static/viewCode/bg@2x.png" class="h-full w-full" mode="scaleToFill" />
     </view>
 
-    <!-- 自定义导航栏 -->
-    <view class="relative z-50 w-full shrink-0" :style="{ height: (statusBarHeight + navBarHeight) + 'px' }">
-      <view class="fixed left-0 top-0 w-full z-50" :style="{ height: (statusBarHeight + navBarHeight) + 'px' }">
-        <view class="flex items-center justify-between w-full absolute left-0 z-10"
-          :style="{ top: statusBarHeight + 'px', height: navBarHeight + 'px' }">
-          <view class="flex h-full w-[88rpx] items-center justify-center relative z-10" @click="handleBack">
+    <view class="relative z-50 w-full shrink-0" :style="{ height: `${statusBarHeight + navBarHeight}px` }">
+      <view class="fixed left-0 top-0 z-50 w-full" :style="{ height: `${statusBarHeight + navBarHeight}px` }">
+        <view class="absolute left-0 z-10 flex w-full items-center justify-between" :style="{ top: `${statusBarHeight}px`, height: `${navBarHeight}px` }">
+          <view class="relative z-10 flex h-full w-[88rpx] items-center justify-center" @click="handleBack">
             <wd-icon name="arrow-left" size="40rpx" color="#ffffff" />
           </view>
-          <view class="absolute left-0 w-full text-center text-[34rpx] font-medium text-white z-0">
-            预约记录
+          <view class="absolute left-0 z-0 w-full text-center text-[34rpx] text-white font-medium">
+            访客码
           </view>
-          <view class="w-[88rpx]"></view>
+          <view class="w-[88rpx]" />
         </view>
       </view>
     </view>
 
-    <!-- 内容区 -->
-    <view class="relative z-10 px-[32rpx] pt-[24rpx] flex-1 flex flex-col items-center">
-      
-      <!-- 提示文字 -->
-      <view class="flex items-center justify-center mb-[24rpx]">
+    <view class="relative z-10 flex flex-1 flex-col items-center px-[32rpx] pt-[24rpx]">
+      <view class="mb-[24rpx] flex items-center justify-center">
         <wd-icon name="info-circle" size="32rpx" color="#FFC140" class="mr-[10rpx]" />
-        <text class="text-[24rpx] text-[#FFF] ml-[10rpx]">使用方式:前往访问单位,使用访客机刷此码进场</text>
+        <text class="ml-[10rpx] text-[24rpx] text-[#FFF]">使用方式:前往访问单位,使用访客机刷此码进场</text>
       </view>
 
-      <!-- 卡片区域 -->
-      <view class="bg-white rounded-[16rpx] shadow-[0_4rpx_16rpx_rgba(0,0,0,0.05)] w-full mb-[64rpx] overflow-hidden">
-        <view class="h-[96rpx] flex items-center justify-center border-b-[2rpx] border-solid border-[#F5F5F5]">
-          <text class="text-[28rpx] text-[#1A1A1A1] font-medium">王琦的访客码</text>
-        </view>
-        <view class="py-[24rpx] flex flex-col items-center">
-          <view class="flex items-center mb-[16rpx]">
-            <text class="text-[28rpx] text-[#1D2129] font-medium">被访人:</text>
-            <text class="text-[28rpx] text-[#B3B3B3]">王明(管理部)</text>
-          </view>
-          <view class="w-full h-[64rpx] flex items-center justify-center mb-[52rpx]" style="background: linear-gradient(90deg, rgba(0, 104, 255, 0) 0%, #0068FF 50%, rgba(0, 104, 255, 0) 100%);">
-            <text class="text-[28rpx] text-white">2023-10-10 11:30至2023-10-15 18:30</text>
-          </view>
-          
-          <!-- 二维码与左右箭头 -->
-          <view class="flex items-center justify-between w-full px-[40rpx] mb-[20rpx] box-border">
-            <view class="w-[24rpx] h-[48rpx] bg-[#EAF2FF] flex items-center justify-center transition-opacity active:opacity-70">
-              <wd-icon name="arrow-left" size="24rpx" color="#0068FF" />
-            </view>
-            <view class="w-[448rpx] h-[448rpx] flex items-center justify-center">
-              <image src="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=VisitorCode" class="w-full h-full" mode="aspectFit" />
-            </view>
-            <view class="w-[24rpx] h-[48rpx] bg-[#EAF2FF] flex items-center justify-center transition-opacity active:opacity-70">
-              <wd-icon name="arrow-right" size="24rpx" color="#0068FF" />
-            </view>
-          </view>
-          
-          <text class="text-[24rpx] text-[#1A1A1A1] mb-[16rpx]">门岗访客机自主登记,刷身份证原件</text>
-        </view>
+      <view v-if="loading" class="mt-[120rpx] text-[28rpx] text-white">
+        加载中...
       </view>
 
-      <!-- 底部按钮 -->
-      <view class="w-[480rpx] flex flex-col items-center gap-[32rpx]">
-        <view class="w-full h-[96rpx] rounded-full bg-[#0068FF] flex items-center justify-center shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] transition-opacity active:opacity-70">
-          <image src="/static/viewCode/Download@2x.png" class="w-[40rpx] h-[40rpx] mr-[16rpx]" mode="aspectFit" />
-          <text class="text-[32rpx] text-white font-medium">下载访客码</text>
+      <template v-else>
+        <view class="mb-[64rpx] w-full overflow-hidden rounded-[16rpx] bg-white shadow-[0_4rpx_16rpx_rgba(0,0,0,0.05)]">
+          <view class="h-[96rpx] flex items-center justify-center border-b-[2rpx] border-solid border-[#F5F5F5]">
+            <text class="text-[28rpx] text-[#1A1A1A] font-medium">
+              {{ currentTicket?.visitorName || '访客' }}的访客码
+            </text>
+          </view>
+          <view class="flex flex-col items-center py-[24rpx]">
+            <view class="mb-[16rpx] flex items-center">
+              <text class="text-[28rpx] text-[#1D2129] font-medium">被访人:</text>
+              <text class="text-[28rpx] text-[#B3B3B3]">{{ detail?.visitedUserDisplay || '-' }}</text>
+            </view>
+            <view class="mb-[52rpx] flex h-[64rpx] w-full items-center justify-center" style="background: linear-gradient(90deg, rgba(0, 104, 255, 0) 0%, #0068FF 50%, rgba(0, 104, 255, 0) 100%);">
+              <text class="text-[28rpx] text-white">{{ ticketTimeText() || '-' }}</text>
+            </view>
+
+            <view class="mb-[20rpx] flex w-full items-center justify-between px-[40rpx] box-border">
+              <view
+                v-if="canSlideTickets"
+                class="h-[48rpx] w-[24rpx] flex items-center justify-center bg-[#EAF2FF] transition-opacity active:opacity-70"
+                @click="handlePrevTicket"
+              >
+                <wd-icon name="arrow-left" size="24rpx" color="#0068FF" />
+              </view>
+              <view v-else class="w-[24rpx]" />
+              <view class="h-[448rpx] w-[448rpx] flex items-center justify-center">
+                <image v-if="currentTicket?.qrCode" :src="currentTicket.qrCode" class="h-full w-full" mode="aspectFit" />
+                <view v-else class="text-[26rpx] text-[#999999]">暂无可用访客码</view>
+              </view>
+              <view
+                v-if="canSlideTickets"
+                class="h-[48rpx] w-[24rpx] flex items-center justify-center bg-[#EAF2FF] transition-opacity active:opacity-70"
+                @click="handleNextTicket"
+              >
+                <wd-icon name="arrow-right" size="24rpx" color="#0068FF" />
+              </view>
+              <view v-else class="w-[24rpx]" />
+            </view>
+
+            <text v-if="detail?.accessAreaText" class="mb-[10rpx] px-[40rpx] text-center text-[22rpx] text-[#999999]">
+              通行区域:{{ detail.accessAreaText }}
+            </text>
+            <text v-if="canSlideTickets" class="mb-[16rpx] text-[24rpx] text-[#1A1A1A]">
+              {{ currentTicketIndex + 1 }}/{{ qrCodes.length }}
+            </text>
+            <text class="mb-[16rpx] text-[24rpx] text-[#1A1A1A]">门岗访客机自主登记,刷身份证原件</text>
+          </view>
         </view>
-        <view class="w-full h-[96rpx] rounded-full bg-white border-[2rpx] border-solid border-[#0068FF] flex items-center justify-center transition-opacity active:opacity-70 box-border">
-          <image src="/static/home/share.png" class="w-[40rpx] h-[40rpx] mr-[16rpx]" mode="aspectFit" />
-          <text class="text-[32rpx] text-[#0068FF] font-medium">分享好友</text>
+
+        <view class="flex w-[480rpx] flex-col items-center gap-[32rpx]">
+          <view class="h-[96rpx] w-full flex items-center justify-center rounded-full bg-[#0068FF] shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] transition-opacity active:opacity-70" @click="handleDownloadVisitorCode">
+            <image src="/static/viewCode/Download@2x.png" class="mr-[16rpx] h-[40rpx] w-[40rpx]" mode="aspectFit" />
+            <text class="text-[32rpx] text-white font-medium">下载访客码</text>
+          </view>
+          <button class="m-0 h-[96rpx] w-full flex items-center justify-center rounded-full border-[2rpx] border-solid border-[#0068FF] bg-white p-0 transition-opacity active:opacity-70 after:border-none" open-type="share">
+            <image src="/static/home/share.png" class="mr-[16rpx] h-[40rpx] w-[40rpx]" mode="aspectFit" />
+            <text class="text-[32rpx] text-[#0068FF] font-medium">分享好友</text>
+          </button>
         </view>
-      </view>
+      </template>
     </view>
   </view>
+  <canvas canvas-id="sharedVisitorQrCanvas" class="qr-canvas" />
 </template>
 
 <style scoped>
+.qr-canvas {
+  position: fixed;
+  left: -9999px;
+  top: -9999px;
+  width: 400px;
+  height: 400px;
+  opacity: 0;
+  pointer-events: none;
+}
 </style>

--
Gitblit v1.7.1