feat(order): support balance payment, dynamic marketing coupon recommendation, and quick activity picker
这个提交包含在:
@@ -267,6 +267,8 @@ export function formatOrderData(raw) {
|
||||
status: statusKey,
|
||||
meetVenue: raw.location || raw.meetVenue || '北京市公开商圈',
|
||||
expenseBurden: raw.expenseBurden || raw.expense_burden || 'AA制各自付',
|
||||
payType: raw.payType || raw.pay_type || 'WECHAT',
|
||||
couponDiscount: Number(raw.couponDiscount || raw.coupon_discount || 0),
|
||||
verifyCode: raw.verifyCode || raw.verify_code || '',
|
||||
remark: raw.remark || '',
|
||||
createdAt: raw.createdAt || raw.created_at || '刚刚'
|
||||
|
||||
@@ -16,6 +16,21 @@ export const ICONS = {
|
||||
</svg>
|
||||
`),
|
||||
|
||||
wallet: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 12V8H6a2 2 0 0 1-2-2c0-1.1.9-2 2-2h12v4"></path>
|
||||
<path d="M4 6v12a2 2 0 0 0 2 2h14v-4"></path>
|
||||
<path d="M18 12a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4v-4h-4z"></path>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
wechat: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 10c0-3.3-3.1-6-7-6S3 6.7 3 10c0 1.9 1 3.6 2.6 4.7L5 18l3.6-1.8c.4.1.9.1 1.4.1 3.9 0 7-2.7 7-6.3z"></path>
|
||||
<path d="M17 12c2.8 0 5 1.8 5 4 0 1.1-.6 2.2-1.7 2.9L21 21l-2.4-1.2c-.5.1-1.1.2-1.6.2-2.8 0-5-1.8-5-4 0-2.2 2.2-4 5-4z"></path>
|
||||
</svg>
|
||||
`),
|
||||
|
||||
flame: svgToUri(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#FFFFFF">
|
||||
<path d="M12 2c-.6 2.7-2.6 4.7-3.9 6.2C6.5 10 5 12.3 5 15c0 3.9 3.1 7 7 7s7-3.1 7-7c0-2.3-1.2-4.4-2.8-6-1.5-1.5-3.6-3.7-4.2-7zM12 19c-2.2 0-4-1.8-4-4 0-1.5.8-2.8 1.9-3.8.3-.3.8-.1.8.3.1 1.2.9 2.2 2.1 2.5.4.1.7.5.7.9 0 2.3-1.5 4.1-1.5 4.1z"/>
|
||||
|
||||
@@ -59,13 +59,32 @@
|
||||
<input
|
||||
class="row-input"
|
||||
v-model="form.startTime"
|
||||
placeholder="如:今晚 20:30 / 周六 18:30"
|
||||
placeholder="点选快捷时段或自定义输入"
|
||||
placeholder-class="ph"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 快捷时段点选胶囊 (告别纯手打易错) -->
|
||||
<view class="quick-chip-row">
|
||||
<text class="chip-label">快捷时段:</text>
|
||||
<view class="quick-chips">
|
||||
<view
|
||||
v-for="timeItem in presetTimeOptions"
|
||||
:key="timeItem"
|
||||
class="time-chip btn-active"
|
||||
:class="{ selected: form.startTime === timeItem }"
|
||||
@click="form.startTime = timeItem"
|
||||
>
|
||||
{{ timeItem }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-row">
|
||||
<text class="row-label">地点/商圈</text>
|
||||
<view class="label-with-action">
|
||||
<text class="row-label">地点/商圈</text>
|
||||
<text class="btn-choose-loc btn-active" @click="handleChooseLocation">地图选点</text>
|
||||
</view>
|
||||
<input
|
||||
class="row-input"
|
||||
v-model="form.location"
|
||||
@@ -73,6 +92,22 @@
|
||||
placeholder-class="ph"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 合规示范商圈一键点选 (防风控与合规保障) -->
|
||||
<view class="quick-chip-row">
|
||||
<text class="chip-label">合规商圈:</text>
|
||||
<view class="quick-chips">
|
||||
<view
|
||||
v-for="zoneItem in recommendPublicZones.slice(0, 4)"
|
||||
:key="zoneItem"
|
||||
class="zone-chip-item btn-active"
|
||||
:class="{ selected: form.location === zoneItem }"
|
||||
@click="form.location = zoneItem"
|
||||
>
|
||||
{{ zoneItem.split(' ')[0] }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 席位与费用配置 -->
|
||||
@@ -349,6 +384,42 @@ const recommendPublicZones = ref([
|
||||
'北京市海淀区五道口购物中心 (高校商圈)'
|
||||
]);
|
||||
|
||||
const presetTimeOptions = ref([
|
||||
'今晚 19:30',
|
||||
'今晚 20:30',
|
||||
'明天 14:00',
|
||||
'周六 18:30',
|
||||
'周日 15:00'
|
||||
]);
|
||||
|
||||
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;
|
||||
uni.showToast({ title: '已选取地点', icon: 'success' });
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn('[ActivityCreate] 地图选点取消或未授权:', err);
|
||||
// 若未授权或在H5/开发环境,平滑引导点选推荐公开商圈
|
||||
if (recommendPublicZones.value.length > 0) {
|
||||
form.value.location = recommendPublicZones.value[0];
|
||||
uni.showToast({ title: '已填入推荐商圈', icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN || APP-PLUS
|
||||
if (recommendPublicZones.value.length > 0) {
|
||||
form.value.location = recommendPublicZones.value[0];
|
||||
uni.showToast({ title: '已为您填入示范商圈', icon: 'none' });
|
||||
}
|
||||
// #endif
|
||||
};
|
||||
|
||||
const minParticipants = ref(2);
|
||||
const maxParticipants = ref(10);
|
||||
const presetPerPersonFees = ref([200, 300, 500, 800, 1000]);
|
||||
@@ -609,6 +680,71 @@ const handleSubmit = async () => {
|
||||
font-weight: normal;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.label-with-action {
|
||||
width: 220rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.row-label {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn-choose-loc {
|
||||
font-size: 20rpx;
|
||||
color: #38BDF8;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12rpx 0 16rpx;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
|
||||
.chip-label {
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
width: 140rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
|
||||
.time-chip, .zone-chip-item {
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
transition: all 0.15s;
|
||||
|
||||
&.selected {
|
||||
color: #818CF8;
|
||||
border-color: #6366F1;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.zone-chip-item.selected {
|
||||
color: #38BDF8;
|
||||
border-color: #38BDF8;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stepper-row {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="create-order-container">
|
||||
<view class="create-order-container" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
|
||||
<!-- 服务大神与项目信息 -->
|
||||
<view class="order-card">
|
||||
<view class="expert-profile">
|
||||
@@ -139,13 +139,60 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 支付方式人性化选择 (支持微信支付与钱包余额抵扣) -->
|
||||
<view class="order-card payment-method-card">
|
||||
<view class="section-title">支付方式</view>
|
||||
<view class="pay-method-list">
|
||||
<view
|
||||
class="pay-method-item btn-active"
|
||||
:class="{ active: payMethod === 'WECHAT' }"
|
||||
@click="payMethod = 'WECHAT'"
|
||||
>
|
||||
<view class="method-left">
|
||||
<image class="method-svg" :src="ICONS.wechat" mode="aspectFit" />
|
||||
<view class="method-info">
|
||||
<text class="method-title">微信支付</text>
|
||||
<text class="method-desc">亿万用户的安全移动支付通道</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="radio-circle" :class="{ checked: payMethod === 'WECHAT' }"></view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="pay-method-item btn-active"
|
||||
:class="{
|
||||
active: payMethod === 'BALANCE',
|
||||
disabled: Number(userBalance) < finalTotal
|
||||
}"
|
||||
@click="selectBalancePay"
|
||||
>
|
||||
<view class="method-left">
|
||||
<image class="method-svg" :src="ICONS.wallet" mode="aspectFit" />
|
||||
<view class="method-info">
|
||||
<view class="title-with-badge">
|
||||
<text class="method-title">钱包余额抵扣</text>
|
||||
<text class="balance-tag" :class="{ 'low-balance': Number(userBalance) < finalTotal }">
|
||||
可用: ¥{{ userBalance }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="method-desc" v-if="Number(userBalance) >= finalTotal">扣除钱包余额,秒级极速结算</text>
|
||||
<text class="method-desc warn-tip" v-else>余额不足以支付整单 (差额 ¥{{ (finalTotal - Number(userBalance)).toFixed(2) }})</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="radio-circle" :class="{ checked: payMethod === 'BALANCE' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部固定结算条 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="amount-wrap">
|
||||
<text class="amount-label">实付:</text>
|
||||
<text class="amount-num">¥{{ finalTotal }}</text>
|
||||
</view>
|
||||
<button class="btn-pay btn-active" @click="handlePay">微信支付</button>
|
||||
<button class="btn-pay btn-active" :class="{ 'btn-balance': payMethod === 'BALANCE' }" @click="handlePay">
|
||||
{{ payMethod === 'BALANCE' ? '余额极速支付' : '微信安全支付' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 优惠券选择弹窗 -->
|
||||
@@ -190,10 +237,48 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import { createOrder, getCompanionDetail } from '@/common/api.js';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { createOrder, getCompanionDetail, getUserProfile, getMarketingCoupons } from '@/common/api.js';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
|
||||
const currentThemeKey = ref(getCurrentThemeKey());
|
||||
const userBalance = ref('0.00');
|
||||
const payMethod = ref('WECHAT'); // 'WECHAT' | 'BALANCE'
|
||||
|
||||
const onThemeChanged = (key) => {
|
||||
currentThemeKey.value = key;
|
||||
applyThemeToNativeUI(key);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
uni.$on('theme-change', onThemeChanged);
|
||||
loadUserWallet();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
loadUserWallet();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.$off('theme-change', onThemeChanged);
|
||||
});
|
||||
|
||||
const loadUserWallet = async () => {
|
||||
try {
|
||||
const u = await getUserProfile(1001);
|
||||
if (u && u.balance !== undefined) {
|
||||
userBalance.value = String(u.balance);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[OrderCreate] 加载用户资产异常:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const expert = ref({
|
||||
id: '',
|
||||
@@ -206,7 +291,7 @@ const expert = ref({
|
||||
const selectedService = ref(null);
|
||||
const hours = ref(2);
|
||||
const timeType = ref('NOW');
|
||||
const discount = ref(10);
|
||||
const discount = ref(0);
|
||||
const remark = ref('');
|
||||
const meetVenue = ref('朝阳大悦城海底捞');
|
||||
const expenseBurden = ref('AA');
|
||||
@@ -244,6 +329,9 @@ onLoad(async (query) => {
|
||||
} catch (err) {
|
||||
console.warn('[OrderCreate] 获取线上大神详情异常:', err);
|
||||
}
|
||||
|
||||
// 加载后台真实可用优惠券
|
||||
await loadLiveCoupons();
|
||||
});
|
||||
|
||||
const rawTotal = computed(() => {
|
||||
@@ -257,16 +345,50 @@ const finalTotal = computed(() => {
|
||||
|
||||
const changeHours = (delta) => {
|
||||
hours.value = Math.max(1, Math.min(10, hours.value + delta));
|
||||
autoSelectBestCoupon();
|
||||
};
|
||||
|
||||
const couponModalVisible = ref(false);
|
||||
const availableCoupons = ref([
|
||||
{ id: 'c1', title: '平台新客专享立减券', amount: 10, minAmount: 0, tag: '新人专享' },
|
||||
{ id: 'c2', title: '周末高阶开黑满减券', amount: 20, minAmount: 150, tag: '满150可用' },
|
||||
{ id: 'c3', title: '不使用优惠券', amount: 0, minAmount: 0, tag: '不使用' }
|
||||
{ id: 'c_none', title: '不使用优惠券', amount: 0, minAmount: 0, tag: '不使用' }
|
||||
]);
|
||||
const currentCoupon = ref(availableCoupons.value[0]);
|
||||
|
||||
const loadLiveCoupons = async () => {
|
||||
try {
|
||||
const list = await getMarketingCoupons();
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
const activeCoupons = list
|
||||
.filter(c => c.status === 1)
|
||||
.map(c => ({
|
||||
id: String(c.id),
|
||||
title: c.name,
|
||||
amount: Number(c.discountAmount || 0),
|
||||
minAmount: Number(c.minSpend || 0),
|
||||
tag: c.minSpend > 0 ? `满¥${c.minSpend}减¥${c.discountAmount}` : `立减¥${c.discountAmount}`
|
||||
}));
|
||||
activeCoupons.push({ id: 'c_none', title: '不使用优惠券', amount: 0, minAmount: 0, tag: '不使用' });
|
||||
availableCoupons.value = activeCoupons;
|
||||
autoSelectBestCoupon();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[OrderCreate] 加载优惠券异常:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const autoSelectBestCoupon = () => {
|
||||
const validList = availableCoupons.value.filter(c => c.amount > 0 && rawTotal.value >= c.minAmount);
|
||||
if (validList.length > 0) {
|
||||
// 选减免金额最大的一张
|
||||
validList.sort((a, b) => b.amount - a.amount);
|
||||
currentCoupon.value = validList[0];
|
||||
discount.value = validList[0].amount;
|
||||
} else {
|
||||
currentCoupon.value = availableCoupons.value.find(c => c.id === 'c_none') || availableCoupons.value[0];
|
||||
discount.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const openCouponModal = () => {
|
||||
couponModalVisible.value = true;
|
||||
};
|
||||
@@ -293,8 +415,36 @@ const selectCouponItem = (coupon) => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectBalancePay = () => {
|
||||
if (Number(userBalance.value) < finalTotal.value) {
|
||||
uni.showModal({
|
||||
title: '钱包余额不足',
|
||||
content: `当前钱包余额为 ¥${userBalance.value},实付需 ¥${finalTotal.value}。您可以前往「我的」-「我的钱包」充值后再使用余额支付,或切换为微信直接支付。`,
|
||||
confirmText: '去充值',
|
||||
cancelText: '微信支付',
|
||||
confirmColor: '#6366F1',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.switchTab({ url: '/pages/user/index' });
|
||||
} else {
|
||||
payMethod.value = 'WECHAT';
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
payMethod.value = 'BALANCE';
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
uni.showLoading({ title: '正在拉起微信支付...' });
|
||||
if (payMethod.value === 'BALANCE' && Number(userBalance.value) < finalTotal.value) {
|
||||
selectBalancePay();
|
||||
return;
|
||||
}
|
||||
|
||||
const payDesc = payMethod.value === 'BALANCE' ? '钱包余额支付' : '微信支付';
|
||||
uni.showLoading({ title: `正在处理${payDesc}...` });
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
buyerId: 1001,
|
||||
@@ -307,6 +457,8 @@ const handlePay = async () => {
|
||||
hourlyRate: selectedService.value ? selectedService.value.price : (expert.value.price || 35),
|
||||
durationHours: hours.value,
|
||||
amount: finalTotal.value,
|
||||
couponDiscount: discount.value,
|
||||
payType: payMethod.value,
|
||||
location: isOffline.value ? meetVenue.value : (remark.value || '线上语音开黑'),
|
||||
expenseBurden: expenseBurden.value === 'AA' ? 'AA制各自付' : (expenseBurden.value === 'CLIENT' ? '客户全包' : '大神自理'),
|
||||
remark: remark.value,
|
||||
@@ -317,7 +469,7 @@ const handlePay = async () => {
|
||||
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: '支付成功!订单已写入系统',
|
||||
title: `${payDesc}成功!订单已写入系统`,
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
@@ -732,6 +884,117 @@ const handlePay = async () => {
|
||||
padding: 0 60rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(16, 185, 129, 0.35);
|
||||
margin: 0;
|
||||
|
||||
&.btn-balance {
|
||||
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.payment-method-card {
|
||||
.pay-method-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
|
||||
.pay-method-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16rpx;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.method-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
|
||||
.method-svg {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.method-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
|
||||
.title-with-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.method-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #F8FAFC;
|
||||
}
|
||||
|
||||
.balance-tag {
|
||||
font-size: 20rpx;
|
||||
color: #10B981;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.low-balance {
|
||||
color: #EF4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.method-desc {
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
|
||||
&.warn-tip {
|
||||
color: #EF4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.radio-circle {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.checked {
|
||||
border-color: #6366F1;
|
||||
background: #6366F1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #6366F1;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,7 +1163,42 @@ const handlePay = async () => {
|
||||
background: #FFF7ED;
|
||||
}
|
||||
}
|
||||
.payment-method-card {
|
||||
background: #FFFFFF;
|
||||
border-color: #E7E5E4;
|
||||
|
||||
.pay-method-item {
|
||||
background: #FDFBF7;
|
||||
border-color: #E7E5E4;
|
||||
|
||||
.method-info {
|
||||
.method-title {
|
||||
color: #1C1917;
|
||||
}
|
||||
.method-desc {
|
||||
color: #78716C;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-circle {
|
||||
border-color: #D6D3D1;
|
||||
|
||||
&.checked {
|
||||
border-color: #EA580C;
|
||||
background: #EA580C;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #EA580C;
|
||||
background: #FFF7ED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-pay.btn-balance {
|
||||
background: linear-gradient(135deg, #EA580C 0%, #C2410C 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(234, 88, 12, 0.35);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -120,7 +120,9 @@ 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 remark VARCHAR(255) AFTER verify_code"
|
||||
"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"
|
||||
];
|
||||
for (const colSql of extraOrderCols) {
|
||||
try {
|
||||
|
||||
@@ -617,6 +617,8 @@ export class MysqlRepository {
|
||||
hourly_rate as hourlyRate,
|
||||
duration_hours as durationHours,
|
||||
amount,
|
||||
COALESCE(coupon_discount, 0.00) as couponDiscount,
|
||||
COALESCE(pay_type, 'WECHAT') as payType,
|
||||
location,
|
||||
verify_code as verifyCode,
|
||||
remark,
|
||||
@@ -639,6 +641,8 @@ export class MysqlRepository {
|
||||
hourlyRate?: number;
|
||||
durationHours?: number;
|
||||
amount?: number;
|
||||
couponDiscount?: number;
|
||||
payType?: string;
|
||||
location?: string;
|
||||
expenseBurden?: string;
|
||||
remark?: string;
|
||||
@@ -656,6 +660,8 @@ export class MysqlRepository {
|
||||
const hourlyRate = Number(data.hourlyRate) || 35;
|
||||
const durationHours = Number(data.durationHours) || 2;
|
||||
const amount = Number(data.amount) || hourlyRate * durationHours;
|
||||
const couponDiscount = Number(data.couponDiscount || 0);
|
||||
const payType = data.payType === 'BALANCE' ? 'BALANCE' : 'WECHAT';
|
||||
const location = data.location || '朝阳大悦城';
|
||||
const expenseBurden = data.expenseBurden || 'AA制各自付';
|
||||
const verifyCode = String(Math.floor(1000 + Math.random() * 9000));
|
||||
@@ -665,27 +671,52 @@ export class MysqlRepository {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 如果是余额支付,先锁行校验扣减用户钱包余额
|
||||
if (payType === 'BALANCE') {
|
||||
const [uRows]: any = await conn.query('SELECT balance FROM users WHERE id = ? FOR UPDATE', [buyerId]);
|
||||
if (!uRows || uRows.length === 0) {
|
||||
throw new Error('支付账户不存在');
|
||||
}
|
||||
const userBalance = Number(uRows[0].balance || 0);
|
||||
if (userBalance < amount) {
|
||||
throw new Error(`钱包余额不足 (当前可用 ¥${userBalance.toFixed(2)},需支付 ¥${amount.toFixed(2)})`);
|
||||
}
|
||||
const newBalance = userBalance - amount;
|
||||
await conn.query('UPDATE users SET balance = ? WHERE id = ?', [newBalance, buyerId]);
|
||||
|
||||
// 记一笔钱包扣款流水
|
||||
const walletFinId = `fin_bal_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
const walletRecNo = `WB${Date.now()}${Math.floor(Math.random() * 100)}`;
|
||||
await conn.query(`
|
||||
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)
|
||||
VALUES (?, ?, '钱包余额支付订单', ?, 'OUT', ?, ?)
|
||||
`, [walletFinId, walletRecNo, amount.toFixed(2), `用户 ${buyerId} (${companionName})`, id]);
|
||||
}
|
||||
|
||||
await conn.query(`
|
||||
INSERT INTO orders (
|
||||
id, buyer_id, expert_id, order_no, client_name,
|
||||
companion_name, expert_avatar, track, service_title,
|
||||
expense_burden, hourly_rate, duration_hours, amount,
|
||||
coupon_discount, pay_type,
|
||||
location, verify_code, remark, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`, [
|
||||
id, buyerId, expertId, orderNo, clientName,
|
||||
companionName, expertAvatar, track, serviceTitle,
|
||||
expenseBurden, hourlyRate, durationHours, amount,
|
||||
couponDiscount, payType,
|
||||
location, verifyCode, remark, status
|
||||
]);
|
||||
|
||||
// 记一笔进账流水
|
||||
// 记一笔平台订单进账流水
|
||||
const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
const recordNo = `TX${Date.now()}${Math.floor(Math.random() * 100)}`;
|
||||
await conn.query(`
|
||||
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)
|
||||
VALUES (?, ?, '服务预约订单付款', ?, 'IN', ?, ?)
|
||||
`, [finId, recordNo, amount.toFixed(2), clientName, id]);
|
||||
VALUES (?, ?, ?, ?, 'IN', ?, ?)
|
||||
`, [finId, recordNo, payType === 'BALANCE' ? '服务预约订单付款 (钱包余额)' : '服务预约订单付款 (微信支付)', amount.toFixed(2), clientName, id]);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
@@ -703,6 +734,8 @@ export class MysqlRepository {
|
||||
hourlyRate,
|
||||
durationHours,
|
||||
amount,
|
||||
couponDiscount,
|
||||
payType,
|
||||
location,
|
||||
verifyCode,
|
||||
remark,
|
||||
|
||||
@@ -263,5 +263,60 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
||||
expect(createActContent).toContain('activity_rules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('8. 订单结算体系与前端交互人性化专项验证 (支付方式二选一、动态优惠券、组局快捷时段与商圈选点)', () => {
|
||||
it('MySQL Schema 必须包含 pay_type 与 coupon_discount 字段升级补丁', () => {
|
||||
const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts');
|
||||
const content = fs.readFileSync(mysqlPath, 'utf-8');
|
||||
expect(content).toContain('ALTER TABLE orders ADD COLUMN pay_type');
|
||||
expect(content).toContain('ALTER TABLE orders ADD COLUMN coupon_discount');
|
||||
});
|
||||
|
||||
it('Repository createOrder 必须支持余额支付原子事务校验、扣减余额及财务流水记录', () => {
|
||||
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
expect(content).toContain("data.payType === 'BALANCE'");
|
||||
expect(content).toContain('SELECT balance FROM users WHERE id = ? FOR UPDATE');
|
||||
expect(content).toContain('const newBalance = userBalance - amount');
|
||||
expect(content).toContain('UPDATE users SET balance = ? WHERE id = ?');
|
||||
expect(content).toContain('钱包余额支付订单');
|
||||
expect(content).toContain("INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)");
|
||||
});
|
||||
|
||||
it('小程序订单创建页面必须支持钱包余额与微信支付二选一并动态拉取营销优惠券', () => {
|
||||
const orderCreatePath = path.join(rootDir, 'pwuniapp', 'pages', 'order', 'create.vue');
|
||||
const content = fs.readFileSync(orderCreatePath, 'utf-8');
|
||||
expect(content).toContain('payType');
|
||||
expect(content).toContain("'BALANCE'");
|
||||
expect(content).toContain("'WECHAT'");
|
||||
expect(content).toContain('getMarketingCoupons');
|
||||
expect(content).toContain('couponDiscount');
|
||||
});
|
||||
|
||||
it('小程序活动发起页面必须包含快捷时段胶囊与示范商圈及地图选点交互', () => {
|
||||
const actCreatePath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'create.vue');
|
||||
const content = fs.readFileSync(actCreatePath, 'utf-8');
|
||||
expect(content).toContain('presetTimeOptions');
|
||||
expect(content).toContain('recommendPublicZones');
|
||||
expect(content).toContain('handleChooseLocation');
|
||||
expect(content).toContain('uni.chooseLocation');
|
||||
});
|
||||
|
||||
it('所有新增代码与测试套件严禁包含任何 Emoji 字符', () => {
|
||||
const pathsToCheck = [
|
||||
path.join(rootDir, 'server', 'src', 'db', 'mysql.ts'),
|
||||
path.join(rootDir, 'server', 'src', 'db', 'repository.ts'),
|
||||
path.join(rootDir, 'pwuniapp', 'pages', 'order', 'create.vue'),
|
||||
path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'create.vue'),
|
||||
path.join(rootDir, 'pwuniapp', 'common', 'icons.js'),
|
||||
path.join(rootDir, 'pwuniapp', 'common', 'api.js')
|
||||
];
|
||||
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
|
||||
for (const p of pathsToCheck) {
|
||||
const text = fs.readFileSync(p, 'utf-8');
|
||||
expect(emojiRegex.test(text)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户