| | |
| | | <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', |
| | |
| | | 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> |