feat(order): 平台派单闭环(发需求单进入待接单池)

- 下单页新增「指定大神 / 平台派单」模式:平台派单填品类、预算、地点/选点,expertId=0

- 服务端:平台派单单据支付后进入待接单池(余额支付即时入池,微信支付确认后入池),预算由买家出价但受 1~2000 元/小时约束

- 抢单由认领大神写入 expert_id 并转履约中;30 分钟无人接单沿用自动全额退款

- 修复订单 INSERT 占位符少一个导致下单报 Column count doesn't match 的问题,并新增列数/占位符一致性护栏

- 111 套件/737 用例全绿;线上 1 元实测:下单→待接单→池内 640m,测试数据已回滚
这个提交包含在:
xcbyy
2026-10-21 15:16:03 +08:00
父节点 5538d8ce7e
当前提交 77aa4cb50b
共有 6 个文件被更改,包括 257 次插入 和 32 次删除

查看文件

@@ -1,7 +1,21 @@
<template>
<view class="create-order-container" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
<!-- 下单模式切换:指定大神 / 平台派单 -->
<view class="order-card mode-card">
<view class="mode-tabs">
<view class="mode-tab btn-active" :class="{ active: orderMode === 'EXPERT' }" @click="switchMode('EXPERT')">
<text class="mode-title">指定大神</text>
<text class="mode-desc">按大神定价直接下单</text>
</view>
<view class="mode-tab btn-active" :class="{ active: orderMode === 'PLATFORM' }" @click="switchMode('PLATFORM')">
<text class="mode-title">平台派单</text>
<text class="mode-desc">发布需求 · 附近大神抢单</text>
</view>
</view>
</view>
<!-- 服务大神与项目信息 -->
<view class="order-card">
<view class="order-card" v-if="!isPlatformMode">
<view class="expert-profile">
<image class="expert-avatar" :src="expert.avatar" mode="aspectFill" />
<view class="expert-meta">
@@ -17,6 +31,30 @@
</view>
</view>
<!-- 平台派单:品类与预算 -->
<view class="order-card" v-if="isPlatformMode">
<view class="section-title">需求品类</view>
<view class="platform-cats">
<view
v-for="cat in platformCategories"
:key="cat"
class="platform-cat btn-active"
:class="{ active: platformCategory === cat }"
@click="selectPlatformCategory(cat)"
>
{{ cat }}
</view>
</view>
<view class="remark-row">
<text class="cell-label">预算 (元/小时)</text>
<input class="remark-input" type="digit" v-model="platformBudget" placeholder="如 88" placeholder-class="ph" />
</view>
<view class="safety-tip-card">
<image class="safety-icon-svg" :src="ICONS.shield" mode="aspectFit" />
<text class="safety-text">支付后进入附近大神派单池,5km 内认证大神可抢单;30 分钟内无人接单将自动全额退款。</text>
</view>
</view>
<!-- 数量/时长步进器 -->
<view class="order-card">
<view class="stepper-row">
@@ -240,7 +278,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { createOrder, payOrder as payOrderApi, requestWechatPayment, getCompanionDetail, getUserProfile, getMyCoupons } from '@/common/api.js';
import { createOrder, payOrder as payOrderApi, requestWechatPayment, getCompanionDetail, getUserProfile, getMyCoupons, normalizeUserId } from '@/common/api.js';
import { preloadSubscribeTemplates, requestSubscribeFor } from '@/common/subscribe.js';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
@@ -329,6 +367,27 @@ const remark = ref('');
const meetVenue = ref('朝阳大悦城海底捞');
const meetCoords = ref({ latitude: null, longitude: null });
// 下单模式:EXPERT=指定大神,PLATFORM=平台派单(发布需求,附近大神抢单)
const orderMode = ref('EXPERT');
const platformCategory = ref('同城美食陪吃');
const platformBudget = ref(88);
const platformCategories = ['同城美食陪吃', '下午茶陪喝', '同城KTV陪唱', '同城陪玩', '线上电竞开黑'];
const isPlatformMode = computed(() => orderMode.value === 'PLATFORM');
const selectPlatformCategory = (cat) => {
platformCategory.value = cat;
};
const switchMode = (mode) => {
if (mode === orderMode.value) return;
orderMode.value = mode;
if (mode === 'PLATFORM') {
// 平台派单必须由买家重新选点,避免沿用指定大神页的默认地点造成错误距离
meetVenue.value = '';
meetCoords.value = { latitude: null, longitude: null };
}
};
const pickVenue = () => {
// #ifdef MP-WEIXIN || APP-PLUS
uni.chooseLocation({
@@ -352,6 +411,7 @@ const pickVenue = () => {
const expenseBurden = ref('AA');
const isOffline = computed(() => {
if (isPlatformMode.value) return platformCategory.value !== '线上电竞开黑';
return expert.value && (
expert.value.track === 'OFFLINE_MEAL' ||
expert.value.track === 'OFFLINE_DRINK' ||
@@ -412,6 +472,9 @@ onLoad(async (query) => {
});
const rawTotal = computed(() => {
if (isPlatformMode.value) {
return (Number(platformBudget.value) || 0) * hours.value;
}
return (selectedService.value ? selectedService.value.price : 35) * hours.value;
});
@@ -516,6 +579,17 @@ const handlePay = async () => {
if (!ensureLoginOrRedirect('/pages/order/create')) {
return;
}
// 平台派单校验:品类预算与碰面地点
if (isPlatformMode.value) {
if (!(Number(platformBudget.value) > 0)) {
uni.showToast({ title: '请填写有效的预算(元/小时)', icon: 'none' });
return;
}
if (isOffline.value && !String(meetVenue.value || '').trim()) {
uni.showToast({ title: '请填写或选择碰面地点', icon: 'none' });
return;
}
}
// 下单/支付前请求一次性订阅授权(订单状态 + 核销码通知)
requestSubscribeFor('order');
if (payMethod.value === 'BALANCE' && Number(userBalance.value) < finalTotal.value) {
@@ -523,18 +597,23 @@ const handlePay = async () => {
return;
}
const uid = Number(uni.getStorageSync('userId') || currentUserId.value || 1001);
const serviceTitle = selectedService.value ? selectedService.value.title : `${expert.value.game}专属服务`;
const uid = normalizeUserId();
const isPlatform = isPlatformMode.value;
const serviceTitle = isPlatform
? `${platformCategory.value} · 平台派单需求`
: (selectedService.value ? selectedService.value.title : `${expert.value.game}专属服务`);
const usingUserCoupon = currentCoupon.value && currentCoupon.value.id && currentCoupon.value.id !== 'c_none';
const basePayload = {
buyerId: uid,
clientName: currentUserName.value || '超能电竞玩家',
expertId: expert.value.id,
companionName: expert.value.nickname,
expertAvatar: expert.value.avatar,
track: expert.value.game || (isOffline.value ? '同城生活' : '线上电竞'),
expertId: isPlatform ? 0 : expert.value.id,
companionName: isPlatform ? '平台派单' : expert.value.nickname,
expertAvatar: isPlatform ? '' : expert.value.avatar,
track: isPlatform ? platformCategory.value : (expert.value.game || (isOffline.value ? '同城生活' : '线上电竞')),
serviceTitle,
hourlyRate: selectedService.value ? selectedService.value.price : (expert.value.price || 35),
hourlyRate: isPlatform
? (Number(platformBudget.value) || 88)
: (selectedService.value ? selectedService.value.price : (expert.value.price || 35)),
durationHours: hours.value,
amount: finalTotal.value,
couponDiscount: usingUserCoupon ? 0 : discount.value,
@@ -647,6 +726,66 @@ const handlePay = async () => {
margin-bottom: 24rpx;
border: 1px solid var(--border-subtle);
&.mode-card {
padding: 20rpx;
}
.mode-tabs {
display: flex;
gap: 16rpx;
.mode-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
padding: 18rpx 12rpx;
border-radius: 18rpx;
border: 1px solid var(--border-subtle);
background: var(--bg-surface-elevated);
&.active {
border-color: #10B981;
background: rgba(16, 185, 129, 0.12);
}
.mode-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.mode-desc {
font-size: 22rpx;
color: var(--text-muted);
}
}
}
.platform-cats {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin: 16rpx 0 8rpx;
.platform-cat {
padding: 12rpx 22rpx;
border-radius: 28rpx;
font-size: 24rpx;
color: var(--text-secondary);
background: var(--bg-surface-elevated);
border: 1px solid var(--border-subtle);
&.active {
color: #FFFFFF;
border-color: transparent;
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
}
}
}
.expert-profile {
display: flex;
align-items: center;