Files
medical-mall/pages/mall/delivery/orders/checkin.uvue
2026-06-10 20:20:47 +08:00

449 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ServicePageScaffold title="到达签到" fallback-url="/pages/mall/delivery/orders/route">
<ServicePanel title="居家服务认证" subtitle="使用居家服务系统账号登录">
<view v-if="!isHomecareLoggedIn">
<text class="info-text">状态:未登录</text>
<text class="info-text warning-text">居家服务认证服务暂不可用,请跳过此步骤继续操作</text>
</view>
<view v-else>
<text class="info-text">已登录:{{ homecareUserEmail }}</text>
<text class="info-text success-text">居家服务认证通过</text>
</view>
</ServicePanel>
<ServicePanel title="签到要求" subtitle="定位签到或扫码签到,要求在 50 米范围内并上传现场图片。">
<text v-if="order != null" class="info-text">服务地址:{{ order.fullAddress }}</text>
<text class="info-text">当前定位:{{ locationText }}</text>
<text class="info-text">定位精度:{{ accuracyText }}</text>
<text class="info-text">现场图片:{{ photos.length }} 张</text>
</ServicePanel>
<ServicePanel title="距离预校验" subtitle="获取定位后校验是否在允许签到范围内">
<text class="info-text">距离服务点:{{ distanceText }}</text>
<text class="info-text">允许半径:{{ allowedRadiusText }}</text>
<text class="info-text">校验状态:{{ precheckStatusText }}</text>
<text v-if="reasonText !== ''" class="info-text warning-text">{{ reasonText }}</text>
<view class="button-stack">
<button class="secondary-btn" :disabled="prechecking" @click="handlePrecheck">{{ prechecking ? '预校验中...' : '距离预校验' }}</button>
</view>
</ServicePanel>
<ServicePanel title="签到操作" subtitle="定位失败时要给出清晰提示。">
<view class="button-stack">
<button class="secondary-btn" @click="getCurrentLocation">获取 GPS 定位</button>
<button class="secondary-btn" @click="choosePhoto">拍照上传现场图片</button>
<input class="note-input" v-model="note" placeholder="补充签到说明,例如入户前核验联系人" />
<button class="primary-btn" :disabled="submitting" @click="submitCheckin">{{ submitting ? '签到中...' : '完成签到' }}</button>
</view>
</ServicePanel>
</ServicePageScaffold>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePageScaffold from '@/components/homeService/ServicePageScaffold.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import type { DeliveryLocationType, DeliveryOrderType } from '@/types/delivery.uts'
import { checkinOrder, getDeliveryOrderDetail } from '@/services/deliveryService.uts'
import { requireDeliveryAuth } from '@/utils/deliveryAuth.uts'
import { getDeliveryRouteParam } from '@/utils/deliveryRoute.uts'
import {
emailLogin,
checkinPrecheck,
getHomecareToken,
getHomecareUser,
getReasonText
} from '@/utils/homecareAuth.uts'
const orderId = ref('')
const order = ref<DeliveryOrderType | null>(null)
const currentLocation = ref<DeliveryLocationType | null>(null)
const locationText = ref('未获取')
const accuracyText = ref('未知')
const photos = ref([] as Array<string>)
const note = ref('')
const submitting = ref(false)
// 居家服务认证状态
const isHomecareLoggedIn = ref(false)
const homecareUserEmail = ref('')
// 距离预校验状态
const prechecking = ref(false)
const canCheckin = ref(false)
const distanceText = ref('未校验')
const allowedRadiusText = ref('未校验')
const precheckStatusText = ref('未校验')
const reasonText = ref('')
function updateHomecareLoginStatus(): void {
console.warn('[CHECKIN DEBUG] updateHomecareLoginStatus: called')
const token = getHomecareToken()
console.warn('[CHECKIN DEBUG] updateHomecareLoginStatus: token length:', token.length)
if (token !== '') {
isHomecareLoggedIn.value = true
const user = getHomecareUser()
if (user != null) {
const email = user.getString('email')
homecareUserEmail.value = email != null ? email : ''
console.warn('[CHECKIN DEBUG] updateHomecareLoginStatus: logged in as', homecareUserEmail.value)
}
} else {
isHomecareLoggedIn.value = false
homecareUserEmail.value = ''
console.warn('[CHECKIN DEBUG] updateHomecareLoginStatus: not logged in')
}
}
async function loginHomecare(): Promise<void> {
const token = getHomecareToken()
if (token !== '') {
uni.showToast({ title: '已登录,无需重复登录', icon: 'success' })
return
}
uni.showToast({ title: '居家服务认证服务暂不可用', icon: 'none' })
}
function toRadians(value: number): number {
return value * 3.1415926 / 180
}
function calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const earthRadius = 6378137
const deltaLat = toRadians(lat2 - lat1)
const deltaLng = toRadians(lng2 - lng1)
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return earthRadius * c
}
function wrapLocation(): Promise<DeliveryLocationType> {
console.warn('[CHECKIN DEBUG] wrapLocation: starting...')
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
enableHighAccuracy: true,
success: (res) => {
console.warn('[CHECKIN DEBUG] wrapLocation SUCCESS:', JSON.stringify({
latitude: res.latitude,
longitude: res.longitude,
accuracy: res.accuracy,
speed: res.speed,
altitude: res.altitude,
altitudeAccuracy: res.altitudeAccuracy,
heading: res.heading,
timestamp: res.timestamp
}))
resolve({
latitude: res.latitude,
longitude: res.longitude,
address: '当前位置',
time: new Date().toISOString().replace('T', ' ').substring(0, 19)
})
},
fail: (err) => {
console.warn('[CHECKIN DEBUG] wrapLocation FAIL:', JSON.stringify(err))
reject(new Error('定位失败'))
}
})
})
}
type LocationFullResult = {
location: DeliveryLocationType
accuracy: number
}
function wrapLocationFull(): Promise<LocationFullResult> {
console.warn('[CHECKIN DEBUG] wrapLocationFull: starting...')
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
enableHighAccuracy: true,
success: (res) => {
console.warn('[CHECKIN DEBUG] wrapLocationFull SUCCESS:', JSON.stringify({
latitude: res.latitude,
longitude: res.longitude,
accuracy: res.accuracy,
speed: res.speed,
altitude: res.altitude,
altitudeAccuracy: res.altitudeAccuracy,
heading: res.heading,
timestamp: res.timestamp
}))
const loc: DeliveryLocationType = {
latitude: res.latitude,
longitude: res.longitude,
address: '当前位置',
time: new Date().toISOString().replace('T', ' ').substring(0, 19)
}
const acc = res.accuracy != null ? res.accuracy : 0
console.warn('[CHECKIN DEBUG] wrapLocationFull resolved with accuracy:', acc)
resolve({ location: loc, accuracy: acc })
},
fail: (err) => {
console.warn('[CHECKIN DEBUG] wrapLocationFull FAIL:', JSON.stringify(err))
reject(new Error('定位失败'))
}
})
})
}
function wrapChooseImage(): Promise<Array<string>> {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: 3,
sourceType: ['camera'],
success: (res) => {
resolve(res.tempFilePaths)
},
fail: () => {
reject(new Error('拍照失败'))
}
})
})
}
async function loadData() {
console.warn('[CHECKIN DEBUG] loadData: called, orderId:', orderId.value)
const authResult = await requireDeliveryAuth({ redirectOnFail: true, toastOnFail: true })
if (!authResult.ok || orderId.value == '') {
console.warn('[CHECKIN DEBUG] loadData: auth failed or orderId empty')
return
}
console.warn('[CHECKIN DEBUG] loadData: fetching order detail')
order.value = await getDeliveryOrderDetail(orderId.value)
console.warn('[CHECKIN DEBUG] loadData: order fetched:', JSON.stringify({
id: order.value?.id,
fullAddress: order.value?.fullAddress,
latitude: order.value?.latitude,
longitude: order.value?.longitude,
allowCheckinRadiusMeters: order.value?.allowCheckinRadiusMeters
}))
updateHomecareLoginStatus()
}
async function getCurrentLocation() {
console.warn('[CHECKIN DEBUG] getCurrentLocation: called')
try {
currentLocation.value = await wrapLocation()
locationText.value = '纬度 ' + String(currentLocation.value.latitude) + ' / 经度 ' + String(currentLocation.value.longitude)
console.warn('[CHECKIN DEBUG] getCurrentLocation completed, location:', JSON.stringify(currentLocation.value))
} catch (error) {
console.warn('[CHECKIN DEBUG] getCurrentLocation error:', error)
uni.showToast({ title: '签到定位失败,请检查定位权限', icon: 'none' })
}
}
async function choosePhoto() {
try {
const selected = await wrapChooseImage()
photos.value = selected
} catch (error) {
uni.showToast({ title: '拍照失败,请重试', icon: 'none' })
}
}
async function handlePrecheck(): Promise<void> {
console.warn('[CHECKIN PAGE] ========== handlePrecheck START ==========')
console.warn('[CHECKIN PAGE] 当前 orderId:', orderId.value)
if (prechecking.value) {
console.warn('[CHECKIN PAGE] 已经在预校验中,直接返回')
return
}
if (orderId.value === '') {
console.warn('[CHECKIN PAGE] orderId 为空')
uni.showToast({ title: '工单信息缺失', icon: 'none' })
return
}
console.warn('[CHECKIN PAGE] 开始预校验流程')
prechecking.value = true
canCheckin.value = false
distanceText.value = '定位中...'
allowedRadiusText.value = '定位中...'
precheckStatusText.value = '定位中...'
reasonText.value = ''
try {
console.warn('[CHECKIN PAGE] 步骤 1: 调用 wrapLocationFull 获取位置')
const fullResult = await wrapLocationFull()
const location = fullResult.location
const accuracy = fullResult.accuracy
console.warn('[CHECKIN PAGE] 位置信息: lat=', location.latitude, ' lng=', location.longitude, ' accuracy=', accuracy)
currentLocation.value = location
locationText.value = '纬度 ' + String(location.latitude) + ' / 经度 ' + String(location.longitude)
accuracyText.value = accuracy > 0 ? String(accuracy) + ' 米' : '未知'
// 调用 RPC 预校验(现在直接走 Supabase不需要本地后端
console.warn('[CHECKIN PAGE] 步骤 2: 调用 checkinPrecheck RPC')
const result = await checkinPrecheck(
orderId.value,
location.latitude,
location.longitude,
accuracy
)
console.warn('[CHECKIN PAGE] RPC 返回结果: distance=', result.distanceMeters, ' radius=', result.allowedRadiusMeters, ' canCheckin=', result.canCheckin, ' reason=', result.reasonCode)
if (result.distanceMeters != null) {
distanceText.value = String(result.distanceMeters) + ' 米'
} else {
distanceText.value = '未知'
}
allowedRadiusText.value = String(result.allowedRadiusMeters) + ' 米'
if (result.canCheckin) {
canCheckin.value = true
precheckStatusText.value = '可以签到 / 下一步拍照'
reasonText.value = ''
console.warn('[CHECKIN PAGE] 可以签到')
} else {
canCheckin.value = false
precheckStatusText.value = '不可签到'
reasonText.value = getReasonText(result.reasonCode)
console.warn('[CHECKIN PAGE] 不可签到,原因:', result.reasonCode, getReasonText(result.reasonCode))
}
} catch (error) {
console.warn('[CHECKIN PAGE] ========== 捕获异常 ==========')
console.warn('[CHECKIN PAGE] 异常类型:', error instanceof Error ? 'Error' : typeof error)
console.warn('[CHECKIN PAGE] 异常信息:', error)
console.warn('[CHECKIN PAGE] ========== handlePrecheck END (ERROR) ==========')
uni.showToast({ title: '预校验失败,请重试', icon: 'none' })
precheckStatusText.value = '预校验失败'
} finally {
prechecking.value = false
console.warn('[CHECKIN PAGE] ========== handlePrecheck END (FINISHED) ==========')
}
}
async function submitCheckin() {
console.warn('[CHECKIN DEBUG] submitCheckin: called')
if (submitting.value) {
console.warn('[CHECKIN DEBUG] submitCheckin: already submitting, returning')
return
}
if (order.value == null) {
console.warn('[CHECKIN DEBUG] submitCheckin: order is null')
uni.showToast({ title: '订单信息缺失', icon: 'none' })
return
}
if (currentLocation.value == null) {
console.warn('[CHECKIN DEBUG] submitCheckin: currentLocation is null, calling getCurrentLocation')
await getCurrentLocation()
if (currentLocation.value == null) {
console.warn('[CHECKIN DEBUG] submitCheckin: still null after getCurrentLocation')
return
}
}
if (photos.value.length == 0) {
console.warn('[CHECKIN DEBUG] submitCheckin: no photos uploaded')
uni.showToast({ title: '请至少上传一张现场图片', icon: 'none' })
return
}
// RPC 预校验已经做了距离判断,这里直接提交
// 保留坐标检查作为兜底,防止跳过预校验直接提交
if (order.value.latitude == 0 && order.value.longitude == 0) {
console.warn('[CHECKIN DEBUG] submitCheckin: order has no valid coordinates (lat=0, lng=0)')
uni.showToast({ title: '订单缺少服务地址坐标', icon: 'none' })
return
}
const distance = calculateDistance(currentLocation.value.latitude, currentLocation.value.longitude, order.value.latitude, order.value.longitude)
console.warn('[CHECKIN DEBUG] submitCheckin: calculated distance:', distance, 'meters, allowedRadius:', order.value.allowCheckinRadiusMeters)
console.warn('[CHECKIN DEBUG] submitCheckin: proceeding with checkin, photos count:', photos.value.length)
doCheckin()
}
async function doCheckin() {
submitting.value = true
try {
await checkinOrder(orderId.value, {
location: currentLocation.value as DeliveryLocationType,
note: note.value,
photos: photos.value,
checkinMode: 'gps'
})
console.warn('[CHECKIN DEBUG] submitCheckin: checkinOrder succeeded')
uni.showToast({ title: '签到成功', icon: 'success' })
uni.redirectTo({ url: '/pages/mall/delivery/service-record/index?id=' + orderId.value })
} catch (error) {
console.warn('[CHECKIN DEBUG] submitCheckin error:', error)
uni.showToast({ title: '签到失败,请重试', icon: 'none' })
} finally {
submitting.value = false
}
}
onLoad((options) => {
console.warn('[CHECKIN DEBUG] onLoad: called, options:', JSON.stringify(options))
if (options != null) {
orderId.value = getDeliveryRouteParam(options as UTSJSONObject, 'id')
console.warn('[CHECKIN DEBUG] onLoad: extracted orderId:', orderId.value)
}
console.warn('[CHECKIN DEBUG] onLoad: calling loadData')
loadData()
})
</script>
<style scoped>
.info-text {
display: block;
margin-bottom: 14rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #16324f;
}
.success-text {
color: #0f766e;
font-weight: bold;
}
.warning-text {
color: #dc2626;
}
.button-stack {
flex-direction: column;
}
.primary-btn,
.secondary-btn {
margin-bottom: 18rpx;
border-radius: 18rpx;
font-size: 28rpx;
}
.primary-btn {
background: #0f766e;
color: #ffffff;
}
.primary-btn[disabled] {
background: #9ca3af;
}
.secondary-btn {
background: #eaf2f0;
color: #0f766e;
}
.note-input {
height: 84rpx;
padding: 0 24rpx;
margin-bottom: 18rpx;
border-radius: 18rpx;
background: #f2f7f6;
font-size: 28rpx;
}
</style>