<script setup lang="ts">
|
import QRCode from 'qrcode'
|
import { fetchMyApprovalTodos, fetchMyVisitorCodes, fetchVisitRecords } from '@/api/appointment'
|
import { useMiniAuthStore } from '@/store/auth'
|
import type { MiniMyVisitorCodeOrderItem, MiniMyVisitorCodeTicketItem } from '@/types/appointment'
|
|
interface VisitorCodeDisplayTicket extends MiniMyVisitorCodeTicketItem {
|
qrCode: string
|
}
|
|
interface VisitorCodeDisplayOrder extends Omit<MiniMyVisitorCodeOrderItem, 'qrCodes'> {
|
timeText: string
|
qrCodes: VisitorCodeDisplayTicket[]
|
}
|
|
const systemInfo = uni.getSystemInfoSync()
|
const { safeAreaInsets } = systemInfo
|
const parkFlowBottomPx = Math.max(24, Math.round(systemInfo.windowHeight / 8))
|
const router = useRouter()
|
const authStore = useMiniAuthStore()
|
const visitorOrders = ref<VisitorCodeDisplayOrder[]>([])
|
const currentOrderIndex = ref(0)
|
const loadingVisitorCodes = ref(false)
|
const qrRenderToken = ref(0)
|
const ticketIndexMap = ref<Record<number, number>>({})
|
const approvalTodoCount = ref(0)
|
const visitRecordCount = ref(0)
|
|
const profile = computed(() => authStore.profile)
|
const isEmployee = computed(() => profile.value?.userType === 'EMPLOYEE')
|
const userRoleText = computed(() => {
|
if (!profile.value) {
|
return ''
|
}
|
if (profile.value.userType === 'EMPLOYEE') {
|
return profile.value.deptLeaderFlag ? '部门领导' : '员工'
|
}
|
return '普通访客'
|
})
|
const secondaryText = computed(() => {
|
if (!profile.value) {
|
return ''
|
}
|
if (profile.value.userType === 'EMPLOYEE') {
|
return profile.value.deptName || '未分配部门'
|
}
|
return profile.value.companyName || ''
|
})
|
const currentOrder = computed(() => visitorOrders.value[currentOrderIndex.value] || null)
|
const currentTicket = computed(() => {
|
if (!currentOrder.value) {
|
return null
|
}
|
const index = ticketIndexMap.value[currentOrder.value.orderId] ?? 0
|
return currentOrder.value.qrCodes[index] || currentOrder.value.qrCodes[0] || null
|
})
|
|
function handlePrevOrder() {
|
if (currentOrderIndex.value > 0) {
|
currentOrderIndex.value--
|
}
|
}
|
|
function handleNextOrder() {
|
if (currentOrderIndex.value < visitorOrders.value.length - 1) {
|
currentOrderIndex.value++
|
}
|
}
|
|
function onSwiperChange(e: { detail?: { current?: number } }) {
|
currentOrderIndex.value = e.detail?.current || 0
|
}
|
|
function currentTicketIndex(orderId: number) {
|
return ticketIndexMap.value[orderId] ?? 0
|
}
|
|
function handlePrevTicket(orderId: number) {
|
const currentIndex = currentTicketIndex(orderId)
|
if (currentIndex <= 0) {
|
return
|
}
|
ticketIndexMap.value = {
|
...ticketIndexMap.value,
|
[orderId]: currentIndex - 1,
|
}
|
}
|
|
function handleNextTicket(orderId: number) {
|
const order = visitorOrders.value.find(item => item.orderId === orderId)
|
const currentIndex = currentTicketIndex(orderId)
|
if (!order || currentIndex >= order.qrCodes.length - 1) {
|
return
|
}
|
ticketIndexMap.value = {
|
...ticketIndexMap.value,
|
[orderId]: currentIndex + 1,
|
}
|
}
|
|
function formatVisitTime(order: MiniMyVisitorCodeOrderItem) {
|
return order.visitTimeText || '暂无有效期'
|
}
|
|
function formatDateTime(value?: string | null) {
|
if (!value) {
|
return ''
|
}
|
return value.replace('T', ' ').slice(0, 16)
|
}
|
|
function buildVisitorOrders(orders: MiniMyVisitorCodeOrderItem[]): VisitorCodeDisplayOrder[] {
|
return orders.map(order => ({
|
...order,
|
timeText: formatVisitTime(order),
|
qrCodes: order.qrCodes.map((ticket: MiniMyVisitorCodeTicketItem) => ({
|
...ticket,
|
qrCode: '',
|
})),
|
}))
|
}
|
|
async function loadVisitorCodes() {
|
loadingVisitorCodes.value = true
|
const currentToken = qrRenderToken.value + 1
|
qrRenderToken.value = currentToken
|
try {
|
const orders = await fetchMyVisitorCodes()
|
const displayOrders = buildVisitorOrders(orders)
|
visitorOrders.value = displayOrders
|
currentOrderIndex.value = 0
|
ticketIndexMap.value = Object.fromEntries(displayOrders.map(item => [item.orderId, 0]))
|
await nextTick()
|
const hydratedOrders = await Promise.all(displayOrders.map(async order => ({
|
...order,
|
qrCodes: await Promise.all(order.qrCodes.map(async ticket => ({
|
...ticket,
|
qrCode: await createQrImage(ticket.payload),
|
}))),
|
})))
|
if (qrRenderToken.value === currentToken) {
|
visitorOrders.value = hydratedOrders
|
}
|
}
|
catch (error) {
|
visitorOrders.value = []
|
ticketIndexMap.value = {}
|
uni.showToast({
|
title: error instanceof Error ? error.message : '加载访客码失败',
|
icon: 'none',
|
})
|
}
|
finally {
|
if (qrRenderToken.value === currentToken) {
|
loadingVisitorCodes.value = false
|
}
|
}
|
}
|
|
async function loadEmployeeDashboardCounts() {
|
if (!isEmployee.value) {
|
approvalTodoCount.value = 0
|
visitRecordCount.value = 0
|
return
|
}
|
try {
|
const [todoResult, visitRecordResult] = await Promise.all([
|
fetchMyApprovalTodos({ page: 1, size: 1 }),
|
fetchVisitRecords({ page: 1, size: 1, futureOnly: true }),
|
])
|
approvalTodoCount.value = todoResult.total
|
visitRecordCount.value = visitRecordResult.total
|
}
|
catch (error) {
|
approvalTodoCount.value = 0
|
visitRecordCount.value = 0
|
console.warn('load employee dashboard counts failed', error)
|
}
|
}
|
|
async function createQrImage(payload: string) {
|
// #ifdef H5
|
return QRCode.toDataURL(payload, {
|
errorCorrectionLevel: 'M',
|
margin: 1,
|
width: 300,
|
})
|
// #endif
|
|
const qr = QRCode.create(payload, {
|
errorCorrectionLevel: 'M',
|
margin: 1,
|
})
|
const size = 300
|
const moduleCount = qr.modules.size
|
const tileSize = size / moduleCount
|
const canvasId = 'visitorQrCanvas'
|
|
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,
|
})
|
})
|
})
|
}
|
|
function handleDownloadVisitorCode(qrCodeUrl: string) {
|
if (!qrCodeUrl) {
|
uni.showToast({
|
title: '访客码生成中',
|
icon: 'none',
|
})
|
return
|
}
|
|
uni.showLoading({ title: '保存中...' })
|
void resolveSaveFilePath(qrCodeUrl)
|
.then(filePath => saveImageToAlbum(filePath))
|
.catch((error) => {
|
uni.hideLoading()
|
uni.showToast({
|
title: error instanceof Error ? error.message : '保存失败',
|
icon: 'none',
|
})
|
})
|
}
|
|
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
|
}
|
|
if (src.startsWith('wxfile://') || src.startsWith('file://') || src.startsWith('/')) {
|
resolve(src)
|
return
|
}
|
|
uni.getImageInfo({
|
src,
|
success: image => resolve(image.path),
|
fail: () => reject(new Error('获取图片失败')),
|
})
|
})
|
}
|
|
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({
|
success(settingdata) {
|
if (settingdata.authSetting['scope.writePhotosAlbum']) {
|
uni.showToast({ title: '授权成功,请重新点击下载', icon: 'none' })
|
}
|
else {
|
uni.showToast({ title: '授权失败,无法保存', icon: 'none' })
|
}
|
},
|
})
|
}
|
},
|
})
|
return
|
}
|
uni.showToast({
|
title: '保存失败',
|
icon: 'none',
|
})
|
console.error('save visitor code failed', err)
|
},
|
})
|
}
|
|
function handleOpenParkFlow() {
|
uni.navigateTo({
|
url: `/pages/aboutUs/index?title=${encodeURIComponent('入园流程')}&code=CONTENT_PARK_FLOW_HTML`,
|
})
|
}
|
|
function handleOpenApprovalTodo() {
|
uni.navigateTo({ url: '/pages/reviewList/index' })
|
}
|
|
function handleOpenVisitRecords() {
|
uni.navigateTo({
|
url: '/pages/reception/index',
|
})
|
}
|
|
function buildVisitorCodeSharePath(orderUuid?: string | null) {
|
const uuid = orderUuid || currentOrder.value?.orderUuid
|
return uuid ? `/pages/visitorCode/index?uuid=${encodeURIComponent(uuid)}` : '/pages/index/index'
|
}
|
|
onShareAppMessage((res) => {
|
const orderUuid = res?.target?.dataset?.orderUuid as string | undefined
|
const visitedUserDisplay = res?.target?.dataset?.visitedUserDisplay as string | undefined
|
return {
|
title: visitedUserDisplay ? `${visitedUserDisplay}的访客码` : '访客码分享',
|
path: buildVisitorCodeSharePath(orderUuid),
|
}
|
})
|
|
onShow(() => {
|
authStore.restoreSession()
|
void loadVisitorCodes()
|
void loadEmployeeDashboardCounts()
|
})
|
|
definePage({
|
name: 'home',
|
layout: 'tabbar',
|
style: {
|
navigationStyle: 'custom',
|
},
|
})
|
</script>
|
|
<template>
|
<view class="relative">
|
<image src="/static/home/bg.png" class="absolute left-0 top-0 z-0 h-[1624rpx] w-full" mode="scaleToFill" />
|
|
<view class="relative z-4" :style="{ paddingTop: `${safeAreaInsets?.top || 44}px` }">
|
<view class="mb-[64rpx] h-[88rpx] flex items-center px-[32rpx]">
|
<text class="text-[36rpx] text-white font-medium">
|
预约小程序
|
</text>
|
</view>
|
|
<view class="mb-[36rpx] flex items-center pl-[26rpx] pr-[22rpx]">
|
<image src="/static/home/hi.png" class="mr-[24rpx] h-[96rpx] w-[212rpx]" mode="aspectFit" />
|
<view class="flex flex-col">
|
<view class="welcome-title flex items-center text-[56rpx] text-white font-[600]">
|
<text>{{ profile?.displayName || '用户' }}</text>
|
</view>
|
<text class="mt-[8rpx] text-[24rpx] text-white/90">
|
{{ userRoleText }}<template v-if="secondaryText"> · {{ secondaryText }}</template>
|
</text>
|
</view>
|
</view>
|
|
<view v-if="isEmployee" class="mb-[32rpx] w-full pl-[26rpx] pr-[22rpx]" style="box-sizing: border-box;">
|
<view
|
class="rounded-[16rpx] p-[24rpx] pb-[26rpx]"
|
style="background: linear-gradient(180deg, #CCE4FE 0%, #FAFCFD 40%, #FDFDFD 100%); box-shadow: 0 4rpx 8rpx 0 rgba(0, 104, 255, 0.2);border: 1rpx solid #fff;"
|
>
|
<view class="flex gap-[30rpx]">
|
<view
|
class="h-[126rpx] w-[312rpx] flex rounded-[20rpx] pl-[38rpx] pt-[20rpx]"
|
style="background: url('/static/home/toDo_bg.png') no-repeat center center;
|
background-size: 100%;box-sizing: border-box;
|
box-shadow: 0 2rpx 8rpx 0 rgba(119, 144, 213, 0.3)"
|
@click="handleOpenApprovalTodo"
|
>
|
<image src="/static/home/toDo.png" class="mr-[14rpx] h-[100rpx] w-[108rpx]" />
|
<view class="mt-[8rpx] flex flex-col">
|
<text class="mb-[4rpx] text-[24rpx] text-[#056DFF] font-[600]">
|
待办
|
</text>
|
<text class="text-[32rpx] text-[#056DFF] font-bold">
|
{{ approvalTodoCount }}
|
</text>
|
</view>
|
</view>
|
<view
|
class="h-[126rpx] w-[312rpx] flex items-center rounded-[20rpx] pl-[38rpx] pt-[20rpx]"
|
style="background: url('/static/home/wait_bg.png') no-repeat center center;
|
background-size: 100%;box-sizing: border-box;
|
box-shadow: 0 2rpx 8rpx 0 rgba(119, 144, 213, 0.3)"
|
@click="handleOpenVisitRecords"
|
>
|
<image src="/static/home/wait.png" class="mr-[14rpx] h-[100rpx] w-[108rpx]" />
|
<view class="mt-[8rpx] flex flex-col">
|
<text class="mb-[4rpx] text-[24rpx] text-[#F86911] font-[600]">
|
待接待
|
</text>
|
<text class="text-[32rpx] text-[#F86911] font-bold">
|
{{ visitRecordCount }}
|
</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<view class="flex gap-[16rpx] pl-[24rpx] pr-[22rpx]">
|
<view
|
class="h-[240rpx] w-[344rpx] flex flex-1 flex-col items-center justify-center rounded-[20rpx]"
|
style="background: linear-gradient(180deg, #DEF4FF 0%, #FFFFFF 100%);border: 1rpx solid #fff;box-shadow: 0 4rpx 10rpx 0 rgba(0, 104, 255, 0.16)"
|
@click="() => { router.push({ name: 'appointment' }) }"
|
>
|
<image src="/static/home/appointment.png" class="mb-[12rpx] h-[96rpx] w-[96rpx]" />
|
<text class="text-[28rpx] text-[#1a1a1a]">
|
预约
|
</text>
|
</view>
|
<view class="flex flex-1 flex-col gap-[16rpx]">
|
<view
|
class="flex flex-1 items-center rounded-[20rpx] px-[40rpx]"
|
style="background: linear-gradient(180deg, #FFF7E0 0%, #FFFFFF 100%);box-shadow: 0 4rpx 10rpx 0 rgba(0, 104, 255, 0.16)"
|
@click="() => { router.push({ name: 'record' }) }"
|
>
|
<image src="/static/home/record.png" class="mr-[16rpx] h-[64rpx] w-[64rpx]" />
|
<text class="text-[28rpx] text-[#000000]">
|
预约记录
|
</text>
|
</view>
|
<view
|
class="flex flex-1 items-center rounded-[20rpx] px-[40rpx]"
|
style="background: linear-gradient(180deg, #E0FAEE 0%, #FFFFFF 100%);border: 1rpx solid #fff;box-shadow: 0 4rpx 10rpx 0 rgba(0, 104, 255, 0.16)"
|
@click="uni.navigateTo({ url: '/pages/choosePerson/index?mode=manage' })"
|
>
|
<image src="/static/home/visitor.png" class="mr-[16rpx] h-[64rpx] w-[64rpx]" />
|
<text class="text-[28rpx] text-[#000000]">
|
常用来访人
|
</text>
|
</view>
|
</view>
|
</view>
|
|
<view class="mt-[32rpx] px-[24rpx]">
|
<view class="mb-[20rpx] flex items-center justify-between">
|
<text class="text-[34rpx] text-[#1a1a1a]">
|
我的访客码
|
</text>
|
<view v-if="visitorOrders.length > 0" class="flex items-center">
|
<image
|
src="/static/home/chevron_left.png"
|
class="h-[40rpx] w-[40rpx] pr-[24rpx]"
|
@click="handlePrevOrder"
|
/>
|
<text class="text-[28rpx] text-[#999999] line-height-[40rpx]">
|
{{ currentOrderIndex + 1 }}/{{ visitorOrders.length }}
|
</text>
|
<image
|
src="/static/home/chevron_right.png"
|
class="h-[40rpx] w-[40rpx] pl-[24rpx]"
|
@click="handleNextOrder"
|
/>
|
</view>
|
</view>
|
|
<view
|
v-if="loadingVisitorCodes"
|
class="h-[420rpx] flex items-center justify-center rounded-[12rpx] bg-white text-[28rpx] text-[#999999]"
|
style="box-shadow: 0 6rpx 10rpx 0 rgba(0, 104, 255, 0.16);"
|
>
|
加载访客码中...
|
</view>
|
|
<view
|
v-else-if="visitorOrders.length === 0"
|
class="h-[420rpx] flex items-center justify-center rounded-[12rpx] bg-white text-[28rpx] text-[#999999]"
|
style="box-shadow: 0 6rpx 10rpx 0 rgba(0, 104, 255, 0.16);"
|
>
|
暂无可展示的访客码
|
</view>
|
|
<swiper
|
v-else
|
class="min-h-[760rpx] w-full"
|
:current="currentOrderIndex"
|
style="box-shadow: 0 6rpx 10rpx 0 rgba(0, 104, 255, 0.16);"
|
@change="onSwiperChange"
|
>
|
<swiper-item v-for="order in visitorOrders" :key="order.orderId">
|
<view
|
class="h-full w-full flex flex-col rounded-[12rpx] bg-white py-[24rpx]"
|
style="box-sizing: border-box;overflow: hidden;"
|
>
|
<view class="flex flex-col items-center">
|
<text class="mb-[16rpx] px-[32rpx] text-center text-[28rpx] text-[#1a1a1a]">
|
{{ order.visitedUserDisplay }}
|
</text>
|
<view
|
class="mb-[28rpx] mt-[16rpx] h-[48rpx] w-full flex items-center justify-center px-[20rpx]"
|
style="background: linear-gradient(90deg, rgba(0,104,255,0) 0%, rgba(0,104,255) 50%, rgba(0,104,255,0) 100%);"
|
>
|
<text class="truncate text-[24rpx] text-white">
|
{{ order.timeText }}
|
</text>
|
</view>
|
</view>
|
|
<view class="relative flex items-center justify-center px-[72rpx]">
|
<image
|
v-if="order.qrCodes.length > 1"
|
src="/static/home/left.png"
|
class="absolute left-[32rpx] h-[48rpx] w-[24rpx]"
|
@click="handlePrevTicket(order.orderId)"
|
/>
|
<image
|
:src="currentOrder?.orderId === order.orderId ? currentTicket?.qrCode : order.qrCodes[0]?.qrCode"
|
class="h-[360rpx] w-[360rpx]"
|
mode="aspectFit"
|
/>
|
<image
|
v-if="order.qrCodes.length > 1"
|
src="/static/home/right.png"
|
class="absolute right-[32rpx] h-[48rpx] w-[24rpx]"
|
@click="handleNextTicket(order.orderId)"
|
/>
|
</view>
|
|
<view class="mt-[12rpx] flex flex-col items-center justify-center gap-[8rpx] pb-[24rpx]">
|
<text class="text-[24rpx] text-[rgba(26,26,26,0.5)]">
|
{{ (currentOrder?.orderId === order.orderId ? currentTicket?.visitorName : order.qrCodes[0]?.visitorName) }}的访客码
|
</text>
|
<text
|
v-if="order.accessAreaText"
|
class="px-[40rpx] text-center text-[22rpx] text-[rgba(26,26,26,0.45)]"
|
>
|
通行区域:{{ order.accessAreaText }}
|
</text>
|
<text v-if="order.qrCodes.length > 1" class="text-[22rpx] text-[rgba(26,26,26,0.45)]">
|
{{ currentTicketIndex(order.orderId) + 1 }}/{{ order.qrCodes.length }}
|
</text>
|
</view>
|
|
<view class="h-[104rpx] flex items-center justify-center gap-[32rpx] pt-[20rpx]" style="border-top: 2rpx dashed #D8D8D8;border-radius: 12rpx;">
|
<view
|
class="w-[144rpx] flex flex-col items-center justify-center"
|
@click="handleDownloadVisitorCode(currentOrder?.orderId === order.orderId ? currentTicket?.qrCode || '' : order.qrCodes[0]?.qrCode || '')"
|
>
|
<image src="/static/home/download.png" class="mb-[8rpx] h-[40rpx] w-[40rpx]" />
|
<text class="text-[24rpx] text-[#1a1a1a]">
|
下载访客码
|
</text>
|
</view>
|
<button
|
class="m-0 w-[144rpx] flex flex-col items-center justify-center border-none bg-transparent p-0 line-height-normal after:border-none"
|
open-type="share"
|
:data-order-uuid="order.orderUuid"
|
:data-visited-user-display="order.visitedUserDisplay"
|
>
|
<image src="/static/home/share.png" class="mb-[8rpx] h-[40rpx] w-[40rpx]" />
|
<text class="text-[24rpx] text-[#1a1a1a]">
|
分享好友
|
</text>
|
</button>
|
</view>
|
</view>
|
</swiper-item>
|
</swiper>
|
</view>
|
|
<view class="mt-[48rpx] flex justify-center pb-[32rpx]">
|
<text class="text-[24rpx] text-[#999999]">
|
备案号:2435345342
|
</text>
|
</view>
|
</view>
|
|
<image
|
src="/static/home/process.png"
|
class="fixed right-[-42rpx] z-5 h-[128rpx] w-[128rpx]"
|
:style="{ bottom: `${parkFlowBottomPx}px` }"
|
@click="handleOpenParkFlow"
|
/>
|
<canvas canvas-id="visitorQrCanvas" class="qr-canvas" />
|
</view>
|
</template>
|
|
<style scoped>
|
.welcome-title {
|
font-family: 'YouSheBiaoTiHei', sans-serif;
|
}
|
|
.welcome-title text {
|
font-family: 'YouSheBiaoTiHei', sans-serif;
|
}
|
|
.qr-canvas {
|
position: fixed;
|
left: -9999px;
|
top: -9999px;
|
width: 300px;
|
height: 300px;
|
pointer-events: none;
|
}
|
</style>
|