新增统一导航栏组件YjyfNavBar,替换各页面原生导航 新增服务选择抽屉组件GoodsPickerDrawer,实现分类选品流程 新增tabbar图标矢量资源,优化底部导航样式 新增后端运力统计接口与多商品下单逻辑 修复页面跳转逻辑,统一使用switchTab/redirectTo 优化订单创建与评价页的UI组件化,提升一致性
402 行
17 KiB
Vue
402 行
17 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="svc">
|
||
<view class="svc-thumb"><text class="svc-txt">{{ svc ? svc.name.slice(0, 2) : '' }}</text></view>
|
||
<view class="svc-info">
|
||
<text class="svc-name">{{ svc ? svc.name : '' }}</text>
|
||
<text class="svc-price">{{ svc ? svc.price_text : '' }}</text>
|
||
</view>
|
||
<view class="urgent" :class="{ on: urgent }" v-if="priceType !== 'custom'" @tap="urgent = !urgent">
|
||
<text class="urgent-txt">{{ urgent ? '已加急 +20%' : '加急' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 计价方式:一口价 / 师傅报价竞价 / 用户自主定价 -->
|
||
<view class="sec">
|
||
<view class="sec-title">选择下单方式</view>
|
||
<view class="modes">
|
||
<view
|
||
v-for="m in modes"
|
||
:key="m.key"
|
||
class="mode"
|
||
:class="{ on: priceType === m.key }"
|
||
@tap="selectMode(m.key)"
|
||
>
|
||
<text class="mode-name">{{ m.name }}</text>
|
||
<text class="mode-desc">{{ m.desc }}</text>
|
||
<view class="mode-check" v-if="priceType === m.key">
|
||
<image class="mode-check-img" src="/static/icons/check-orange.svg" mode="aspectFit" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="mode-note">{{ modeNote }}</view>
|
||
|
||
<view class="fi" v-if="priceType === 'custom'">
|
||
<text class="fk">我的出价</text>
|
||
<view class="price-input-wrap">
|
||
<text class="price-unit">¥</text>
|
||
<input class="fv price-input" v-model="userPrice" type="digit" placeholder="请输入您愿意支付的总价" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="sec">
|
||
<view class="sec-title">服务地址</view>
|
||
<view class="addr" v-if="address">
|
||
<view class="addr-main">
|
||
<text class="addr-c">{{ address.contact }} {{ address.mobile }}</text>
|
||
<text class="addr-d">{{ address.province }}{{ address.city }}{{ address.district }}{{ address.address }}</text>
|
||
<text class="addr-x">楼层 {{ address.floor }} · {{ Number(address.has_elevator) ? '有电梯' : '无电梯' }}</text>
|
||
</view>
|
||
<text class="link" @tap="pickAddress">更换</text>
|
||
</view>
|
||
|
||
<view class="form" v-if="!address">
|
||
<view class="fi"><text class="fk">联系人</text><input class="fv" v-model="form.contact" placeholder="收货人姓名" /></view>
|
||
<view class="fi"><text class="fk">手机号</text><input class="fv" v-model="form.mobile" type="number" maxlength="11" placeholder="联系手机号" /></view>
|
||
<view class="fi"><text class="fk">详细地址</text><input class="fv" v-model="form.address" placeholder="小区/楼栋/门牌号" /></view>
|
||
<view class="fi">
|
||
<text class="fk">楼层</text>
|
||
<view class="stepper">
|
||
<view class="st-btn" @tap="stepFloor(-1)">-</view>
|
||
<text class="st-val">{{ form.floor }}</text>
|
||
<view class="st-btn" @tap="stepFloor(1)">+</view>
|
||
</view>
|
||
</view>
|
||
<view class="fi">
|
||
<text class="fk">有无电梯</text>
|
||
<view class="seg">
|
||
<view class="seg-i" :class="{ on: form.has_elevator === 1 }" @tap="form.has_elevator = 1">有</view>
|
||
<view class="seg-i" :class="{ on: form.has_elevator === 0 }" @tap="form.has_elevator = 0">无</view>
|
||
</view>
|
||
</view>
|
||
<view class="save-btn clickable-hover" @tap="saveAddress">保存地址</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="sec">
|
||
<view class="sec-title">预约与服务信息</view>
|
||
<picker mode="date" :value="date" :start="today" @change="onDate">
|
||
<view class="fi"><text class="fk">预约日期</text><text class="fv2">{{ date }}</text><text class="arrow">›</text></view>
|
||
</picker>
|
||
<picker mode="time" :value="time" @change="onTime">
|
||
<view class="fi"><text class="fk">预约时段</text><text class="fv2">{{ time }}</text><text class="arrow">›</text></view>
|
||
</picker>
|
||
<view class="fi" v-if="svc && svc.need_model === 1"><text class="fk">商品型号</text><input class="fv" v-model="form.goods_model" placeholder="如:三人位布艺沙发" /></view>
|
||
<view class="fi"><text class="fk">数量</text><view class="stepper">
|
||
<view class="st-btn" @tap="stepNum(-1)">-</view><text class="st-val">{{ form.goods_num }}</text><view class="st-btn" @tap="stepNum(1)">+</view>
|
||
</view></view>
|
||
<view class="fi"><text class="fk">备注</text><input class="fv" v-model="form.remark" placeholder="施工要求 / 门禁 / 宠物等" /></view>
|
||
</view>
|
||
|
||
<view class="sec" v-if="amount">
|
||
<view class="sec-title">{{ priceType === 'quote' ? '费用预估' : '费用明细' }}</view>
|
||
<view class="li">
|
||
<text class="k">{{ priceType === 'custom' ? '您的出价' : '基础价' }}</text>
|
||
<text class="v">¥{{ amount.base_amount }}</text>
|
||
</view>
|
||
<view class="li" v-if="priceType !== 'custom'">
|
||
<text class="k">楼层费</text>
|
||
<text class="v">¥{{ amount.floor_amount }}</text>
|
||
</view>
|
||
<view class="li" v-if="priceType !== 'custom' && amount.urgent_amount > 0">
|
||
<text class="k">加急费</text>
|
||
<text class="v">¥{{ amount.urgent_amount }}</text>
|
||
</view>
|
||
<view class="li" v-if="amount.discount_amount > 0">
|
||
<text class="k">优惠券抵扣</text>
|
||
<text class="v">-¥{{ amount.discount_amount }}</text>
|
||
</view>
|
||
<view class="li total">
|
||
<text class="k">{{ priceType === 'quote' ? '参考合计' : '应付合计' }}</text>
|
||
<text class="v big">¥{{ amount.pay_amount }}</text>
|
||
</view>
|
||
<text class="fee-note" v-if="priceType === 'quote'">先支付参考价,选定师傅报价后按最终报价结算</text>
|
||
<text class="fee-note" v-if="priceType === 'custom'">自主定价为含楼层/加急的总价,师傅接单即按此价服务</text>
|
||
</view>
|
||
|
||
<view class="bar">
|
||
<view class="bar-l"><text class="bar-k">{{ priceType === 'quote' ? '参考价' : '合计' }}</text><text class="bar-p">¥{{ amount ? amount.pay_amount : 0 }}</text></view>
|
||
<view class="bar-btn clickable-hover" :class="{ disabled: submitting }" @tap="submit">
|
||
{{ submitting ? '提交中...' : (priceType === 'quote' ? '发布需求' : '提交并支付') }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, watch, computed } from 'vue';
|
||
import { onLoad } from '@dcloudio/uni-app';
|
||
import api from '@/utils/api.js';
|
||
import { isLogin, toLogin } from '@/utils/request.js';
|
||
|
||
const svc = ref(null);
|
||
const address = ref(null);
|
||
const addresses = ref([]);
|
||
const amount = ref(null);
|
||
const urgent = ref(false);
|
||
const submitting = ref(false);
|
||
|
||
/** 三种下单方式:一口价 / 师傅报价竞价 / 用户自主定价 */
|
||
const modes = [
|
||
{ key: 'fixed', name: '一口价', desc: '平台明码标价' },
|
||
{ key: 'quote', name: '师傅报价', desc: '师傅竞价,您来选' },
|
||
{ key: 'custom', name: '自主定价', desc: '您出价,师傅接单' }
|
||
];
|
||
const priceType = ref('fixed');
|
||
const userPrice = ref('');
|
||
|
||
const modeNote = computed(() => {
|
||
if (priceType.value === 'quote') return '发布后进入报价大厅,师傅提交报价,您对比价格与评分后选择';
|
||
if (priceType.value === 'custom') return '填写您愿意支付的总价(含楼层等),师傅认可即可接单';
|
||
return '按平台标准价结算,支付后师傅先到先得';
|
||
});
|
||
|
||
const now = new Date();
|
||
const pad = (n) => (n < 10 ? '0' + n : '' + n);
|
||
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||
// 默认预约时间取当前时间之后的可用时段,避免默认值落在过去导致下单失败
|
||
function defaultAppointment() {
|
||
const h = now.getHours();
|
||
if (h >= 18) {
|
||
const t = new Date(now.getTime() + 86400000);
|
||
return { date: `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`, time: '09:00' };
|
||
}
|
||
if (h >= 14) return { date: today, time: pad(h + 1) + ':00' };
|
||
return { date: today, time: '14:00' };
|
||
}
|
||
const defAppointment = defaultAppointment();
|
||
const date = ref(defAppointment.date);
|
||
const time = ref(defAppointment.time);
|
||
|
||
const form = reactive({
|
||
contact: '', mobile: '', address: '', floor: 1, has_elevator: 1,
|
||
goods_model: '', goods_num: 1, remark: ''
|
||
});
|
||
|
||
let serviceId = 0;
|
||
let calcTimer = null;
|
||
|
||
async function calc() {
|
||
if (!serviceId) return;
|
||
const custom = priceType.value === 'custom';
|
||
if (custom && (!userPrice.value || Number(userPrice.value) <= 0)) {
|
||
amount.value = null;
|
||
return;
|
||
}
|
||
const floor = address.value ? address.value.floor : form.floor;
|
||
const elev = address.value ? address.value.has_elevator : form.has_elevator;
|
||
try {
|
||
amount.value = await api.calc({
|
||
service_id: serviceId,
|
||
floor: floor,
|
||
has_elevator: elev,
|
||
urgent: urgent.value ? 1 : 0,
|
||
price_type: priceType.value,
|
||
user_price: custom ? Number(userPrice.value) : 0
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
function scheduleCalc() {
|
||
clearTimeout(calcTimer);
|
||
calcTimer = setTimeout(calc, 300);
|
||
}
|
||
|
||
function selectMode(key) {
|
||
if (priceType.value === key) return;
|
||
priceType.value = key;
|
||
// 自主定价为含楼层/加急的总价,不再单独收加急费
|
||
if (key === 'custom') urgent.value = false;
|
||
amount.value = null;
|
||
calc();
|
||
}
|
||
|
||
watch([urgent, () => form.floor, () => form.has_elevator, priceType], scheduleCalc);
|
||
watch(userPrice, scheduleCalc);
|
||
|
||
function onDate(e) { date.value = e.detail.value; }
|
||
function onTime(e) { time.value = e.detail.value; }
|
||
function stepFloor(d) { form.floor = Math.max(1, form.floor + d); }
|
||
function stepNum(d) { form.goods_num = Math.max(1, form.goods_num + d); }
|
||
|
||
async function loadAddress() {
|
||
try {
|
||
const res = await api.addressList();
|
||
addresses.value = res.list || [];
|
||
address.value = addresses.value.find(a => Number(a.is_default) === 1) || addresses.value[0] || null;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function pickAddress() {
|
||
const names = addresses.value.map(a => (a.contact + ' ' + a.address).slice(0, 14));
|
||
names.push('+ 新增地址');
|
||
uni.showActionSheet({
|
||
itemList: names,
|
||
success: (r) => {
|
||
if (r.tapIndex === addresses.value.length) {
|
||
address.value = null;
|
||
form.contact = '';
|
||
form.address = '';
|
||
} else {
|
||
address.value = addresses.value[r.tapIndex];
|
||
calc();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function saveAddress() {
|
||
if (!form.contact || !form.mobile || !form.address) {
|
||
uni.showToast({ title: '请填写联系人/手机号/地址', icon: 'none' });
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api.addressSave({
|
||
contact: form.contact, mobile: form.mobile,
|
||
province: '贵州省', city: '遵义市', district: '', address: form.address,
|
||
floor: form.floor, has_elevator: form.has_elevator, is_default: 1
|
||
});
|
||
await loadAddress();
|
||
const hit = addresses.value.find(a => Number(a.id) === Number(res.id));
|
||
if (hit) address.value = hit;
|
||
calc();
|
||
uni.showToast({ title: '地址已保存' });
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function submit() {
|
||
if (submitting.value) return;
|
||
if (!isLogin()) { toLogin(); return; }
|
||
if (!address.value) { uni.showToast({ title: '请先保存服务地址', icon: 'none' }); return; }
|
||
if (priceType.value === 'custom' && (!userPrice.value || Number(userPrice.value) <= 0)) {
|
||
uni.showToast({ title: '请填写您的出价', icon: 'none' });
|
||
return;
|
||
}
|
||
submitting.value = true;
|
||
try {
|
||
const appoint = Math.floor(new Date(date.value.replace(/-/g, '/') + ' ' + time.value + ':00').getTime() / 1000);
|
||
const res = await api.orderCreate({
|
||
service_id: serviceId,
|
||
address_id: address.value.id,
|
||
appoint_time: appoint,
|
||
urgent: priceType.value === 'custom' ? 0 : (urgent.value ? 1 : 0),
|
||
goods_model: form.goods_model,
|
||
goods_num: form.goods_num,
|
||
remark: form.remark,
|
||
price_type: priceType.value,
|
||
user_price: priceType.value === 'custom' ? Number(userPrice.value) : 0
|
||
});
|
||
await api.orderPay({ order_id: res.order_id, pay_type: 'mock' });
|
||
const tip = priceType.value === 'quote' ? '支付成功,等待师傅报价' : '支付成功,等待师傅接单';
|
||
uni.showToast({ title: tip });
|
||
setTimeout(() => {
|
||
uni.redirectTo({ url: '/pages/order/detail?id=' + res.order_id });
|
||
}, 600);
|
||
} catch (e) {
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
}
|
||
|
||
onLoad(async (options) => {
|
||
if (!isLogin()) { toLogin(); return; }
|
||
serviceId = Number(options.service_id || 0);
|
||
const res = await api.serviceDetail(serviceId);
|
||
svc.value = res.detail;
|
||
// 默认沿用服务价态:报价服务 → 师傅报价;区间服务 → 自主定价;其余 → 一口价
|
||
priceType.value = svc.value.price_type === 'quote' ? 'quote'
|
||
: (svc.value.price_type === 'range' ? 'custom' : 'fixed');
|
||
await loadAddress();
|
||
calc();
|
||
});
|
||
</script>
|
||
<style scoped>
|
||
.page { min-height: 100vh; background: #F5F6F8; padding-bottom: 180rpx; }
|
||
.svc {
|
||
display: flex; align-items: center; padding: 26rpx 28rpx;
|
||
background: linear-gradient(160deg, #FFB85C 0%, #F98D25 60%, #ED6F0E 100%);
|
||
}
|
||
.svc-thumb {
|
||
width: 96rpx; height: 96rpx; border-radius: 18rpx; flex-shrink: 0;
|
||
background: rgba(255,255,255,0.92); display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.svc-txt { font-size: 28rpx; font-weight: 700; color: #B85E2A; }
|
||
.svc-info { flex: 1; margin-left: 18rpx; display: flex; flex-direction: column; }
|
||
.svc-name { font-size: 32rpx; font-weight: 700; color: #fff; }
|
||
.svc-price { margin-top: 6rpx; font-size: 24rpx; color: rgba(255,255,255,0.9); }
|
||
.urgent {
|
||
flex-shrink: 0; padding: 0 22rpx; line-height: 56rpx; border-radius: 28rpx;
|
||
background: rgba(255,255,255,0.2); border: 2rpx solid rgba(255,255,255,0.5);
|
||
}
|
||
.urgent.on { background: #fff; border-color: #fff; }
|
||
.urgent-txt { font-size: 22rpx; color: #fff; }
|
||
.urgent.on .urgent-txt { color: #E36B0C; font-weight: 600; }
|
||
.sec { margin: 22rpx 28rpx 0; padding: 22rpx 24rpx; background: #fff; border-radius: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,0.06); }
|
||
.sec-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; margin-bottom: 10rpx; }
|
||
.modes { display: flex; margin-top: 8rpx; }
|
||
.mode {
|
||
position: relative; flex: 1; min-width: 0; margin-right: 14rpx; padding: 20rpx 16rpx;
|
||
border-radius: 16rpx; background: #F8F8F8; border: 2rpx solid transparent;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.mode:last-child { margin-right: 0; }
|
||
.mode.on { background: #FFF3E6; border-color: #F0710D; }
|
||
.mode-name { font-size: 27rpx; font-weight: 700; color: #444; }
|
||
.mode.on .mode-name { color: #E36B0C; }
|
||
.mode-desc { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; line-height: 28rpx; }
|
||
.mode.on .mode-desc { color: #C07A3A; }
|
||
.mode-check {
|
||
position: absolute; right: 10rpx; top: 8rpx; width: 32rpx; height: 32rpx; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.mode-check-img { width: 28rpx; height: 28rpx;
|
||
}
|
||
.mode-note {
|
||
margin-top: 14rpx; padding: 14rpx 16rpx; border-radius: 12rpx;
|
||
background: #FAFAFA; font-size: 22rpx; color: #8A8A8A; line-height: 32rpx;
|
||
}
|
||
.addr { display: flex; align-items: center; }
|
||
.addr-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||
.addr-c { font-size: 28rpx; font-weight: 600; color: #222; }
|
||
.addr-d { margin-top: 6rpx; font-size: 24rpx; color: #666; line-height: 34rpx; }
|
||
.addr-x { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; }
|
||
.link { flex-shrink: 0; margin-left: 16rpx; font-size: 24rpx; color: #F0710D; }
|
||
.fi { display: flex; align-items: center; min-height: 88rpx; border-bottom: 1rpx solid #F4F4F4; }
|
||
.fk { width: 150rpx; font-size: 26rpx; color: #666; }
|
||
.fv { flex: 1; height: 88rpx; font-size: 28rpx; color: #222; }
|
||
.fv2 { flex: 1; font-size: 28rpx; color: #222; }
|
||
.arrow { font-size: 30rpx; color: #C4C4C4; }
|
||
.stepper { display: flex; align-items: center; }
|
||
.st-btn { width: 52rpx; height: 52rpx; line-height: 50rpx; text-align: center; border-radius: 26rpx; background: #F5F5F5; color: #666; font-size: 26rpx; }
|
||
.st-val { min-width: 60rpx; text-align: center; font-size: 28rpx; color: #222; }
|
||
.seg { display: flex; }
|
||
.seg-i { padding: 0 26rpx; line-height: 52rpx; margin-right: 12rpx; border-radius: 26rpx; background: #F5F5F5; font-size: 24rpx; color: #666; }
|
||
.seg-i.on { background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%); color: #fff; }
|
||
.save-btn { margin-top: 20rpx; height: 76rpx; display: flex; align-items: center; justify-content: center; border-radius: 38rpx; background: #FFF3E6; color: #E36B0C; font-size: 26rpx; font-weight: 600; }
|
||
.price-input-wrap { flex: 1; display: flex; align-items: center; }
|
||
.price-unit { font-size: 32rpx; font-weight: 700; color: #F0710D; margin-right: 8rpx; }
|
||
.price-input { flex: 1; }
|
||
.li { display: flex; justify-content: space-between; padding: 14rpx 0; border-bottom: 1rpx solid #F4F4F4; }
|
||
.li.total { border-bottom: none; }
|
||
.k { font-size: 26rpx; color: #666; }
|
||
.v { font-size: 26rpx; color: #333; }
|
||
.v.big { font-size: 34rpx; font-weight: 700; color: #F0710D; }
|
||
.fee-note { display: block; margin-top: 10rpx; font-size: 21rpx; color: #9A9A9A; line-height: 30rpx; }
|
||
.bar {
|
||
position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx;
|
||
padding: 0 28rpx; padding-bottom: env(safe-area-inset-bottom);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
background: #fff; box-shadow: 0 -6rpx 24rpx rgba(0,0,0,0.07);
|
||
}
|
||
.bar-l { display: flex; align-items: baseline; }
|
||
.bar-k { font-size: 24rpx; color: #666; }
|
||
.bar-p { margin-left: 8rpx; font-size: 38rpx; font-weight: 700; color: #F0710D; }
|
||
.bar-btn {
|
||
height: 84rpx; padding: 0 48rpx; display: flex; align-items: center;
|
||
border-radius: 42rpx; color: #fff; font-size: 30rpx; font-weight: 600;
|
||
background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%);
|
||
box-shadow: 0 8rpx 20rpx rgba(240,113,13,0.3);
|
||
}
|
||
.bar-btn.disabled { opacity: 0.7; }
|
||
</style>
|