You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1529 lines
46 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view class="reservation-page" :class="{ 'wechat-browser': isWeixinBrowser }">
<view class="fixed-nav" v-if="!isWeixinBrowser">
<NavBar title="过闸预约" />
</view>
<view class="reservation-scroll">
<view v-if="shipList.length > 1" class="card">
<view class="card-title">选择船舶</view>
<view v-if="!currentShip.id" class="ship-selector-tip">请先选择本次预约要使用的船舶</view>
<view class="ship-selector-list">
<view
v-for="item in shipList"
:key="item.id"
class="ship-selector-item"
:class="{ active: currentShip.id === item.id }"
@click="confirmShipSelection(item)"
>
<view class="ship-selector-main">
<text class="ship-selector-name">{{ item.ship_number }}</text>
<text class="ship-selector-meta">{{ item.total_tonnage }}吨 / {{ getShipTypeName(item.ship_type) }}</text>
</view>
<text v-if="currentShip.id === item.id" class="ship-selector-check">已选</text>
</view>
</view>
</view>
<!-- 船舶信息卡片2详细信息 -->
<view class="card">
<view class="card-title">船舶信息</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">编号</text>
<view class="info-value">{{ currentShip.ship_number }} <text class="arrow"></text></view>
</view>
<view class="info-row">
<text class="info-label">总长度</text>
<view class="info-value">{{ currentShip.total_length }}米 <text class="arrow"></text></view>
</view>
<view class="info-row">
<text class="info-label">型宽</text>
<view class="info-value">{{ currentShip.total_width }}米 <text class="arrow"></text></view>
</view>
<view class="info-row">
<text class="info-label">型深</text>
<view class="info-value">{{ currentShip.molded_depth }}米 <text class="arrow"></text></view>
</view>
<view class="info-row">
<text class="info-label">载重吨位</text>
<view class="info-value">{{ currentShip.total_tonnage }}吨 <text class="arrow"></text></view>
</view>
<view class="info-row" style="align-items: flex-start;">
<text class="info-label">单次过闸收费</text>
<view class="info-value" style="width:50%;text-align: right;flex-wrap: wrap;">
<view style="text-align: right;width:100%">{{ formatPrice(unitPrice) }}元</view>{{ calculationDescription }}
</view>
</view>
<view class="info-row">
<text class="info-label">类型</text>
<view class="info-value">{{ getShipTypeName(currentShip.ship_type) }} <text class="arrow"></text></view>
</view>
</view>
</view>
<!-- 航行方向选择 -->
<view class="card">
<view class="card-title">航行方向</view>
<view class="direction-row">
<button
v-for="item in directionEnum"
:key="item.value"
class="direction-btn"
:class="{ active: direction === item.value }"
@click="setDirection(item.value)"
>
{{ item.label }}
</button>
</view>
</view>
<button class="location-refresh-btn location-refresh-btn--standalone" @click="refreshUserLocation">重新获取定位</button>
<!-- 过闸日期选择 -->
<view class="card">
<view class="card-title">过闸日期</view>
<view class="direction-row">
<button
class="direction-btn"
:class="{ active: gateDate === 'today' }"
:disabled="isDateDisabled"
@click="setGateDate('today')"
>今天</button>
<button
class="direction-btn"
:class="{ active: gateDate === 'tomorrow', 'tomorrow-default': gateDate === 'tomorrow' && isDateDisabled }"
:disabled="isDateDisabled"
@click="setGateDate('tomorrow')"
>明天</button>
</view>
</view>
<!-- 预约须知 -->
<view class="notice-row">
<text class="notice-title">预约须知</text>
<view class="notice-check" style="position:relative;">
<checkbox :checked="agreeNotice" />
<text>我已阅读并同意《过闸预约服务协议》</text>
<view style="position:absolute;left:0;top:0;right:0;bottom:0;z-index:2;" @tap="toggleAgreeNotice"></view>
</view>
</view>
</view>
<view class="reservation-bottom-bar">
<button class="reservation-btn" @click="onReserve"></button>
</view>
</view>
</template>
<script>
import NavBar from '@/components/NavBar.vue'
import { API } from '@/config/index.js'
// #ifdef H5
import wx from 'jweixin-module'
// #endif
export default {
name: 'ReservationPage',
geofenceToleranceMeters: 1000,
geofenceAccuracyWarningMeters: 1000,
components: { NavBar },
data() {
return {
isWeixinBrowser: false,
direction: '',
shipList: [],
currentShip: {
id: '',
total_length: '',
total_width: '',
molded_depth: '',
ship_number: '',
total_tonnage: '',
ship_type: ''
},
directionEnum: [],
shipTypeEnum: [],
agreeNotice: false,
gateDate: 'today', // 默认选中"今天"
deadlineTimes: {
toTaihu: '',
toXujiang: ''
},
isDateDisabled: false, // 是否禁用日期选择
userLocation: null, // 用户位置信息 {latitude, longitude}
isInGeofence: null, // 是否在围栏范围内null表示未检查true表示在范围内false表示不在范围内
geofenceRequestSeq: 0, // 方向切换时用于丢弃过期围栏响应
geofenceDebug: {
direction: '',
locationSource: '',
stage: '',
result: null,
message: '',
updatedAt: '',
location: null,
rawGeofences: null,
evaluations: [],
},
unitPrice: '', // 单价
calculationDescription: '', // 计算规则
}
},
onLoad() {
// #ifdef H5
this.isWeixinBrowser = /MicroMessenger/i.test(navigator.userAgent)
// #endif
},
async onShow() {
// 先拉取方向、船型和可用船舶信息,如果没有可用船舶,则直接返回,不再进行后续操作(如获取位置信息等)
await this.fetchDirectionEnum();
await this.fetchShipTypeEnum();
const hasShip = await this.fetchShipList();
if (!hasShip) {
// 已在 fetchShipList 内部给出提示并跳转
return;
}
// 有可用船舶时,再弹出获取位置信息的提示
uni.showModal({
title: '提示',
content: '预约前需要先获取您的位置信息',
showCancel: true,
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.getUserLocation();
} else {
// 用户拒绝获取位置,可根据需要给出提示
uni.showToast({
title: '未获取位置信息,可能无法完成预约',
icon: 'none'
});
}
}
});
// 最后再拉取每日预约截止时间并设置默认日期
this.fetchDailyReservationDeadline();
},
methods: {
// 获取单价和计算规则
async fetchUnitPrice(shipId = '') {
const token = uni.getStorageSync('token')
if (!token) {
return
}
const query = shipId ? `?token=${token}&ship_id=${shipId}` : `?token=${token}`;
const res = await new Promise((resolve, reject) => {
uni.request({
url: `${API.GET_UNIT_PRICE}${query}`,
method: 'get',
success: resolve,
fail: reject
})
})
if (res.data && res.data.errcode === 0) {
const data = res.data.data;
this.unitPrice = data.price || '';
this.calculationDescription = data.price_desc || '';
}
},
// 计算单次过闸费用
// totalPriceText() {
// const unit = Number(this.unitPrice);
// if (isNaN(unit)) {
// return '-';
// }
// return `${unit}元(${this.calculationDescription}`;
// },
// 获取用户位置
getUserLocation() {
// #ifdef H5
if (this.isWeixinBrowser) {
// 微信 H5 环境
console.log('微信 H5 环境');
return this.getWeixinLocation();
} else {
// 普通 H5 环境
console.log('普通 H5 环境');
return this.getBrowserLocation();
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序环境
console.log('微信小程序环境');
return this.getMiniProgramLocation();
// #endif
return Promise.resolve(null);
},
handleLocationAccuracyNotice(location) {
const accuracy = location && typeof location.accuracy === 'number' ? location.accuracy : null;
if (accuracy !== null && accuracy > (this.$options.geofenceAccuracyWarningMeters || 1000)) {
uni.showToast({
title: `当前定位误差约${accuracy.toFixed(0)}`,
icon: 'none',
duration: 2500
});
}
},
async refreshUserLocation() {
uni.showLoading({ title: '重新获取定位...' });
const location = await this.getUserLocation();
uni.hideLoading();
if (!location) {
uni.showToast({ title: '重新获取定位失败', icon: 'none' });
return;
}
this.handleLocationAccuracyNotice(location);
if (this.direction) {
await this.fetchGeofenceByDirection({
showModal: false,
direction: this.direction,
location,
});
}
},
// 微信 H5 获取位置
getWeixinLocation() {
// #ifdef H5
return new Promise((resolve) => {
console.log('[WeixinLocation] 调用 getWeixinLocation');
const inWeixin = /MicroMessenger/i.test(navigator.userAgent);
console.log('[WeixinLocation] 是否微信环境 isWeixinBrowser =', this.isWeixinBrowser, 'UA检测 =', inWeixin ? '是' : '否');
// 优先使用 window.wx由 App.vue 挂载),否则使用导入的 wx
const hasWindowWx = (typeof window !== 'undefined' && !!window.wx);
const hasModuleWx = (typeof wx !== 'undefined');
console.log('[WeixinLocation] has window.wx =', hasWindowWx, ', has module wx =', hasModuleWx);
const wxObj = hasWindowWx ? window.wx : (hasModuleWx ? wx : null);
console.log('[WeixinLocation] wxObj =', wxObj);
if (!wxObj) {
console.error('微信 JS-SDK 未加载');
uni.showModal({
title: '提示',
content: '微信 JS-SDK 未加载,请刷新页面重试',
showCancel: false
});
resolve(null);
return;
}
console.log('[WeixinLocation] wxObj.version =', wxObj.version, 'typeof wxObj.config =', typeof wxObj.config, 'typeof wxObj.ready =', typeof wxObj.ready, 'typeof wxObj.getLocation =', typeof wxObj.getLocation);
if (typeof wxObj.config !== 'function') {
console.warn('[WeixinLocation] wx.config 未定义说明微信JS-SDK可能还没有正确初始化wx.ready 可能不会触发,请确认后端是否已下发 JSSDK 配置并在 App.vue 中调用 initWechatJSSDK');
}
if (typeof wxObj.getLocation !== 'function') {
console.warn('[WeixinLocation] wx.getLocation 不是函数,说明当前 JSSDK 可能未加载完整或未包含该接口');
}
if (typeof wxObj.error === 'function') {
wxObj.error((err) => {
console.error('[WeixinLocation] wx.error 回调触发JSSDK 配置或调用出错:', err);
});
} else {
console.warn('[WeixinLocation] wx.error 不存在,无法捕获 JSSDK 配置错误');
}
// 如果 5 秒内 ready 没有回调,给出日志提示
let readyCalled = false;
setTimeout(() => {
if (!readyCalled) {
console.warn('[WeixinLocation] 5 秒内 wx.ready 未回调,可能是 wx.config 未正确执行或签名错误');
}
}, 5000);
// 使用微信 JS-SDK 获取位置
wxObj.ready(() => {
readyCalled = true;
console.log('[WeixinLocation] wx.ready 回调触发,开始调用 wx.getLocation');
wxObj.getLocation({
type: 'gcj02', // 返回可以用于uni.openLocation的经纬度
success: (res) => {
console.log('[WeixinLocation] wx.getLocation success 原始返回值:', res);
const location = {
latitude: res.latitude,
longitude: res.longitude,
speed: res.speed,
accuracy: res.accuracy
};
this.userLocation = location;
this.updateGeofenceDebug({
locationSource: '微信 JSSDK gcj02',
location,
message: '微信定位成功',
stage: '定位完成',
});
this.handleLocationAccuracyNotice(location);
console.log('[WeixinLocation] 已保存 userLocation =', this.userLocation);
resolve(location);
},
fail: (err) => {
console.error('[WeixinLocation] wx.getLocation fail:', err);
this.updateGeofenceDebug({
locationSource: '微信 JSSDK gcj02',
location: null,
result: null,
message: '微信定位失败',
stage: '定位失败',
});
uni.showModal({
title: '提示',
content: '获取位置失败,请允许访问位置信息',
showCancel: false
});
resolve(null);
}
});
});
});
// #endif
return Promise.resolve(null);
},
// 普通 H5 浏览器获取位置
getBrowserLocation() {
// #ifdef H5
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
uni.showModal({
title: '提示',
content: '您的浏览器不支持地理位置功能',
showCancel: false
});
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const location = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
altitude: position.coords.altitude,
altitudeAccuracy: position.coords.altitudeAccuracy,
heading: position.coords.heading,
speed: position.coords.speed
};
this.userLocation = location;
this.updateGeofenceDebug({
locationSource: '浏览器 geolocation',
location,
message: '浏览器定位成功',
stage: '定位完成',
});
this.handleLocationAccuracyNotice(location);
console.log('浏览器获取位置成功:', this.userLocation);
resolve(location);
},
(error) => {
console.error('浏览器获取位置失败:', error);
this.updateGeofenceDebug({
locationSource: '浏览器 geolocation',
location: null,
result: null,
message: '浏览器定位失败:' + (error && error.message ? error.message : '未知错误'),
stage: '定位失败',
});
let errorMsg = '获取位置失败';
switch(error.code) {
case error.PERMISSION_DENIED:
errorMsg = '用户拒绝了位置请求';
break;
case error.POSITION_UNAVAILABLE:
errorMsg = '位置信息不可用';
break;
case error.TIMEOUT:
errorMsg = '获取位置超时';
break;
}
uni.showModal({
title: '提示',
content: errorMsg + ',请允许访问位置信息',
showCancel: false
});
resolve(null);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
});
// #endif
return Promise.resolve(null);
},
// 微信小程序获取位置
getMiniProgramLocation() {
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
const location = {
latitude: res.latitude,
longitude: res.longitude,
speed: res.speed,
accuracy: res.accuracy
};
this.userLocation = location;
this.updateGeofenceDebug({
locationSource: '微信小程序 gcj02',
location,
message: '小程序定位成功',
stage: '定位完成',
});
this.handleLocationAccuracyNotice(location);
console.log('小程序获取位置成功:', this.userLocation);
resolve(location);
},
fail: (err) => {
console.error('小程序获取位置失败:', err);
this.updateGeofenceDebug({
locationSource: '微信小程序 gcj02',
location: null,
result: null,
message: '小程序定位失败',
stage: '定位失败',
});
uni.showModal({
title: '提示',
content: '获取位置失败,请在设置中允许位置权限',
showCancel: false
});
resolve(null);
}
});
});
// #endif
return Promise.resolve(null);
},
setDirection(dir) {
this.direction = dir;
// 重置围栏范围检查状态,因为切换方向后需要重新检查
this.isInGeofence = null;
this.updateGateDateByDirection();
// 用户选择航行方向后,调用接口
if (dir) {
const requestSeq = ++this.geofenceRequestSeq;
this.fetchGeofenceByDirection({
direction: dir,
showModal: true,
requestSeq,
});
}
},
async fetchGeofenceByDirection(options = {}) {
const {
showModal = true,
direction = this.direction,
location = this.userLocation,
requestSeq = null,
} = options;
const token = uni.getStorageSync('token');
if (!token || !direction) {
return null;
}
this.updateGeofenceDebug({
direction,
stage: '请求围栏',
location: location || null,
});
try {
const res = await new Promise((resolve, reject) => {
uni.request({
url: `${API.GET_GEOFENCE_BY_DIRECTION}?token=${token}&direction=${direction}`,
method: 'GET',
success: resolve,
fail: reject
});
});
if (res.data && res.data.errcode === 0) {
const geofenceData = res.data.data;
console.log(geofenceData);
this.updateGeofenceDebug({
direction,
rawGeofences: geofenceData,
stage: '围栏已返回',
location: location || null,
message: `获取到 ${Array.isArray(geofenceData) ? geofenceData.length : 0} 个围栏`,
});
const result = this.checkLocationInGeofence(geofenceData, {
showModal,
location,
suppressStateUpdate: true,
});
if (requestSeq !== null && requestSeq !== this.geofenceRequestSeq) {
return result;
}
if (direction !== this.direction) {
return result;
}
this.isInGeofence = result;
return result;
}
} catch (e) {
if (requestSeq === null || requestSeq === this.geofenceRequestSeq) {
this.isInGeofence = null;
}
this.updateGeofenceDebug({
direction,
result: null,
stage: '围栏请求失败',
message: e && e.message ? e.message : '围栏请求失败',
});
return null;
}
return null;
},
// 检查用户位置是否在围栏范围内
checkLocationInGeofence(geofenceData, options = {}) {
const {
showModal = true,
location = this.userLocation,
} = options;
// 检查用户位置是否存在
if (!location || !location.latitude || !location.longitude) {
console.warn('用户位置信息不存在,无法进行范围判断');
this.updateGeofenceDebug({
location: location || null,
result: null,
stage: '无法计算',
message: '用户位置信息不存在,无法进行范围判断',
evaluations: [],
});
return null;
}
const userLat = parseFloat(location.latitude);
const userLng = parseFloat(location.longitude);
// 如果数组为空,不做操作,允许提交
if (!geofenceData || !Array.isArray(geofenceData) || geofenceData.length === 0) {
this.updateGeofenceDebug({
location,
rawGeofences: geofenceData,
result: true,
stage: '围栏计算完成',
message: '未配置围栏,默认放行',
evaluations: [],
});
return true;
}
const evaluations = [];
// 遍历围栏数据,查找 coordinates 字段
for (let i = 0; i < geofenceData.length; i++) {
const item = geofenceData[i];
if (item && item.coordinates && Array.isArray(item.coordinates) && item.coordinates.length > 0) {
const evaluation = this.evaluatePointAgainstPolygon(userLng, userLat, item.coordinates);
evaluations.push({
geofenceId: item.id || null,
geofenceName: item.name || '',
pointCount: item.coordinates.length,
...evaluation
});
if (evaluation.inside) {
console.log('用户位置在围栏范围内');
this.updateGeofenceDebug({
location,
rawGeofences: geofenceData,
result: true,
stage: '围栏计算完成',
message: `命中围栏 ${item.name || item.id || i + 1}`,
evaluations,
});
return true;
}
}
}
this.updateGeofenceDebug({
location,
rawGeofences: geofenceData,
result: false,
stage: '围栏计算完成',
message: '未命中任何围栏',
evaluations,
});
// 只有在选择方向时showModal为true才显示提示提交时showModal为false不显示由onReserve统一处理
if (showModal) {
uni.showModal({
title: '提示',
content: '您的当前位置不在闸站可预约范围内',
showCancel: false
});
}
return false;
},
// 判断点是否在多边形内(使用射线法)
isPointInPolygon(lng, lat, coordinates) {
return this.evaluatePointAgainstPolygon(lng, lat, coordinates).inside;
},
evaluatePointAgainstPolygon(lng, lat, coordinates) {
const toleranceMeters = this.$options.geofenceToleranceMeters || 20;
if (!coordinates || coordinates.length < 3) {
return {
inside: false,
toleranceMeters,
polygonPointCount: 0,
onBoundary: false,
withinTolerance: false,
rayCastingInside: false,
minDistanceMeters: null,
};
}
const polygon = coordinates.map(coord => {
if (Array.isArray(coord) && coord.length >= 2) {
return [
parseFloat(coord[0]),
parseFloat(coord[1])
];
}
return null;
}).filter(coord => coord !== null);
if (polygon.length < 3) {
return {
inside: false,
toleranceMeters,
polygonPointCount: polygon.length,
onBoundary: false,
withinTolerance: false,
rayCastingInside: false,
minDistanceMeters: null,
};
}
const boundaryInfo = this.inspectPolygonBoundary(lng, lat, polygon, toleranceMeters);
const rayCastingInside = this.isPointInsidePolygonByRayCasting(lng, lat, polygon);
return {
inside: boundaryInfo.onBoundary || boundaryInfo.withinTolerance || rayCastingInside,
toleranceMeters,
polygonPointCount: polygon.length,
onBoundary: boundaryInfo.onBoundary,
withinTolerance: boundaryInfo.withinTolerance,
nearestSegmentIndex: boundaryInfo.nearestSegmentIndex,
minDistanceMeters: boundaryInfo.minDistanceMeters,
rayCastingInside,
};
},
isPointInsidePolygonByRayCasting(lng, lat, polygon) {
let inside = false;
const x = lng;
const y = lat;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0];
const yi = polygon[i][1];
const xj = polygon[j][0];
const yj = polygon[j][1];
const intersect = ((yi > y) !== (yj > y)) &&
(x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) {
inside = !inside;
}
}
return inside;
},
isPointOnPolygonBoundary(lng, lat, polygon, toleranceMeters = this.$options.geofenceToleranceMeters || 20) {
const boundaryInfo = this.inspectPolygonBoundary(lng, lat, polygon, toleranceMeters);
return boundaryInfo.onBoundary || boundaryInfo.withinTolerance;
},
inspectPolygonBoundary(lng, lat, polygon, toleranceMeters = this.$options.geofenceToleranceMeters || 20) {
if (!polygon || polygon.length < 2) {
return {
onBoundary: false,
withinTolerance: false,
minDistanceMeters: null,
nearestSegmentIndex: null,
};
}
let minDistanceMeters = null;
let nearestSegmentIndex = null;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const start = polygon[j];
const end = polygon[i];
if (!start || !end || start.length < 2 || end.length < 2) {
continue;
}
if (this.isPointOnSegment(lng, lat, start[0], start[1], end[0], end[1])) {
return {
onBoundary: true,
withinTolerance: true,
minDistanceMeters: 0,
nearestSegmentIndex: i,
};
}
const distanceMeters = this.getDistanceToSegmentMeters(lng, lat, start[0], start[1], end[0], end[1]);
if (minDistanceMeters === null || distanceMeters < minDistanceMeters) {
minDistanceMeters = distanceMeters;
nearestSegmentIndex = i;
}
}
return {
onBoundary: false,
withinTolerance: minDistanceMeters !== null && minDistanceMeters <= toleranceMeters,
minDistanceMeters: minDistanceMeters !== null ? Number(minDistanceMeters.toFixed(3)) : null,
nearestSegmentIndex,
};
},
isPointOnSegment(px, py, x1, y1, x2, y2, epsilon = 1e-10) {
const cross = (px - x1) * (y2 - y1) - (py - y1) * (x2 - x1);
if (Math.abs(cross) > epsilon) {
return false;
}
const dot = (px - x1) * (x2 - x1) + (py - y1) * (y2 - y1);
if (dot < -epsilon) {
return false;
}
const squaredLength = (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);
return dot <= squaredLength + epsilon;
},
getDistanceToSegmentMeters(px, py, x1, y1, x2, y2) {
const meanLatRad = ((py + y1 + y2) / 3) * Math.PI / 180;
const metersPerDegreeLat = 111320;
const metersPerDegreeLng = Math.cos(meanLatRad) * 111320;
const ax = x1 * metersPerDegreeLng;
const ay = y1 * metersPerDegreeLat;
const bx = x2 * metersPerDegreeLng;
const by = y2 * metersPerDegreeLat;
const pxMeters = px * metersPerDegreeLng;
const pyMeters = py * metersPerDegreeLat;
const dx = bx - ax;
const dy = by - ay;
if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) {
return Math.hypot(pxMeters - ax, pyMeters - ay);
}
let projection = ((pxMeters - ax) * dx + (pyMeters - ay) * dy) / (dx * dx + dy * dy);
projection = Math.max(0, Math.min(1, projection));
const closestX = ax + projection * dx;
const closestY = ay + projection * dy;
return Math.hypot(pxMeters - closestX, pyMeters - closestY);
},
updateGeofenceDebug(payload = {}) {
this.geofenceDebug = {
...this.geofenceDebug,
...payload,
updatedAt: new Date().toLocaleString(),
};
},
formatDebug(value) {
if (value === null || typeof value === 'undefined') {
return 'null';
}
try {
return JSON.stringify(value, null, 2);
} catch (error) {
return String(value);
}
},
async fetchDailyReservationDeadline() {
const token = uni.getStorageSync('token');
if (!token) return;
try {
const res = await new Promise((resolve, reject) => {
uni.request({
url: `${API.GET_DAILY_RESERVATION_DEADLINE}?token=${token}`,
method: 'GET',
success: resolve,
fail: reject
});
});
if (res.data && res.data.errcode === 0) {
const deadlineData = res.data.data;
if (deadlineData) {
this.deadlineTimes = {
toTaihu: deadlineData.deadline_time_to_taihu || '',
toXujiang: deadlineData.deadline_time_to_xujiang || ''
};
this.updateGateDateByDirection();
}
}
} catch (e) {
// 可选:错误处理
}
},
getDeadlineTimeByDirection(direction) {
if (direction === 'out') {
return this.deadlineTimes.toTaihu;
}
if (direction === 'in') {
return this.deadlineTimes.toXujiang;
}
return '';
},
updateGateDateByDirection() {
const deadlineTime = this.getDeadlineTimeByDirection(this.direction);
if (!deadlineTime) {
this.isDateDisabled = false;
return;
}
this.checkDeadlineAndSetDate(deadlineTime);
},
checkDeadlineAndSetDate(deadlineTime) {
if (!deadlineTime) return;
const now = new Date();
const currentHour = now.getHours();
const currentMinute = now.getMinutes();
// 解析截止时间,格式如 "18:00"
const [deadlineHour, deadlineMinute] = deadlineTime.split(':').map(Number);
// 计算当前时间(分钟)和截止时间(分钟)
const currentTimeMinutes = currentHour * 60 + currentMinute;
const deadlineTimeMinutes = deadlineHour * 60 + deadlineMinute;
// 如果当前时间晚于或等于截止时间,默认选择明天
if (currentTimeMinutes >= deadlineTimeMinutes) {
this.gateDate = 'tomorrow';
} else {
// 如果当前时间早于截止时间,默认选择今天
this.gateDate = 'today';
}
// 任何时候都不能更改日期
this.isDateDisabled = true;
},
async fetchShipList() {
const token = uni.getStorageSync('token');
if (!token) {
uni.showToast({ title: '请先登录', icon: 'none' });
return false;
}
try {
const res = await new Promise((resolve, reject) => {
uni.request({
url: `${API.AVAILABLE_SHIPS}?token=${token}`,
method: 'GET',
success: resolve,
fail: reject
});
});
if (res.data && res.data.errcode === 0) {
const availableShips = Array.isArray(res.data.data)
? res.data.data.map(item => this.normalizeShip(item))
: [];
this.shipList = availableShips;
if (!availableShips.length) {
uni.showToast({ title: '暂无船舶信息', icon: 'none', duration: 1500 });
setTimeout(() => {
uni.redirectTo({ url: '/pages/index/ship_manage' });
}, 1000);
return false;
}
if (availableShips.length === 1) {
this.currentShip = availableShips[0];
await this.fetchUnitPrice(this.currentShip.id);
} else {
this.currentShip = this.emptyShip();
this.unitPrice = '';
this.calculationDescription = '';
}
return true;
} else {
uni.showToast({ title: (res.data && res.data.errmsg) || '获取船舶信息失败', icon: 'none' });
return false;
}
} catch (e) {
uni.showToast({ title: '网络错误', icon: 'none' });
return false;
}
},
async fetchDirectionEnum() {
const token = uni.getStorageSync('token');
if (!token) return;
try {
const res = await new Promise((resolve, reject) => {
uni.request({
url: `${API.GET_DIRECTION_ENUM}?token=${token}`,
method: 'GET',
success: resolve,
fail: reject
});
});
if (res.data && res.data.errcode === 0) {
// 转为数组 [{label, value}]
this.directionEnum = Object.keys(res.data.data).map(label => ({
label,
value: res.data.data[label]
}));
// 默认不选择,等待用户选择
}
} catch (e) {}
},
async fetchShipTypeEnum() {
const token = uni.getStorageSync('token');
if (!token) return;
try {
const res = await new Promise((resolve, reject) => {
uni.request({
url: `${API.SHIP_PROPERTY_ENUM}?token=${token}`,
method: 'GET',
success: resolve,
fail: reject
});
});
if (res.data && res.data.errcode === 0) {
const shipTypeRaw = res.data.data.ship_type || {};
if (Array.isArray(shipTypeRaw)) {
this.shipTypeEnum = shipTypeRaw;
} else {
this.shipTypeEnum = Object.keys(shipTypeRaw).map(label => ({
label,
value: shipTypeRaw[label]
}));
}
}
} catch (e) {
// 可选:错误处理
}
},
normalizeShip(ship) {
return {
id: ship.id,
total_length: ship.total_length,
total_width: ship.total_width,
molded_depth: ship.molded_depth,
ship_number: ship.ship_number,
total_tonnage: ship.total_tonnage,
ship_type: ship.ship_type
};
},
emptyShip() {
return {
id: '',
total_length: '',
total_width: '',
molded_depth: '',
ship_number: '',
total_tonnage: '',
ship_type: ''
};
},
confirmShipSelection(ship) {
const targetShip = this.normalizeShip(ship);
uni.showModal({
title: '确认船舶',
content: `本次预约将使用船舶“${targetShip.ship_number}”,请确认是否继续?`,
confirmText: '确认',
cancelText: '重选',
success: async (res) => {
if (!res.confirm) {
return;
}
this.currentShip = targetShip;
await this.fetchUnitPrice(targetShip.id);
}
});
},
getShipTypeName(type) {
const found = this.shipTypeEnum.find(item => item.value === type || item.value == type);
return found ? found.label : type;
},
formatPrice(price) {
if (price === '' || price === null || typeof price === 'undefined') {
return '';
}
const numericPrice = Number(price);
if (Number.isNaN(numericPrice)) {
return price;
}
return numericPrice.toFixed(2);
},
async onReserve() {
if (!this.currentShip.id) {
uni.showToast({ title: '请先选择船舶', icon: 'none' });
return;
}
if (!this.agreeNotice) {
uni.showToast({ title: '请先阅读并同意预约须知', icon: 'none' });
return;
}
if (!this.direction) {
uni.showToast({ title: '请先选择航行方向', icon: 'none' });
return;
}
// 提交前强制重新获取一次最新位置,避免沿用旧坐标
uni.showLoading({ title: '正在获取位置...' });
const latestLocation = await this.getUserLocation();
uni.hideLoading();
// 如果位置不存在,提示用户
if (!latestLocation || !latestLocation.latitude || !latestLocation.longitude) {
uni.showToast({ title: '无法获取位置信息,请检查位置权限', icon: 'none' });
return;
}
// 用刚获取到的最新位置重新检查围栏范围
uni.showLoading({ title: '正在验证位置...' });
const geofenceResult = await this.fetchGeofenceByDirection({
showModal: false,
direction: this.direction,
location: latestLocation,
});
uni.hideLoading();
// 检查是否在围栏范围内
if (geofenceResult === false) {
uni.showToast({ title: '您的当前位置不在闸站可预约范围内', icon: 'none' });
return;
}
// 如果检查状态为null未检查或检查失败也阻止提交
if (geofenceResult === null) {
uni.showToast({ title: '位置验证失败,请重试', icon: 'none' });
return;
}
const token = uni.getStorageSync('token');
if (!token) {
uni.showToast({ title: '请先登录', icon: 'none' });
return;
}
// if (!this.currentShip || !this.currentShip.ship_number || !this.currentShip.id) {
// uni.showToast({ title: '无效的船舶信息', icon: 'none' });
// return;
// }
// direction: north->in, south->out
const directionValue = this.direction || 'in';
// 计算过闸日期
let gateDateStr = '';
const today = new Date();
if (this.gateDate === 'today') {
gateDateStr = today.toISOString().slice(0, 10);
} else if (this.gateDate === 'tomorrow') {
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
gateDateStr = tomorrow.toISOString().slice(0, 10);
}
// 准备提交数据
const requestData = {
token,
ship_id: this.currentShip.id,
direction: directionValue,
passage_date: gateDateStr
};
// 如果用户位置存在,添加经纬度
if (latestLocation && latestLocation.latitude && latestLocation.longitude) {
requestData.latitude = latestLocation.latitude;
requestData.longitude = latestLocation.longitude;
}
uni.showLoading({ title: '提交中...' });
uni.request({
url: `${API.RESERVATION_CREATE}`,
method: 'POST',
data: requestData,
success: (res) => {
uni.hideLoading();
if (res.data && res.data.errcode === 0) {
uni.showToast({ title: '预约成功', icon: 'success' });
setTimeout(() => {
uni.switchTab({ url: '/pages/order/index' });
}, 800);
} else {
// 处理包含字段错误信息的返回,例如:
// res.data.data = { latitude: ['纬度不能为空'], longitude: ['经度不能为空'] }
const resp = res.data || {};
const detail = resp.data || {};
let msg = '';
if (detail && typeof detail === 'object') {
const parts = [];
if (Array.isArray(detail.latitude) && detail.latitude.length) {
parts.push(`纬度:${detail.latitude.join('')}`);
}
if (Array.isArray(detail.longitude) && detail.longitude.length) {
parts.push(`经度:${detail.longitude.join('')}`);
}
if (parts.length) {
msg = parts.join('');
}
}
if (!msg) {
msg = resp.errmsg || '预约失败';
}
uni.showModal({
title: '提示',
content: msg,
showCancel: false
});
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络错误', icon: 'none' });
}
});
},
toggleAgreeNotice() {
this.agreeNotice = !this.agreeNotice;
},
setGateDate(val) {
// 如果日期选择被禁用,不允许更改
if (this.isDateDisabled) {
return;
}
this.gateDate = val;
},
}
}
</script>
<style lang="scss" scoped>
.reservation-page {
background: linear-gradient(180deg, #cbe6ff 0%, #f6faff 100%);
min-height: 100vh;
padding-bottom: 140rpx;
padding-top: 90px;
font-family: 'SourceHanSansCN', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.wechat-browser {
padding-top: 10px;
}
.fixed-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: linear-gradient(180deg, #cbe6ff 0%, #f6faff 100%);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 16px 10px 16px;
background: linear-gradient(180deg, #cbe6ff 0%, #f6faff 100%);
padding-top: 7vh;
}
.back-btn, .more-btn {
font-size: 24px;
color: #333;
}
.title {
font-size: 22px;
font-weight: bold;
color: #222;
}
.card {
background: #fff;
border-radius: 18px;
margin: 0 16px 16px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 18px 18px 12px 18px;
margin-top: 20px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #222;
}
.ship-selector-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.ship-selector-tip {
margin-bottom: 16rpx;
padding: 16rpx 20rpx;
border-radius: 16rpx;
background: rgba(255, 186, 73, 0.16);
color: #9a5a00;
font-size: 24rpx;
}
.ship-selector-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-radius: 20rpx;
background: #f5f8fc;
border: 2rpx solid transparent;
}
.ship-selector-item.active {
border-color: #217aff;
background: rgba(33, 122, 255, 0.08);
}
.ship-selector-main {
display: flex;
flex-direction: column;
}
.ship-selector-name {
font-size: 30rpx;
font-weight: 600;
color: #222;
}
.ship-selector-meta {
margin-top: 8rpx;
font-size: 24rpx;
color: #7b8794;
}
.ship-selector-check {
color: #217aff;
font-size: 24rpx;
font-weight: 600;
}
.location-refresh-btn {
margin-top: 20rpx;
height: 76rpx;
line-height: 76rpx;
border-radius: 999rpx;
background: #fff7ed;
color: #c2410c;
border: 1rpx solid #fff7ed;
font-size: 26rpx;
}
.location-refresh-btn--standalone {
display: block;
width: calc(100% - 64rpx);
margin: 20rpx 32rpx 0;
}
.water-info-row {
display: flex;
justify-content: center;
align-items: flex-start;
width: fit-content;
margin: 0 auto;
gap: 150rpx;
}
.water-info-col {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
}
.label {
color: #888;
font-size: 15px;
margin-bottom: 2px;
text-align: left;
}
.value.blue {
color: #217aff;
font-size: 14px;
text-align: left;
}
.info-list {
border-top: 1px solid #f0f0f0;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: #222;
font-size: 16px;
}
.info-value {
color: #222;
font-size: 16px;
display: flex;
align-items: center;
}
.arrow {
color: #bdbdbd;
font-size: 18px;
margin-left: 4px;
}
.direction-row, .batch-row {
display: flex;
margin-bottom: 8px;
justify-content: center;
}
.direction-btn, .batch-btn {
border: none;
border-radius: 24px;
padding: 0;
font-size: 14px;
background: #f2f6fa;
color: #888;
height: 24px;
width: 110px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 20rpx;
border: none;
outline: none;
&::after {
border: none;
}
}
.direction-btn.active, .batch-btn.active {
background: #217aff;
color: #fff;
}
.direction-btn:disabled {
opacity: 0.6;
pointer-events: none;
}
.direction-btn.tomorrow-default {
background: linear-gradient(90deg, rgb(255, 185, 128) 0%, rgb(255, 201, 154) 100%) !important;
color: #fff;
}
.notice-row {
margin: 24px 16px 0 16px;
}
.notice-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #222;
}
.notice-check {
display: flex;
align-items: center;
margin-top: 8px;
font-size: 13px;
color: #888;
}
.tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 60px;
background: #fff;
display: flex;
border-top: 1px solid #eaeaea;
z-index: 10;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #888;
font-size: 14px;
}
.tab-item.active {
color: #217aff;
}
.icon {
font-size: 22px;
margin-bottom: 2px;
}
.reservation-bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fcfcfc;
box-shadow: 0 -2rpx 16rpx rgba(59,124,255,0.08);
padding: 24rpx 24rpx 32rpx 24rpx;
z-index: 999;
display: flex;
justify-content: center;
}
.reservation-btn {
min-width: 320rpx;
height: 80rpx;
border-radius: 40rpx;
background: #217aff;
color: #fff;
font-size: 32rpx;
font-weight: 500;
border: none;
outline: none;
box-shadow: 0 4rpx 16rpx rgba(33,122,255,0.08);
transition: background 0.2s;
}
.reservation-scroll {
padding-bottom: 80rpx;
}
.date-btn.active {
background: #217aff;
color: #fff;
}
.price-info-section {
background: #fff;
border-radius: 18px;
margin: 0 16px 16px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 18px 18px 12px 18px;
margin-top: 20px;
}
.price-info-item {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
font-size: 14px;
}
.price-info-item:last-child {
margin-bottom: 0;
}
.price-label {
color: #666;
min-width: 100px;
flex-shrink: 0;
}
.price-value {
color: #217aff;
font-weight: 600;
flex: 1;
}
.price-desc {
color: #222;
flex: 1;
line-height: 1.6;
}
</style>