449 lines
15 KiB
Plaintext
449 lines
15 KiB
Plaintext
<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>
|