feat(lbs): 真实距离与按距离派单落地
- 数据库:orders/activities 新增 latitude/longitude(含存量表 ALTER 迁移) - 服务端:Haversine 计算 distanceKm/distanceText,派单池支持 lat/lng/radius 过滤并按距离升序;历史无坐标订单用腾讯地理编码兜底并回写缓存 - 新增 /api/lbs/reverse 逆地址解析(Key 仅服务端持有,登录 + 限流) - 小程序:雷达/接单大厅改用真实定位与真实距离,删除全部写死演示距离;下单页新增地图选点,组局页保存选点经纬度 - 修复接单大厅点接单会新建订单的问题,改为真正 claimOrder 认领 - 新增 lbs-distance 护栏(110 套件/729 用例全绿)
这个提交包含在:
@@ -87,6 +87,8 @@ export async function createActivity(payload) {
|
||||
title: payload.title,
|
||||
gameCategory: payload.game || payload.gameCategory || '美食陪吃',
|
||||
location: payload.location,
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
startTime: payload.startTime,
|
||||
payType: payload.payType === 'AA' ? 1 : 2,
|
||||
totalFeeCents: Math.round((Number(payload.totalFee) || 0) * 100),
|
||||
@@ -684,16 +686,38 @@ export async function getUserFinanceRecords(userId) {
|
||||
/**
|
||||
* 获取可抢订单池 (真实读取 MySQL orders 待接单列表)
|
||||
*/
|
||||
export async function getOrdersPool() {
|
||||
export async function getOrdersPool(options = {}) {
|
||||
try {
|
||||
const res = await http.get('/orders/pool');
|
||||
if (Array.isArray(res) && res.length > 0) {
|
||||
const lat = Number(options.lat);
|
||||
const lng = Number(options.lng);
|
||||
const radius = Number(options.radius);
|
||||
const qs = [];
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
qs.push(`lat=${lat}`, `lng=${lng}`);
|
||||
if (Number.isFinite(radius) && radius > 0) qs.push(`radius=${radius}`);
|
||||
}
|
||||
const res = await http.get(`/orders/pool${qs.length ? `?${qs.join('&')}` : ''}`);
|
||||
if (Array.isArray(res)) {
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[API] 获取可抢订单池异常:', err.message);
|
||||
throw err;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 坐标反查行政区/地址(雷达顶部定位文案)
|
||||
*/
|
||||
export async function reverseGeocode(latitude, longitude) {
|
||||
try {
|
||||
const res = await http.get(`/lbs/reverse?lat=${Number(latitude)}&lng=${Number(longitude)}`);
|
||||
return res || null;
|
||||
} catch (err) {
|
||||
console.warn('[API] 逆地址解析异常:', err.message);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
:key="zoneItem"
|
||||
class="zone-chip-item btn-active"
|
||||
:class="{ selected: form.location === zoneItem }"
|
||||
@click="form.location = zoneItem"
|
||||
@click="selectZone(zoneItem)"
|
||||
>
|
||||
{{ zoneItem.split(' ')[0] }}
|
||||
</view>
|
||||
@@ -380,6 +380,8 @@ const form = ref({
|
||||
title: '',
|
||||
startTime: '',
|
||||
location: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
maxParticipants: 4,
|
||||
aaFee: '',
|
||||
pricingMode: 'PER_PERSON',
|
||||
@@ -470,12 +472,20 @@ const selectQuickTime = (timeItem) => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectZone = (zoneItem) => {
|
||||
form.value.location = zoneItem;
|
||||
form.value.latitude = null;
|
||||
form.value.longitude = null;
|
||||
};
|
||||
|
||||
const handleChooseLocation = () => {
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
uni.chooseLocation({
|
||||
success: (res) => {
|
||||
if (res && (res.name || res.address)) {
|
||||
form.value.location = res.name ? `${res.address ? res.address + ' ' : ''}${res.name}` : res.address;
|
||||
form.value.latitude = Number.isFinite(Number(res.latitude)) ? Number(res.latitude) : null;
|
||||
form.value.longitude = Number.isFinite(Number(res.longitude)) ? Number(res.longitude) : null;
|
||||
uni.showToast({ title: '已选取地点', icon: 'success' });
|
||||
}
|
||||
},
|
||||
@@ -483,7 +493,7 @@ const handleChooseLocation = () => {
|
||||
console.warn('[ActivityCreate] 地图选点取消或未授权:', err);
|
||||
// 若未授权或在H5/开发环境,平滑引导点选推荐公开商圈
|
||||
if (recommendPublicZones.value.length > 0) {
|
||||
form.value.location = recommendPublicZones.value[0];
|
||||
selectZone(recommendPublicZones.value[0]);
|
||||
uni.showToast({ title: '已填入推荐商圈', icon: 'none' });
|
||||
}
|
||||
}
|
||||
@@ -492,7 +502,7 @@ const handleChooseLocation = () => {
|
||||
|
||||
// #ifndef MP-WEIXIN || APP-PLUS
|
||||
if (recommendPublicZones.value.length > 0) {
|
||||
form.value.location = recommendPublicZones.value[0];
|
||||
selectZone(recommendPublicZones.value[0]);
|
||||
uni.showToast({ title: '已为您填入示范商圈', icon: 'none' });
|
||||
}
|
||||
// #endif
|
||||
@@ -647,6 +657,8 @@ const handleSubmit = async () => {
|
||||
maxParticipants: form.value.maxParticipants,
|
||||
startTime: trimmedTime,
|
||||
location: trimmedLocation,
|
||||
latitude: form.value.latitude,
|
||||
longitude: form.value.longitude,
|
||||
creatorNickname: cachedUser.nickname || '同城局长',
|
||||
creatorAvatar: cachedUser.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150&h=150&fit=crop',
|
||||
};
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
|
||||
<!-- 派单池订单列表 -->
|
||||
<view class="dispatch-list">
|
||||
<view v-if="filteredOrders.length === 0" class="hall-empty">
|
||||
<text class="hall-empty-title">暂无待接需求单</text>
|
||||
<text class="hall-empty-desc">开启定位并稍后刷新,附近新单会优先推给你</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
@@ -227,7 +231,8 @@ import AppTabBar from '@/components/CustomTabBar.vue';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
import { isExpertCertified } from '@/common/role.js';
|
||||
import { createOrder, getOrdersPool, getExpertStats, claimOrder, updateCompanionProfile, getUserProfile } from '@/common/api.js';
|
||||
import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile } from '@/common/api.js';
|
||||
import { getUserLocation } from '@/common/location.js';
|
||||
import { getNavBarInfo } from '@/common/nav.js';
|
||||
|
||||
const currentThemeKey = ref(getCurrentThemeKey());
|
||||
@@ -289,6 +294,13 @@ const loadHallData = async () => {
|
||||
isOnline.value = uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0';
|
||||
|
||||
// 2. 并行动态读取最新资料、经营数据与派单池
|
||||
let myCoords = { lat: null, lng: null };
|
||||
try {
|
||||
const pos = await getUserLocation();
|
||||
myCoords = { lat: pos.latitude, lng: pos.longitude };
|
||||
} catch (_) {
|
||||
// 未授权定位:距离显示「同城」,不影响大厅浏览
|
||||
}
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
getUserProfile(uid).then((profile) => {
|
||||
@@ -317,8 +329,8 @@ const loadHallData = async () => {
|
||||
}
|
||||
}).catch(() => {}),
|
||||
|
||||
getOrdersPool().then((pool) => {
|
||||
if (pool && pool.length > 0) {
|
||||
getOrdersPool(myCoords).then((pool) => {
|
||||
if (Array.isArray(pool)) {
|
||||
orders.value = pool.map(p => {
|
||||
let typeClass = 'cat-food';
|
||||
if (p.category.includes('喝') || p.category.includes('咖啡')) typeClass = 'cat-drink';
|
||||
@@ -328,7 +340,7 @@ const loadHallData = async () => {
|
||||
...p,
|
||||
typeClass,
|
||||
timeSlot: p.timeSlot || '今日内随时可约',
|
||||
distance: '1.2km内',
|
||||
distance: p.distanceText || '同城',
|
||||
requirement: p.requirement || '按约定准时到场,文明健康社交。'
|
||||
};
|
||||
});
|
||||
@@ -382,64 +394,7 @@ const categories = ref([
|
||||
{ key: 'game', name: '线上电竞', count: 3, icon: ICONS.gameCrosshair }
|
||||
]);
|
||||
|
||||
const orders = ref([
|
||||
{
|
||||
id: 101,
|
||||
category: '同城美食陪吃',
|
||||
typeClass: 'cat-food',
|
||||
payMethod: 'AA制各付',
|
||||
reward: 120,
|
||||
title: '三里屯北京烤鸭探店·寻2位好脾气饭搭子',
|
||||
location: '朝阳区 · 三里屯SOHO商场',
|
||||
distance: '1.1km',
|
||||
timeSlot: '今日 18:30 - 20:30',
|
||||
requirement: '已提前订座,现场各自出示4位核销码,性格随和开朗。',
|
||||
userName: '小鹿斑比',
|
||||
userAvatar: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=100'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
category: '线上电竞开黑',
|
||||
typeClass: 'cat-game',
|
||||
payMethod: '老板全包',
|
||||
reward: 70,
|
||||
title: '王者荣耀 · 微信区荣耀王者晋级双排',
|
||||
location: '线上远程开黑',
|
||||
distance: '全国',
|
||||
timeSlot: '接单后即刻开始 (2局)',
|
||||
requirement: '擅长射手或打野位,带飞晋级,游戏内清晰沟通不压力。',
|
||||
userName: '想赢一把好难',
|
||||
userAvatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=100'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
category: '同城下午茶陪喝',
|
||||
typeClass: 'cat-drink',
|
||||
payMethod: '房主买单',
|
||||
reward: 150,
|
||||
title: '工体精品手冲咖啡馆 · 倾听解压与交流',
|
||||
location: '朝阳区 · 工体北路',
|
||||
distance: '1.8km',
|
||||
timeSlot: '今日 15:00 - 17:00',
|
||||
requirement: '纯正规咖啡馆,工作压力大想倾诉,求善解人意的倾听导师。',
|
||||
userName: '风吹过的夏天',
|
||||
userAvatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=100'
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
category: '同城KTV陪唱',
|
||||
typeClass: 'cat-sing',
|
||||
payMethod: 'AA分摊',
|
||||
reward: 200,
|
||||
title: '量贩纯K包厢麦霸欢唱 · 流行情歌对唱',
|
||||
location: '朝阳区 · 青年路大悦城店',
|
||||
distance: '3.2km',
|
||||
timeSlot: '今日 20:00 - 23:00',
|
||||
requirement: '爱唱歌、不跑调,会带动气氛,严禁任何违规,正规量贩KTV。',
|
||||
userName: '麦霸小王子',
|
||||
userAvatar: 'https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=100'
|
||||
}
|
||||
]);
|
||||
const orders = ref([]);
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (activeCategory.value === 'all') return orders.value;
|
||||
@@ -476,20 +431,10 @@ const handleGrabOrder = (order) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '正在锁定订单...' });
|
||||
try {
|
||||
await createOrder({
|
||||
buyerId: 2001,
|
||||
clientName: order.userName || '客户老板',
|
||||
await claimOrder(order.id, {
|
||||
expertId: expert.value.id || 2006,
|
||||
companionName: expert.value.nickname || '接单大神',
|
||||
expertAvatar: expert.value.avatar || DEFAULT_EXPERT_AVATAR,
|
||||
track: order.category || '同城生活',
|
||||
serviceTitle: order.title,
|
||||
hourlyRate: order.reward,
|
||||
durationHours: 2,
|
||||
amount: order.reward,
|
||||
location: order.location,
|
||||
expenseBurden: order.payMethod,
|
||||
status: '履约中'
|
||||
expertAvatar: expert.value.avatar || DEFAULT_EXPERT_AVATAR
|
||||
});
|
||||
} catch (err) {
|
||||
uni.hideLoading();
|
||||
@@ -802,6 +747,29 @@ const copyOrderNo = () => {
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
|
||||
.hall-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
padding: 60rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1rpx dashed rgba(255, 255, 255, 0.12);
|
||||
|
||||
.hall-empty-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
.hall-empty-desc {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
}
|
||||
|
||||
.dispatch-card {
|
||||
background: #151D30;
|
||||
border-radius: 24rpx;
|
||||
|
||||
@@ -125,6 +125,10 @@
|
||||
|
||||
<!-- 订单卡片流 -->
|
||||
<view class="orders-stream">
|
||||
<view v-if="nearbyOrders.length === 0" class="pool-empty">
|
||||
<text class="pool-empty-title">附近 {{ currentRadius }}km 暂无待接需求单</text>
|
||||
<text class="pool-empty-desc">调整巡航半径或点击右上角刷新试试</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in nearbyOrders"
|
||||
:key="item.id"
|
||||
@@ -185,7 +189,8 @@ import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
import { isExpertCertified } from '@/common/role.js';
|
||||
import { getNavBarInfo } from '@/common/nav.js';
|
||||
import { getOrdersPool, claimOrder, createOrder } from '@/common/api.js';
|
||||
import { getOrdersPool, claimOrder, createOrder, reverseGeocode } from '@/common/api.js';
|
||||
import { getUserLocation } from '@/common/location.js';
|
||||
|
||||
const navInfo = ref(getNavBarInfo());
|
||||
const navStyle = computed(() => ({
|
||||
@@ -203,9 +208,12 @@ const currentRadius = ref(5);
|
||||
const rangeOptions = [1, 3, 5];
|
||||
const selectedOrderId = ref(1);
|
||||
|
||||
const myCoords = ref({ lat: null, lng: null });
|
||||
const locating = ref(false);
|
||||
|
||||
const currentLocation = ref({
|
||||
name: '朝阳区 · 三里屯太古里北区',
|
||||
address: '北京市朝阳区三里屯路19号院 (精度 ±5m)'
|
||||
name: '正在定位…',
|
||||
address: '正在获取当前位置'
|
||||
});
|
||||
|
||||
const onThemeChanged = (newTheme) => {
|
||||
@@ -213,63 +221,98 @@ const onThemeChanged = (newTheme) => {
|
||||
applyThemeToNativeUI(newTheme);
|
||||
};
|
||||
|
||||
const markerPosition = (distanceKm, idx) => {
|
||||
const radius = Math.max(1, Number(currentRadius.value) || 5);
|
||||
const ratio = Number.isFinite(Number(distanceKm))
|
||||
? Math.min(0.92, Math.max(0.16, Number(distanceKm) / radius))
|
||||
: 0.5;
|
||||
const angle = (Math.PI * 2 * idx) / 5 - Math.PI / 2;
|
||||
const top = 50 + Math.sin(angle) * ratio * 40;
|
||||
const left = 50 + Math.cos(angle) * ratio * 40;
|
||||
return { top: `${top.toFixed(1)}%`, left: `${left.toFixed(1)}%` };
|
||||
};
|
||||
|
||||
const loadNearbyOrdersFromPool = async () => {
|
||||
try {
|
||||
const pool = await getOrdersPool();
|
||||
if (pool && pool.length > 0) {
|
||||
const coords = [
|
||||
{ top: '28%', left: '22%', dist: '650m' },
|
||||
{ top: '32%', left: '72%', dist: '1.4km' },
|
||||
{ top: '68%', left: '30%', dist: '2.2km' },
|
||||
{ top: '65%', left: '75%', dist: '3.8km' },
|
||||
{ top: '45%', left: '50%', dist: '900m' }
|
||||
];
|
||||
nearbyOrders.value = pool.slice(0, 5).map((p, idx) => {
|
||||
let typeClass = 'type-food';
|
||||
let typeIcon = ICONS.utensils;
|
||||
if (p.category.includes('喝') || p.category.includes('咖啡')) {
|
||||
typeClass = 'type-coffee';
|
||||
typeIcon = ICONS.coffee;
|
||||
} else if (p.category.includes('唱') || p.category.includes('KTV')) {
|
||||
typeClass = 'type-ktv';
|
||||
typeIcon = ICONS.micVocal;
|
||||
} else if (p.category.includes('游戏') || p.category.includes('电竞')) {
|
||||
typeClass = 'type-game';
|
||||
typeIcon = ICONS.gameAll;
|
||||
}
|
||||
const coord = coords[idx % coords.length];
|
||||
return {
|
||||
id: p.id,
|
||||
categoryName: p.category,
|
||||
title: p.title,
|
||||
requirement: p.requirement || '按约定准时到场,现场出示4位核销码,文明健康社交。',
|
||||
reward: p.reward,
|
||||
duration: '2小时',
|
||||
distance: coord.dist,
|
||||
timeAgo: '刚刚',
|
||||
buyerName: p.userName || '客户老板',
|
||||
buyerAvatar: p.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
|
||||
typeClass,
|
||||
typeIcon,
|
||||
markerTop: coord.top,
|
||||
markerLeft: coord.left
|
||||
};
|
||||
});
|
||||
if (nearbyOrders.value.length > 0) {
|
||||
selectedOrderId.value = nearbyOrders.value[0].id;
|
||||
const pool = await getOrdersPool({
|
||||
lat: myCoords.value.lat,
|
||||
lng: myCoords.value.lng,
|
||||
radius: currentRadius.value
|
||||
});
|
||||
nearbyOrders.value = (pool || []).slice(0, 5).map((p, idx) => {
|
||||
let typeClass = 'type-food';
|
||||
let typeIcon = ICONS.utensils;
|
||||
if (String(p.category || '').includes('喝') || String(p.category || '').includes('咖啡')) {
|
||||
typeClass = 'type-coffee';
|
||||
typeIcon = ICONS.coffee;
|
||||
} else if (String(p.category || '').includes('唱') || String(p.category || '').includes('KTV')) {
|
||||
typeClass = 'type-ktv';
|
||||
typeIcon = ICONS.micVocal;
|
||||
} else if (String(p.category || '').includes('游戏') || String(p.category || '').includes('电竞')) {
|
||||
typeClass = 'type-game';
|
||||
typeIcon = ICONS.gameAll;
|
||||
}
|
||||
const pos = markerPosition(p.distanceKm, idx);
|
||||
return {
|
||||
id: p.id,
|
||||
categoryName: p.category,
|
||||
title: p.title,
|
||||
requirement: p.requirement || '按约定准时到场,现场出示4位核销码,文明健康社交。',
|
||||
reward: p.reward,
|
||||
duration: '2小时',
|
||||
distance: p.distanceText || '同城',
|
||||
distanceKm: p.distanceKm,
|
||||
timeAgo: '刚刚',
|
||||
buyerName: p.userName || '客户老板',
|
||||
buyerAvatar: p.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
|
||||
typeClass,
|
||||
typeIcon,
|
||||
markerTop: pos.top,
|
||||
markerLeft: pos.left
|
||||
};
|
||||
});
|
||||
if (nearbyOrders.value.length > 0) {
|
||||
selectedOrderId.value = nearbyOrders.value[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Radar] 加载雷达单池异常:', err);
|
||||
uni.showToast({ title: '获取附近需求单失败,请稍后重试', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLocation = async () => {
|
||||
if (locating.value) return;
|
||||
locating.value = true;
|
||||
try {
|
||||
const pos = await getUserLocation();
|
||||
myCoords.value = { lat: pos.latitude, lng: pos.longitude };
|
||||
const geo = await reverseGeocode(pos.latitude, pos.longitude);
|
||||
if (geo && geo.name) {
|
||||
currentLocation.value = { name: geo.name, address: geo.address || '当前位置' };
|
||||
} else {
|
||||
currentLocation.value = {
|
||||
name: '当前位置',
|
||||
address: `经纬度 ${Number(pos.latitude).toFixed(4)}, ${Number(pos.longitude).toFixed(4)}`
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
myCoords.value = { lat: null, lng: null };
|
||||
currentLocation.value = {
|
||||
name: '未开启定位',
|
||||
address: '开启定位权限后可查看附近需求单的真实距离'
|
||||
};
|
||||
uni.showToast({ title: '未获取到定位,距离将显示为同城', icon: 'none' });
|
||||
} finally {
|
||||
locating.value = false;
|
||||
}
|
||||
await loadNearbyOrdersFromPool();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
navInfo.value = getNavBarInfo();
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
uni.$on('theme-change', onThemeChanged);
|
||||
loadNearbyOrdersFromPool();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
@@ -288,7 +331,7 @@ onShow(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
uni.hideTabBar({ animation: false, fail: () => {} });
|
||||
loadNearbyOrdersFromPool();
|
||||
refreshLocation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -296,90 +339,27 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
|
||||
const nearbyOrders = ref([
|
||||
{
|
||||
id: 1,
|
||||
categoryName: '同城美食陪吃',
|
||||
title: '三里屯太古里 · 铜锅涮肉AA搭子',
|
||||
requirement: '吃货随和好相处,现场出示4位核销码,餐费已预订AA',
|
||||
reward: 98,
|
||||
duration: '2小时',
|
||||
distance: '650m',
|
||||
timeAgo: '5分钟前',
|
||||
buyerName: '北京吃货小王',
|
||||
buyerAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
|
||||
typeClass: 'type-food',
|
||||
typeIcon: ICONS.utensils,
|
||||
markerTop: '28%',
|
||||
markerLeft: '22%'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
categoryName: '同城下午茶陪喝',
|
||||
title: '工体北路 · 独立手冲咖啡闲聊倾听',
|
||||
requirement: '安静舒适咖啡馆,主要是倾听工作解压,老板全买单请客',
|
||||
reward: 120,
|
||||
duration: '2小时',
|
||||
distance: '1.4km',
|
||||
timeAgo: '12分钟前',
|
||||
buyerName: '晴天小筑',
|
||||
buyerAvatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120',
|
||||
typeClass: 'type-coffee',
|
||||
typeIcon: ICONS.coffee,
|
||||
markerTop: '32%',
|
||||
markerLeft: '72%'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
categoryName: '同城KTV陪唱',
|
||||
title: '工体量贩纯K · 麦霸对唱与气氛组',
|
||||
requirement: '会唱周杰伦/陈奕迅经典情歌,气氛欢快,拒绝私密',
|
||||
reward: 180,
|
||||
duration: '2.5小时',
|
||||
distance: '2.2km',
|
||||
timeAgo: '18分钟前',
|
||||
buyerName: '欢唱麦霸哥',
|
||||
buyerAvatar: 'https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?w=120',
|
||||
typeClass: 'type-ktv',
|
||||
typeIcon: ICONS.micVocal,
|
||||
markerTop: '68%',
|
||||
markerLeft: '30%'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
categoryName: '线上王者冲分',
|
||||
title: '王者荣耀 · 荣耀王者晋级双排急召',
|
||||
requirement: '擅长打野/射手节奏位,全程语音沟通,保星连胜',
|
||||
reward: 70,
|
||||
duration: '2局',
|
||||
distance: '3.8km',
|
||||
timeAgo: '25分钟前',
|
||||
buyerName: '摘星少年',
|
||||
buyerAvatar: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=120',
|
||||
typeClass: 'type-game',
|
||||
typeIcon: ICONS.gameAll,
|
||||
markerTop: '65%',
|
||||
markerLeft: '75%'
|
||||
}
|
||||
]);
|
||||
const nearbyOrders = ref([]);
|
||||
|
||||
const changeRadius = (r) => {
|
||||
const changeRadius = async (r) => {
|
||||
currentRadius.value = r;
|
||||
uni.showToast({
|
||||
title: `已更新探测范围至周边 ${r} 公里`,
|
||||
icon: 'none'
|
||||
});
|
||||
await loadNearbyOrdersFromPool();
|
||||
};
|
||||
|
||||
const handleRelocate = () => {
|
||||
uni.showLoading({ title: '正在校准GPS...' });
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: 'GPS定位已校准 (精度 ±3m)',
|
||||
icon: 'success'
|
||||
});
|
||||
}, 400);
|
||||
const handleRelocate = async () => {
|
||||
uni.showLoading({ title: '正在重新定位...' });
|
||||
try {
|
||||
await refreshLocation();
|
||||
} catch (_) {}
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: myCoords.value.lat ? '定位已更新' : '定位失败,请检查定位权限',
|
||||
icon: myCoords.value.lat ? 'success' : 'none'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRadarSwitch = (e) => {
|
||||
@@ -394,12 +374,14 @@ const selectMarkerOrder = (id) => {
|
||||
selectedOrderId.value = id;
|
||||
};
|
||||
|
||||
const refreshNearbyOrders = () => {
|
||||
const refreshNearbyOrders = async () => {
|
||||
uni.showLoading({ title: '雷达刷新中...' });
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '已匹配最新 5km 派单池', icon: 'success' });
|
||||
}, 500);
|
||||
await refreshLocation();
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: `已匹配 ${currentRadius.value}km 内最新派单池`,
|
||||
icon: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
const handleTakeOrder = (order) => {
|
||||
@@ -938,6 +920,29 @@ const handleTakeOrder = (order) => {
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
|
||||
.pool-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
padding: 60rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1rpx dashed rgba(255, 255, 255, 0.12);
|
||||
|
||||
.pool-empty-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
.pool-empty-desc {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
}
|
||||
|
||||
.nearby-card {
|
||||
background: #151D30;
|
||||
border-radius: 24rpx;
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<view class="remark-row">
|
||||
<text class="cell-label">碰面场所</text>
|
||||
<input class="remark-input" v-model="meetVenue" placeholder="正规场所,如:工体纯K量贩KTV/朝阳大悦城" placeholder-class="ph" />
|
||||
<view class="pick-loc-btn btn-active" @click="pickVenue">地图选点</view>
|
||||
</view>
|
||||
<view class="expense-row">
|
||||
<text class="cell-label">现场消费</text>
|
||||
@@ -326,6 +327,28 @@ const timeType = ref('NOW');
|
||||
const discount = ref(0);
|
||||
const remark = ref('');
|
||||
const meetVenue = ref('朝阳大悦城海底捞');
|
||||
const meetCoords = ref({ latitude: null, longitude: null });
|
||||
|
||||
const pickVenue = () => {
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
uni.chooseLocation({
|
||||
success: (res) => {
|
||||
if (!res) return;
|
||||
const name = res.name ? `${res.address ? res.address + ' ' : ''}${res.name}` : res.address;
|
||||
if (name) meetVenue.value = name;
|
||||
meetCoords.value = {
|
||||
latitude: Number.isFinite(Number(res.latitude)) ? Number(res.latitude) : null,
|
||||
longitude: Number.isFinite(Number(res.longitude)) ? Number(res.longitude) : null
|
||||
};
|
||||
uni.showToast({ title: '已选择碰面地点', icon: 'success' });
|
||||
},
|
||||
fail: () => uni.showToast({ title: '未选择地点,可手动填写', icon: 'none' })
|
||||
});
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN || APP-PLUS
|
||||
uni.showToast({ title: '当前环境不支持地图选点,请手动填写', icon: 'none' });
|
||||
// #endif
|
||||
};
|
||||
const expenseBurden = ref('AA');
|
||||
|
||||
const isOffline = computed(() => {
|
||||
@@ -518,6 +541,8 @@ const handlePay = async () => {
|
||||
userCouponId: usingUserCoupon ? String(currentCoupon.value.id) : '',
|
||||
payType: payMethod.value,
|
||||
location: isOffline.value ? meetVenue.value : (remark.value || '线上语音开黑'),
|
||||
latitude: isOffline.value ? meetCoords.value.latitude : null,
|
||||
longitude: isOffline.value ? meetCoords.value.longitude : null,
|
||||
expenseBurden: expenseBurden.value === 'AA' ? 'AA制各自付' : (expenseBurden.value === 'CLIENT' ? '客户全包' : '大神自理'),
|
||||
remark: remark.value
|
||||
};
|
||||
@@ -829,6 +854,15 @@ const handlePay = async () => {
|
||||
.ph {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pick-loc-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12rpx;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.expense-row {
|
||||
|
||||
@@ -27,6 +27,8 @@ export async function initDatabase(): Promise<void> {
|
||||
title VARCHAR(255) NOT NULL,
|
||||
game_category VARCHAR(64) NOT NULL,
|
||||
location VARCHAR(255),
|
||||
latitude DECIMAL(10,6) DEFAULT NULL,
|
||||
longitude DECIMAL(10,6) DEFAULT NULL,
|
||||
start_time VARCHAR(64) DEFAULT '',
|
||||
pay_type INT NOT NULL DEFAULT 1,
|
||||
total_fee_cents INT DEFAULT 0,
|
||||
@@ -45,6 +47,17 @@ export async function initDatabase(): Promise<void> {
|
||||
// 字段已存在忽略
|
||||
}
|
||||
|
||||
try {
|
||||
await conn.query(`ALTER TABLE activities ADD COLUMN latitude DECIMAL(10,6) DEFAULT NULL AFTER location`);
|
||||
} catch (_) {
|
||||
// 字段已存在忽略
|
||||
}
|
||||
try {
|
||||
await conn.query(`ALTER TABLE activities ADD COLUMN longitude DECIMAL(10,6) DEFAULT NULL AFTER latitude`);
|
||||
} catch (_) {
|
||||
// 字段已存在忽略
|
||||
}
|
||||
|
||||
try {
|
||||
await conn.query(`ALTER TABLE activities ADD COLUMN status VARCHAR(32) DEFAULT 'RECRUITING' AFTER price_adjust_rule`);
|
||||
} catch (_) {
|
||||
@@ -137,6 +150,8 @@ export async function initDatabase(): Promise<void> {
|
||||
duration_hours INT NOT NULL DEFAULT 2,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
location VARCHAR(255),
|
||||
latitude DECIMAL(10,6) DEFAULT NULL,
|
||||
longitude DECIMAL(10,6) DEFAULT NULL,
|
||||
verify_code VARCHAR(8),
|
||||
remark VARCHAR(255),
|
||||
status VARCHAR(32) NOT NULL DEFAULT '待支付',
|
||||
@@ -151,6 +166,8 @@ export async function initDatabase(): Promise<void> {
|
||||
"ALTER TABLE orders ADD COLUMN expert_avatar TEXT AFTER companion_name",
|
||||
"ALTER TABLE orders ADD COLUMN service_title VARCHAR(255) DEFAULT '同城陪伴服务' AFTER track",
|
||||
"ALTER TABLE orders ADD COLUMN verify_code VARCHAR(8) AFTER location",
|
||||
"ALTER TABLE orders ADD COLUMN latitude DECIMAL(10,6) DEFAULT NULL AFTER location",
|
||||
"ALTER TABLE orders ADD COLUMN longitude DECIMAL(10,6) DEFAULT NULL AFTER latitude",
|
||||
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code",
|
||||
"ALTER TABLE orders ADD COLUMN pay_type VARCHAR(32) DEFAULT 'WECHAT' AFTER status",
|
||||
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount",
|
||||
|
||||
@@ -9,6 +9,32 @@ function logServerError(scope: string, err: any): void {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/** 坐标归一化:非法/越界值一律存 NULL,避免脏数据影响距离计算 */
|
||||
function normalizeCoord(v: any, limit: number): number | null {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || Math.abs(n) > limit) return null;
|
||||
return Number(n.toFixed(6));
|
||||
}
|
||||
|
||||
/** 球面距离(千米,Haversine) */
|
||||
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/** 距离文案:<1km 显示米(取整到 10m),否则显示一位小数 km */
|
||||
export function formatDistanceText(km: number): string {
|
||||
if (!Number.isFinite(km) || km < 0) return '同城';
|
||||
if (km < 1) return `${Math.max(10, Math.round((km * 1000) / 10) * 10)}m`;
|
||||
return `${km.toFixed(1)}km`;
|
||||
}
|
||||
|
||||
export interface ActivityRow {
|
||||
id: string;
|
||||
creatorId: number;
|
||||
@@ -119,6 +145,8 @@ export class MysqlRepository {
|
||||
title: string;
|
||||
gameCategory: string;
|
||||
location?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
startTime?: string;
|
||||
payType?: number;
|
||||
totalFeeCents?: number;
|
||||
@@ -155,14 +183,16 @@ export class MysqlRepository {
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO activities
|
||||
(id, creator_id, title, game_category, location, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, creator_id, title, game_category, location, latitude, longitude, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
data.creatorId,
|
||||
data.title,
|
||||
data.gameCategory,
|
||||
data.location || '',
|
||||
normalizeCoord(data.latitude, 90),
|
||||
normalizeCoord(data.longitude, 180),
|
||||
data.startTime || '',
|
||||
payType,
|
||||
totalFeeCents,
|
||||
@@ -1411,6 +1441,8 @@ export class MysqlRepository {
|
||||
userCouponId?: string;
|
||||
payType?: string;
|
||||
location?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
expenseBurden?: string;
|
||||
remark?: string;
|
||||
status?: string;
|
||||
@@ -1475,6 +1507,8 @@ export class MysqlRepository {
|
||||
const amount = Number(Math.max(baseAmount - couponDiscount, 0.01).toFixed(2));
|
||||
const payType = data.payType === 'BALANCE' ? 'BALANCE' : 'WECHAT';
|
||||
const location = data.location || '朝阳大悦城';
|
||||
const orderLat = normalizeCoord(data.latitude, 90);
|
||||
const orderLng = normalizeCoord(data.longitude, 180);
|
||||
const expenseBurden = data.expenseBurden || 'AA制各自付';
|
||||
const verifyCode = String(Math.floor(1000 + Math.random() * 9000));
|
||||
const remark = data.remark || '';
|
||||
@@ -1513,14 +1547,14 @@ export class MysqlRepository {
|
||||
companion_name, expert_avatar, track, service_title,
|
||||
expense_burden, hourly_rate, duration_hours, amount,
|
||||
coupon_discount, user_coupon_id, pay_type,
|
||||
location, verify_code, remark, status, paid_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
location, latitude, longitude, verify_code, remark, status, paid_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
`, [
|
||||
id, buyerId, expertId, orderNo, clientName,
|
||||
companionName, expertAvatar, track, serviceTitle,
|
||||
expenseBurden, hourlyRate, durationHours, amount,
|
||||
couponDiscount, userCouponId || null, payType,
|
||||
location, verifyCode, remark, status
|
||||
location, orderLat, orderLng, verifyCode, remark, status
|
||||
]);
|
||||
|
||||
// 占用"我的券"(与订单同事务,避免下单成功但券未核销导致重复使用)
|
||||
@@ -1923,19 +1957,19 @@ export class MysqlRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async getOrdersPool(): Promise<any[]> {
|
||||
async getOrdersPool(opts: { fromLat?: number | null; fromLng?: number | null; radiusKm?: number | null } = {}): Promise<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
id, order_no as orderNo, client_name as clientName,
|
||||
track as category, service_title as serviceTitle,
|
||||
hourly_rate as hourlyRate, duration_hours as durationHours,
|
||||
amount, location, expense_burden as payMethod,
|
||||
amount, location, latitude, longitude, expense_burden as payMethod,
|
||||
status, created_at as createdAt
|
||||
FROM orders
|
||||
WHERE status = '待接单'
|
||||
AND (claimed_by IS NULL OR claimed_by = 0)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
LIMIT 50
|
||||
`);
|
||||
|
||||
// 默认池保障
|
||||
@@ -1945,21 +1979,61 @@ export class MysqlRepository {
|
||||
if (s.length <= 1) return `${s}**`;
|
||||
return `${s[0]}**`;
|
||||
};
|
||||
const poolList = (rows || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
category: r.category || '同城生活',
|
||||
title: r.serviceTitle || `${r.category} · 专业同城陪伴`,
|
||||
location: r.location || '北京市朝阳区商圈',
|
||||
reward: Number(r.amount) || 120,
|
||||
timeSlot: '今日内随时可约',
|
||||
userName: maskClientName(r.clientName),
|
||||
userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
|
||||
payMethod: r.payMethod || 'AA制各付',
|
||||
countdown: 45,
|
||||
status: r.status,
|
||||
}));
|
||||
|
||||
return poolList;
|
||||
const fromLat = Number(opts.fromLat);
|
||||
const fromLng = Number(opts.fromLng);
|
||||
const hasFrom = Number.isFinite(fromLat) && Number.isFinite(fromLng) && Math.abs(fromLat) <= 90 && Math.abs(fromLng) <= 180;
|
||||
const radius = Number(opts.radiusKm);
|
||||
const hasRadius = hasFrom && Number.isFinite(radius) && radius > 0;
|
||||
|
||||
let poolList = (rows || []).map((r: any) => {
|
||||
const lat = r.latitude === null || r.latitude === undefined ? null : Number(r.latitude);
|
||||
const lng = r.longitude === null || r.longitude === undefined ? null : Number(r.longitude);
|
||||
const hasGeo = lat !== null && lng !== null && Number.isFinite(lat) && Number.isFinite(lng);
|
||||
const distanceKm = hasFrom && hasGeo ? haversineKm(fromLat, fromLng, lat, lng) : null;
|
||||
return {
|
||||
id: r.id,
|
||||
category: r.category || '同城生活',
|
||||
title: r.serviceTitle || `${r.category} · 专业同城陪伴`,
|
||||
location: r.location || '北京市朝阳区商圈',
|
||||
latitude: hasGeo ? lat : null,
|
||||
longitude: hasGeo ? lng : null,
|
||||
hasGeo,
|
||||
distanceKm: distanceKm === null ? null : Number(distanceKm.toFixed(3)),
|
||||
distanceText: distanceKm === null ? '同城' : formatDistanceText(distanceKm),
|
||||
reward: Number(r.amount) || 120,
|
||||
timeSlot: '今日内随时可约',
|
||||
userName: maskClientName(r.clientName),
|
||||
userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
|
||||
payMethod: r.payMethod || 'AA制各付',
|
||||
countdown: 45,
|
||||
status: r.status,
|
||||
};
|
||||
});
|
||||
|
||||
// 有定位且有半径:仅保留半径内(无坐标的单无法判断距离,一并排除)
|
||||
if (hasRadius) {
|
||||
poolList = poolList.filter((o: any) => o.distanceKm !== null && o.distanceKm <= radius);
|
||||
}
|
||||
// 有定位:按距离升序
|
||||
if (hasFrom) {
|
||||
poolList.sort((a: any, b: any) => {
|
||||
if (a.distanceKm === null && b.distanceKm === null) return 0;
|
||||
if (a.distanceKm === null) return 1;
|
||||
if (b.distanceKm === null) return -1;
|
||||
return a.distanceKm - b.distanceKm;
|
||||
});
|
||||
}
|
||||
|
||||
return poolList.slice(0, 20);
|
||||
}
|
||||
|
||||
/** 地理编码兜底结果回写(避免重复解析同一地址) */
|
||||
async updateOrderGeo(id: string, latitude: number, longitude: number): Promise<void> {
|
||||
const lat = normalizeCoord(latitude, 90);
|
||||
const lng = normalizeCoord(longitude, 180);
|
||||
if (lat === null || lng === null) return;
|
||||
await pool.query('UPDATE orders SET latitude = ?, longitude = ? WHERE id = ? AND (latitude IS NULL OR longitude IS NULL)', [lat, lng, id]);
|
||||
}
|
||||
|
||||
async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise<any> {
|
||||
|
||||
@@ -489,6 +489,99 @@ function assertCompanionActor(
|
||||
throw e;
|
||||
}
|
||||
|
||||
// ===== 腾讯位置服务(仅服务端调用,Key 绝不下发小程序)=====
|
||||
let mapKeyCache: { key: string; at: number } | null = null;
|
||||
|
||||
async function resolveTencentMapKey(): Promise<string> {
|
||||
const envKey = String(process.env.TENCENT_MAP_KEY || '').trim();
|
||||
if (envKey) return envKey;
|
||||
const now = Date.now();
|
||||
if (mapKeyCache && now - mapKeyCache.at < 60_000) return mapKeyCache.key;
|
||||
let key = '';
|
||||
try {
|
||||
const record = await repo.getPlatformConfigByKey('map_location_config');
|
||||
const value = (record && record.valueJson) || {};
|
||||
key = String(value.mapKey || '').trim();
|
||||
} catch (e) {
|
||||
logServerError('resolveTencentMapKey', e);
|
||||
}
|
||||
mapKeyCache = { key, at: now };
|
||||
return key;
|
||||
}
|
||||
|
||||
function httpsGetJson(url: string, timeoutMs = 4000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (resp) => {
|
||||
let data = '';
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error('腾讯位置服务请求超时')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const geocodeCache = new Map<string, { geo: { lat: number; lng: number } | null; at: number }>();
|
||||
|
||||
/** 地址 -> 坐标(腾讯 WebService 地理编码),带进程内缓存 */
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
const text = String(address || '').trim();
|
||||
if (!text) return null;
|
||||
const cached = geocodeCache.get(text);
|
||||
if (cached && Date.now() - cached.at < 10 * 60_000) return cached.geo;
|
||||
const key = await resolveTencentMapKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?address=${encodeURIComponent(text)}&key=${encodeURIComponent(key)}`;
|
||||
const data = await httpsGetJson(url);
|
||||
if (data && data.status === 0 && data.result && data.result.location) {
|
||||
const geo = { lat: Number(data.result.location.lat), lng: Number(data.result.location.lng) };
|
||||
if (Number.isFinite(geo.lat) && Number.isFinite(geo.lng)) {
|
||||
geocodeCache.set(text, { geo, at: Date.now() });
|
||||
return geo;
|
||||
}
|
||||
}
|
||||
logServerError('geocodeAddress', new Error(`腾讯地理编码失败: ${data && (data.message || data.status)}`));
|
||||
geocodeCache.set(text, { geo: null, at: Date.now() });
|
||||
} catch (e) {
|
||||
logServerError('geocodeAddress', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 坐标 -> 行政区/地址(腾讯 WebService 逆地址解析) */
|
||||
async function reverseGeocode(lat: number, lng: number): Promise<any | null> {
|
||||
const key = await resolveTencentMapKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${encodeURIComponent(key)}&get_poi=0`;
|
||||
const data = await httpsGetJson(url);
|
||||
if (data && data.status === 0 && data.result) {
|
||||
const r = data.result;
|
||||
const comp = r.address_component || {};
|
||||
const city = comp.city || '';
|
||||
const district = comp.district || '';
|
||||
return {
|
||||
name: [city, district].filter(Boolean).join(' · ') || r.address || '当前位置',
|
||||
address: r.address || '',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
logServerError('reverseGeocode', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 管理端:吊销所有后台登录态(版本号 +1,所有已签发 admin token 立即失效)
|
||||
app.post('/api/admin/session/revoke-all', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -1381,15 +1474,54 @@ app.get('/api/orders', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// 获取即时可抢订单池 (落盘 MySQL)
|
||||
app.get('/api/orders/pool', async (_req: Request, res: Response) => {
|
||||
app.get('/api/orders/pool', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const list = await repo.getOrdersPool();
|
||||
const lat = Number(req.query.lat);
|
||||
const lng = Number(req.query.lng);
|
||||
const radius = Number(req.query.radius);
|
||||
const hasFrom = Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 && !(lat === 0 && lng === 0);
|
||||
|
||||
// 先取不带半径的全量池:为历史无坐标订单做地理编码兜底(每轮最多 5 条,结果回写入库)
|
||||
let pre = await repo.getOrdersPool(hasFrom ? { fromLat: lat, fromLng: lng } : {});
|
||||
if (hasFrom) {
|
||||
const missing = pre.filter((o: any) => !o.hasGeo && o.location).slice(0, 5);
|
||||
let changed = false;
|
||||
for (const o of missing) {
|
||||
const geo = await geocodeAddress(o.location);
|
||||
if (geo) {
|
||||
await repo.updateOrderGeo(o.id, geo.lat, geo.lng);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) pre = await repo.getOrdersPool({ fromLat: lat, fromLng: lng });
|
||||
}
|
||||
|
||||
const list = hasFrom && Number.isFinite(radius) && radius > 0
|
||||
? pre.filter((o: any) => o.distanceKm !== null && o.distanceKm <= radius)
|
||||
: pre;
|
||||
res.json({ code: 20000, data: list });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 坐标反查行政区/地址(雷达/接单大厅顶部定位文案;仅登录用户可调用,避免刷额度)
|
||||
app.get('/api/lbs/reverse', rateLimit({ name: 'lbs_reverse', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (SECURE_MODE) resolveSecureUserId(req, Number(req.query.userId || 0), '解析当前位置');
|
||||
const lat = Number(req.query.lat);
|
||||
const lng = Number(req.query.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) {
|
||||
return res.status(400).json({ code: 40000, message: '经纬度参数不合法' });
|
||||
}
|
||||
const data = await reverseGeocode(lat, lng);
|
||||
res.json({ code: 20000, data });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 500;
|
||||
res.status(statusCode).json({ code: err.code || 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 大神抢单/接单认领 (落盘 MySQL)
|
||||
app.post('/api/orders/:id/claim', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第九十四批:真实距离(LBS)落地闸门。
|
||||
* 背景:雷达页/接单大厅的「650m / 1.4km / 1.2km内」全部是写死演示值,
|
||||
* orders/activities 也没有经纬度字段,导致「按距离派单」实际不生效。
|
||||
*/
|
||||
describe('第九十四批真实距离与 LBS 闸门', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||
|
||||
const mysql = read('server/src/db/mysql.ts');
|
||||
const repo = read('server/src/db/repository.ts');
|
||||
const indexCode = read('server/src/index.ts');
|
||||
const apiCode = read('pwuniapp/common/api.js');
|
||||
const radar = read('pwuniapp/pages/expert/radar.vue');
|
||||
const hall = read('pwuniapp/pages/expert/hall.vue');
|
||||
const orderCreate = read('pwuniapp/pages/order/create.vue');
|
||||
const activityCreate = read('pwuniapp/pages/activity/create.vue');
|
||||
|
||||
it('订单与组局表必须存储经纬度', () => {
|
||||
expect(mysql).toContain('latitude DECIMAL(10,6)');
|
||||
expect(mysql).toContain('longitude DECIMAL(10,6)');
|
||||
expect(mysql).toContain('ALTER TABLE activities ADD COLUMN latitude');
|
||||
expect(mysql).toContain('ALTER TABLE orders ADD COLUMN latitude');
|
||||
});
|
||||
|
||||
it('服务端按 Haversine 计算距离并返回距离文案', () => {
|
||||
expect(repo).toContain('export function haversineKm');
|
||||
expect(repo).toContain('export function formatDistanceText');
|
||||
expect(repo).toContain('distanceText');
|
||||
expect(repo).toContain('radiusKm');
|
||||
expect(repo).toContain('async updateOrderGeo');
|
||||
});
|
||||
|
||||
it('派单池接口接收定位并按半径过滤,历史地址走腾讯地理编码兜底', () => {
|
||||
const start = indexCode.indexOf("app.get('/api/orders/pool'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 2200);
|
||||
expect(block).toContain('req.query.lat');
|
||||
expect(block).toContain('req.query.radius');
|
||||
expect(block).toContain('geocodeAddress');
|
||||
expect(block).toContain('repo.updateOrderGeo');
|
||||
});
|
||||
|
||||
it('逆地址解析接口仅服务端持有 Key,并要求登录', () => {
|
||||
expect(indexCode).toContain("app.get('/api/lbs/reverse'");
|
||||
expect(indexCode).toContain('resolveTencentMapKey');
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(req.query.userId || 0), '解析当前位置')");
|
||||
// Key 不允许出现在小程序端
|
||||
expect(apiCode).not.toContain('7I4BZ');
|
||||
});
|
||||
|
||||
it('小程序雷达与接单大厅改用真实距离,删除写死演示距离', () => {
|
||||
expect(radar).toContain('getUserLocation');
|
||||
expect(radar).toContain('p.distanceText');
|
||||
expect(radar).not.toContain("distance: '650m'");
|
||||
expect(radar).not.toContain("dist: '1.4km'");
|
||||
|
||||
expect(hall).toContain('getUserLocation');
|
||||
expect(hall).toContain('p.distanceText');
|
||||
expect(hall).not.toContain("'1.2km内'");
|
||||
expect(hall).not.toContain("'1.1km'");
|
||||
});
|
||||
|
||||
it('下单/组局在选点后提交经纬度', () => {
|
||||
expect(orderCreate).toContain('pickVenue');
|
||||
expect(orderCreate).toContain('uni.chooseLocation');
|
||||
expect(orderCreate).toContain('latitude: isOffline.value ? meetCoords.value.latitude : null');
|
||||
expect(activityCreate).toContain('form.value.latitude');
|
||||
expect(apiCode).toContain('latitude: payload.latitude');
|
||||
});
|
||||
|
||||
it('小程序的派单池调用支持携带定位与半径', () => {
|
||||
expect(apiCode).toContain('export async function getOrdersPool(options = {})');
|
||||
expect(apiCode).toContain('qs.push(`lat=${lat}`, `lng=${lng}`)');
|
||||
expect(apiCode).toContain('radius=${radius}');
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,11 @@ describe('第二十九批抢单池过滤闸门', () => {
|
||||
it('抢单池必须排除终态/已核销订单并过滤已认领订单', () => {
|
||||
const start = repoCode.indexOf('async getOrdersPool(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = repoCode.slice(start, start + 1400);
|
||||
const block = repoCode.slice(start, start + 5200);
|
||||
expect(block).toContain("WHERE status = '待接单'");
|
||||
expect(block).toContain('claimed_by IS NULL');
|
||||
expect(block).toContain('LIMIT 20');
|
||||
// SQL 多取候选,最终按距离排序后截断 20 条
|
||||
expect(block).toContain('LIMIT 50');
|
||||
expect(block).toContain('slice(0, 20)');
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户