feat(activity): 发单页统一组局与平台派单入口,接单大厅展示附近组局

- 移除附近活动页顶部单独的派单按钮(入口重复)

- 「我要发单」新增发布类型切换:发起组局 / 平台派单,派单模式填预算与时长并内联拉起支付,支付成功进入待接单池

- 接单大厅新增「附近组局(可报名)」区块,展示招募中的组局并可进入详情报名

- 113 套件/738 用例全绿
这个提交包含在:
xcbyy
2026-10-21 18:03:09 +08:00
父节点 14ce4b25f5
当前提交 33b6a3ea7d
共有 10 个文件被更改,包括 307 次插入 和 64 次删除

查看文件

@@ -1,7 +1,27 @@
<template>
<view class="create-activity-container" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
<!-- 发布类型切换:发起组局 / 平台派单 -->
<view class="publish-mode-wrap">
<view
class="publish-mode-item btn-active"
:class="{ active: publishMode === 'ACTIVITY' }"
@click="publishMode = 'ACTIVITY'"
>
<text class="publish-mode-title">发起组局</text>
<text class="publish-mode-desc">多人报名 · AA 或房主请客</text>
</view>
<view
class="publish-mode-item btn-active"
:class="{ active: publishMode === 'DISPATCH' }"
@click="publishMode = 'DISPATCH'"
>
<text class="publish-mode-title">平台派单</text>
<text class="publish-mode-desc">发布需求 · 附近大神抢单</text>
</view>
</view>
<!-- 模式选择 Tab (AA制 / 项目制) -->
<view class="mode-switch-wrap">
<view class="mode-switch-wrap" v-if="publishMode === 'ACTIVITY'">
<view
class="mode-item btn-active"
:class="{ active: form.payType === 'AA' }"
@@ -130,7 +150,7 @@
</view>
<!-- 席位与费用配置 -->
<view class="form-card">
<view class="form-card" v-if="publishMode === 'ACTIVITY'">
<view class="stepper-row">
<view class="stepper-info">
<text class="row-label">招募席位 (人)</text>
@@ -246,7 +266,7 @@
</view>
<!-- 房主调价与追加金额设置 (随时灵活追加) -->
<view class="form-card dynamic-price-card" v-if="form.payType === 'PROJECT'">
<view class="form-card dynamic-price-card" v-if="form.payType === 'PROJECT' && publishMode === 'ACTIVITY'">
<view class="card-header-switch">
<view class="switch-title-box">
<text class="form-title">灵活调价与追加金额设置</text>
@@ -299,10 +319,40 @@
</view>
</view>
<!-- 平台派单:预算与时长 -->
<view class="form-card" v-if="publishMode === 'DISPATCH'">
<view class="input-row">
<text class="row-label">预算 (元/小时)</text>
<input
class="row-input price-input"
type="digit"
v-model="dispatchBudget"
placeholder="如:88"
placeholder-class="price-ph"
/>
</view>
<view class="stepper-row">
<view class="stepper-info">
<text class="row-label">服务时长</text>
<text class="sub-label">1 - 8 小时</text>
</view>
<view class="stepper-box">
<button class="step-btn btn-active" :disabled="dispatchHours <= 1" @click="changeDispatchHours(-1)">-</button>
<text class="step-val">{{ dispatchHours }} 小时</text>
<button class="step-btn btn-active" :disabled="dispatchHours >= 8" @click="changeDispatchHours(1)">+</button>
</view>
</view>
<view class="dispatch-total-row">
<text class="row-label">预计支付</text>
<text class="dispatch-total">¥{{ ((Number(dispatchBudget) || 0) * dispatchHours).toFixed(2) }}</text>
</view>
<text class="sub-label">支付后进入附近 5km 派单池,认证大神抢单;30 分钟无人接单自动全额退款。</text>
</view>
<!-- 底部发起操作栏 (适配安全区) -->
<view class="bottom-bar">
<button class="btn-submit btn-active" @click="handleSubmit">
立即发起同城组局
{{ publishMode === 'DISPATCH' ? '发布需求并支付' : '立即发起同城组局' }}
</button>
</view>
@@ -342,7 +392,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { createActivity, getSafetyPlaces, getNavCategories, getPlatformConfigs } from '@/common/api.js';
import { createActivity, createOrder, payOrder as payOrderApi, requestWechatPayment, normalizeUserId, getSafetyPlaces, getNavCategories, getPlatformConfigs } from '@/common/api.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { ensureLoginOrRedirect } from '@/common/role.js';
@@ -408,6 +458,15 @@ const stopPropagation = (e) => {
};
const riskModalVisible = ref(false);
// 发布类型:ACTIVITY=组局(多人报名),DISPATCH=平台派单(订单进抢单池)
const publishMode = ref('ACTIVITY');
const dispatchBudget = ref(88);
const dispatchHours = ref(2);
const changeDispatchHours = (delta) => {
dispatchHours.value = Math.min(8, Math.max(1, Number(dispatchHours.value) + delta));
};
const recommendPublicZones = ref([
'北京市朝阳区三里屯太古里 (AAA公开商圈)',
'北京市朝阳区工体纯K量贩KTV (正规量贩)',
@@ -565,6 +624,71 @@ const changeParticipants = (delta) => {
);
};
const submitDispatch = async (title, startTime, location) => {
const budget = Number(dispatchBudget.value);
if (!budget || budget <= 0) {
uni.showToast({ title: '请输入有效的预算', icon: 'none' });
return;
}
if (budget > 2000) {
uni.showToast({ title: '预算不能超过 ¥2000/小时', icon: 'none' });
return;
}
const hours = Math.min(8, Math.max(1, Math.round(Number(dispatchHours.value) || 1)));
const amount = Number((budget * hours).toFixed(2));
const uid = normalizeUserId();
const cachedUser = uni.getStorageSync('userInfo') || {};
uni.showLoading({ title: '正在创建需求单...' });
let order = null;
try {
order = await createOrder({
buyerId: uid,
clientName: cachedUser.nickname || '同城用户',
expertId: 0,
companionName: '平台派单',
expertAvatar: '',
track: form.value.game,
serviceTitle: title,
hourlyRate: budget,
durationHours: hours,
amount,
payType: 'WECHAT',
location,
latitude: form.value.latitude,
longitude: form.value.longitude,
expenseBurden: 'AA制各自付',
remark: title,
});
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '需求发布失败', icon: 'none' });
return;
}
uni.hideLoading();
try {
await requestWechatPayment({
amount,
title: `${form.value.game} · ${title}`,
orderId: order.id,
scene: 'ORDER',
userId: uid,
});
await payOrderApi(order.id, { targetStatus: '待接单', payType: 'WECHAT', buyerId: uid });
uni.showToast({ title: '需求已发布,已进入 5km 派单池', icon: 'success', duration: 2000 });
} catch (payErr) {
uni.showToast({
title: (payErr && payErr.cancelled) ? '已取消支付,可在订单中心继续支付' : (payErr.message || '支付未完成,可在订单中心继续支付'),
icon: 'none',
duration: 2200,
});
}
setTimeout(() => {
uni.switchTab({ url: '/pages/order/list' });
}, 1200);
};
const handleSubmit = async () => {
if (!ensureLoginOrRedirect('/pages/activity/create')) {
return;
@@ -604,6 +728,12 @@ const handleSubmit = async () => {
return;
}
// 平台派单:直接创建订单并拉起支付,支付成功后进入抢单池
if (publishMode.value === 'DISPATCH') {
await submitDispatch(trimmedTitle, trimmedTime, trimmedLocation);
return;
}
// 3. 费用与预算必填及数值合法性校验
const isProject = form.value.payType === 'PROJECT';
if (!isProject) {
@@ -695,6 +825,54 @@ const handleSubmit = async () => {
padding: 24rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
}
.publish-mode-wrap {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
.publish-mode-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
padding: 20rpx 12rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.04);
&.active {
border-color: #10B981;
background: rgba(16, 185, 129, 0.14);
}
.publish-mode-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary, #F8FAFC);
}
.publish-mode-desc {
font-size: 21rpx;
color: var(--text-muted, #94A3B8);
}
}
}
.dispatch-total-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 12rpx 0;
.dispatch-total {
font-size: 32rpx;
font-weight: 700;
color: #10B981;
}
}
.mode-switch-wrap {
display: flex;
gap: 16rpx;

查看文件

@@ -22,15 +22,6 @@
</view>
</view>
<!-- 平台派单入口:不指定大神,发布需求后附近大神抢单 -->
<view class="demand-entry btn-active" @click="goPublishDemand">
<view class="demand-entry-left">
<text class="demand-entry-title">发布陪玩需求(平台派单)</text>
<text class="demand-entry-desc">不指定大神 · 5km 内认证大神抢单 · 30 分钟无人接单自动退款</text>
</view>
<text class="demand-entry-arrow">›</text>
</view>
<!-- 顶部模式切换选项卡 (矢量SVG模式图标) -->
<view class="tab-header">
<view
@@ -189,7 +180,6 @@ import { getActivities, getNavCategories, normalizeUserId } from '@/common/api.j
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
import { ensureLoginOrRedirect } from '@/common/role.js';
const CURRENT_USER_ID = normalizeUserId();
@@ -379,11 +369,6 @@ const switchTab = (tab) => {
currentTab.value = tab;
};
const goPublishDemand = () => {
if (!ensureLoginOrRedirect('/pages/activity/list')) return;
uni.navigateTo({ url: '/pages/order/create' });
};
const switchCategory = (cat) => {
currentCategory.value = cat;
};
@@ -408,40 +393,6 @@ const createActivity = () => {
padding: 0 32rpx calc(180rpx + env(safe-area-inset-bottom));
}
.demand-entry {
margin: 0 32rpx 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 24rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(99, 102, 241, 0.35);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.16), rgba(139, 92, 246, 0.10));
.demand-entry-left {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.demand-entry-title {
font-size: 26rpx;
font-weight: 600;
color: var(--text-primary, #F8FAFC);
}
.demand-entry-desc {
font-size: 21rpx;
color: var(--text-muted, #94A3B8);
}
.demand-entry-arrow {
font-size: 36rpx;
color: var(--color-brand, #6366F1);
}
}
.header-search {
padding-top: var(--safe-header-gap, 36rpx);
padding-bottom: 24rpx;

查看文件

@@ -108,6 +108,26 @@
</view>
</view>
<!-- 附近组局(招募中的组局,大神可报名参加) -->
<view class="nearby-activities-section" v-if="nearbyActivities.length > 0">
<view class="nearby-activity-head">
<text class="nearby-activity-title">附近组局(可报名)</text>
<text class="nearby-activity-more" @click="goActivityHall">查看全部 ›</text>
</view>
<view
v-for="act in nearbyActivities"
:key="act.id"
class="nearby-activity-card btn-active"
@click="openActivity(act)"
>
<view class="na-row">
<text class="na-title">{{ act.title }}</text>
<text class="na-fee">{{ act.payType === 'PROJECT' ? '房主请客' : `人均 ¥${act.perPersonFee}` }}</text>
</view>
<text class="na-meta">{{ act.startTime || '时间待定' }} · {{ act.location || '地点待定' }} · {{ act.currentParticipants }}/{{ act.maxParticipants }} 人</text>
</view>
</view>
<!-- 派单需求全貌看板弹窗 (方案1) -->
<view class="dispatch-detail-mask" v-if="showDetailModal" @click="closeOrderDetailModal">
<view class="dispatch-detail-modal" @click.stop="stopPropagation">
@@ -231,7 +251,7 @@ 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 { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile } from '@/common/api.js';
import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile, getActivities } from '@/common/api.js';
import { getUserLocation } from '@/common/location.js';
import { getNavBarInfo } from '@/common/nav.js';
@@ -276,6 +296,17 @@ const todayStats = ref({
waitingVerify: 0
});
const nearbyActivities = ref([]);
const openActivity = (act) => {
if (!act || !act.id) return;
uni.navigateTo({ url: `/pages/activity/detail?id=${act.id}` });
};
const goActivityHall = () => {
uni.switchTab({ url: '/pages/activity/list' });
};
const onThemeChanged = (newTheme) => {
currentThemeKey.value = newTheme;
applyThemeToNativeUI(newTheme);
@@ -344,6 +375,13 @@ const loadHallData = async () => {
};
});
}
}).catch(() => {}),
getActivities().then((list) => {
const arr = Array.isArray(list) ? list : [];
nearbyActivities.value = arr
.filter((a) => a && (a.status === 'RECRUITING' || a.status === '招募中'))
.slice(0, 5);
}).catch(() => {})
]);
} catch (err) {
@@ -1474,4 +1512,65 @@ const copyOrderNo = () => {
}
}
}
/* 附近组局(招募中) */
.nearby-activities-section {
margin-top: 28rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #151D30;
border: 1rpx solid rgba(255, 255, 255, 0.08);
.nearby-activity-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.nearby-activity-title {
font-size: 28rpx;
font-weight: 600;
color: #F8FAFC;
}
.nearby-activity-more {
font-size: 22rpx;
color: #10B981;
}
.nearby-activity-card {
padding: 18rpx 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
&:first-of-type {
border-top: none;
}
.na-row {
display: flex;
justify-content: space-between;
gap: 12rpx;
}
.na-title {
flex: 1;
font-size: 26rpx;
font-weight: 600;
color: #E2E8F0;
}
.na-fee {
font-size: 24rpx;
color: #10B981;
}
.na-meta {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #94A3B8;
}
}
}
</style>