puzhibing
2026-08-28 e92cd56bb56b111c1afe9922191f9897215ff6b0
yuyueMiniProgram/src/pages/visitorCode/index.vue
@@ -1,5 +1,15 @@
<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
}
function wait(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms))
}
definePage({
  name: 'visitorCode',
@@ -13,89 +23,324 @@
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 = 448
  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, () => {
      void wait(500).then(() => {
        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
    const hydratedCodes: VisitorCodeDisplayTicket[] = []
    for (const ticket of (codeData.qrCodes || [])) {
      hydratedCodes.push({
        ...ticket,
        qrCode: await createQrImage(ticket.payload),
      })
    }
    qrCodes.value = hydratedCodes
    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() {
  const ticketUuid = currentTicket.value?.ticketUuid || ''
  return ticketUuid
    ? `/pages/visitorCodeShare/index?ticketUuid=${encodeURIComponent(ticketUuid)}`
    : '/pages/index/index'
}
onShareAppMessage(() => ({
  title: '访客码的分享',
  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: 448px;
  height: 448px;
  opacity: 0;
  pointer-events: none;
}
</style>