Files
rent/apps/miniapp/src/pages/order-create/index.vue
T
2026-05-11 00:23:48 +08:00

949 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page-order-create">
<!-- 房源信息卡片 -->
<view class="room-card" v-if="room">
<view class="room-cover-wrapper">
<image class="room-cover" :src="room.coverImage || '/static/default-room.png'" mode="aspectFill" />
<view class="room-badge">精选</view>
</view>
<view class="room-info">
<text class="room-name">{{ room.name }}</text>
<tag-list v-if="roomTags.length" :tags="roomTags" type="primary" size="small" />
<view class="room-price-row">
<price-tag :price="room.price" size="medium" unit="/晚" />
<text class="room-stock-tip" v-if="maxRoomCount <= 3">仅剩{{ maxRoomCount }}</text>
</view>
</view>
</view>
<!-- 入住信息 -->
<view class="section">
<view class="section-header">
<view class="section-icon">📅</view>
<text class="section-title">入住信息</text>
</view>
<view class="date-selector" @tap="openCalendarPicker">
<view class="date-box">
<text class="date-label">入住日期</text>
<text class="date-value">{{ checkInLabel || '请选择' }}</text>
<text class="date-weekday">{{ checkInWeekday }}</text>
</view>
<view class="date-divider">
<view class="divider-line"></view>
<text class="divider-icon"></text>
<view class="divider-line"></view>
</view>
<view class="date-box">
<text class="date-label">离店日期</text>
<text class="date-value">{{ checkOutLabel || '请选择' }}</text>
<text class="date-weekday">{{ checkOutWeekday }}</text>
</view>
<view class="night-badge">
<text class="night-text">{{ nightCount }}</text>
</view>
</view>
<!-- 房间和人数选择 -->
<view class="count-section">
<view class="count-row">
<view class="count-left">
<text class="count-icon">🏠</text>
<view class="count-text-group">
<text class="count-label">房间套数</text>
<text class="count-sublabel">最多{{ maxRoomCount }}套可订</text>
</view>
</view>
<view class="count-stepper">
<view class="stepper-btn" :class="{ disabled: roomCount <= 1 }" @tap="decreaseRoomCount">
<text class="stepper-icon">-</text>
</view>
<text class="stepper-value">{{ roomCount }}</text>
<view class="stepper-btn" :class="{ disabled: roomCount >= maxRoomCount }" @tap="increaseRoomCount">
<text class="stepper-icon">+</text>
</view>
</view>
</view>
<view class="count-divider"></view>
<view class="count-row">
<view class="count-left">
<text class="count-icon">👥</text>
<view class="count-text-group">
<text class="count-label">入住人数</text>
<text class="count-sublabel">最多{{ maxGuestCount }}</text>
</view>
</view>
<view class="count-stepper">
<view class="stepper-btn" :class="{ disabled: guestCount <= 1 }" @tap="decreaseGuestCount">
<text class="stepper-icon">-</text>
</view>
<text class="stepper-value">{{ guestCount }}</text>
<view class="stepper-btn" :class="{ disabled: guestCount >= maxGuestCount }" @tap="increaseGuestCount">
<text class="stepper-icon">+</text>
</view>
</view>
</view>
</view>
<!-- 价格明细 -->
<view class="price-detail">
<view class="price-row">
<text class="price-label">房费 (¥{{ room?.price }} × {{ nightCount }} × {{ roomCount }})</text>
<text class="price-value">¥{{ totalPrice }}</text>
</view>
<view class="price-divider"></view>
<view class="price-row total">
<text class="price-label">合计</text>
<view class="price-total">
<text class="price-symbol">¥</text>
<text class="price-amount">{{ totalPrice }}</text>
</view>
</view>
</view>
</view>
<!-- 联系人信息 -->
<view class="section">
<view class="section-header">
<view class="section-icon">👤</view>
<text class="section-title">联系人信息</text>
</view>
<view class="form-group">
<view class="form-item">
<view class="form-label-row">
<text class="form-label">入住人姓名</text>
<text class="form-required">*</text>
</view>
<input
class="form-input"
v-model="contactName"
placeholder="请输入真实姓名"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<view class="form-label-row">
<text class="form-label">手机号码</text>
<text class="form-required">*</text>
</view>
<input
class="form-input"
v-model="contactPhone"
type="number"
placeholder="用于接收订单通知"
maxlength="11"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<view class="form-label-row">
<text class="form-label">身份证号</text>
<text class="form-optional">选填</text>
</view>
<input
class="form-input"
v-model="contactIdCard"
placeholder="入住时需提供"
maxlength="18"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<view class="form-label-row">
<text class="form-label">特殊需求</text>
<text class="form-optional">选填</text>
</view>
<textarea
class="form-textarea"
v-model="remark"
placeholder="如需加床、禁烟房等,请在此备注"
placeholder-class="input-placeholder"
/>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-section">
<view class="tips-title">📌 温馨提示</view>
<view class="tips-content">
<text class="tips-item"> 请确保入住人信息真实有效</text>
<text class="tips-item"> 入住时需出示有效身份证件</text>
<text class="tips-item"> 如需取消订单请提前联系商家</text>
</view>
</view>
<!-- 底部提交栏 -->
<view class="submit-bar">
<view class="submit-left">
<text class="submit-label">应付金额</text>
<view class="submit-price">
<text class="submit-symbol">¥</text>
<text class="submit-amount">{{ totalPrice }}</text>
</view>
</view>
<view class="submit-btn" @tap="handleSubmit">
<text class="submit-text">提交订单</text>
</view>
</view>
<!-- 日期选择弹窗 -->
<RoomCalendarPicker
ref="calendarPickerRef"
:roomId="roomId"
:checkIn="checkInDate"
:checkOut="checkOutDate"
@change="onDateChange"
/>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { getRoomDetail, getRoomCalendar } from '@/api/user/room';
import { createOrder } from '@/api/user/order';
import RoomCalendarPicker from '@/components/RoomCalendarPicker.vue';
import PriceTag from '@/components/business/PriceTag.vue';
import TagList from '@/components/business/TagList.vue';
const roomId = ref(0);
const merchantId = ref(0);
const checkInDate = ref('');
const checkOutDate = ref('');
const room = ref<any>(null);
const calendarPickerRef = ref();
const roomCount = ref(1);
const guestCount = ref(1);
const contactName = ref('');
const contactPhone = ref('');
const contactIdCard = ref('');
const remark = ref('');
// 房态数据:记录每日剩余库存
const calendarStock = ref<Map<string, number>>(new Map());
const typeLabels: Record<string, string> = {
hotel: '酒店',
homestay: '民宿',
apartment: '公寓',
hostel: '青旅',
};
const weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
// 房源标签
const roomTags = computed(() => {
const tags = [];
if (room.value?.type) {
tags.push(typeLabels[room.value.type] || room.value.type);
}
if (room.value?.bedType) {
tags.push(room.value.bedType);
}
return tags;
});
const nightCount = computed(() => {
if (!checkInDate.value || !checkOutDate.value) return 1;
const diff = new Date(checkOutDate.value).getTime() - new Date(checkInDate.value).getTime();
return Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24)));
});
const checkInLabel = computed(() => {
if (!checkInDate.value) return '';
const d = new Date(checkInDate.value);
return `${d.getMonth() + 1}${d.getDate()}`;
});
const checkOutLabel = computed(() => {
if (!checkOutDate.value) return '';
const d = new Date(checkOutDate.value);
return `${d.getMonth() + 1}${d.getDate()}`;
});
const checkInWeekday = computed(() => {
if (!checkInDate.value) return '';
const d = new Date(checkInDate.value);
return weekdayLabels[d.getDay()];
});
const checkOutWeekday = computed(() => {
if (!checkOutDate.value) return '';
const d = new Date(checkOutDate.value);
return weekdayLabels[d.getDay()];
});
// 最大房间套数:取入住日期区间内每日最小剩余库存
const maxRoomCount = computed(() => {
if (!checkInDate.value || !checkOutDate.value || calendarStock.value.size === 0) return 1;
const checkIn = new Date(checkInDate.value);
const checkOut = new Date(checkOutDate.value);
let minStock = Infinity;
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const stock = calendarStock.value.get(dateStr);
if (stock === undefined || stock <= 0) return 1;
minStock = Math.min(minStock, stock);
}
return minStock === Infinity ? 1 : minStock;
});
// 最大入住人数:房源maxGuests * 房间套数
const maxGuestCount = computed(() => {
const base = room.value?.maxGuests || 1;
return base * roomCount.value;
});
const totalPrice = computed(() => {
if (!room.value) return 0;
return (room.value.price * nightCount.value * roomCount.value).toFixed(2);
});
function initFromUrl() {
const pages = getCurrentPages();
const page = pages[pages.length - 1] as any;
const options = page.options || page.$page?.options || {};
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
roomId.value = Number(options.roomId) || 0;
merchantId.value = Number(options.merchantId) || 0;
checkInDate.value = options.checkIn || fmt(today);
checkOutDate.value = options.checkOut || fmt(tomorrow);
}
onMounted(() => {
initFromUrl();
if (roomId.value) {
fetchRoom();
fetchCalendarStock();
}
});
// 日期变化时重新获取房态
watch([checkInDate, checkOutDate], () => {
if (roomId.value) {
fetchCalendarStock();
}
});
async function fetchRoom() {
try {
const res = await getRoomDetail(roomId.value);
room.value = res.data;
} catch (e) {
console.error(e);
}
}
async function fetchCalendarStock() {
if (!checkInDate.value || !checkOutDate.value) return;
try {
const res = await getRoomCalendar(roomId.value, checkInDate.value, checkOutDate.value);
const data = res.data || [];
const map = new Map<string, number>();
for (const item of data) {
map.set(item.date, (item.stock || 0) - (item.sold || 0));
}
calendarStock.value = map;
} catch (e) {
console.error(e);
}
}
function decreaseRoomCount() {
if (roomCount.value > 1) {
roomCount.value--;
// 如果入住人数超过新的最大值,自动调整
if (guestCount.value > maxGuestCount.value) {
guestCount.value = maxGuestCount.value;
}
}
}
function increaseRoomCount() {
if (roomCount.value < maxRoomCount.value) {
roomCount.value++;
}
}
function decreaseGuestCount() {
if (guestCount.value > 1) {
guestCount.value--;
}
}
function increaseGuestCount() {
if (guestCount.value < maxGuestCount.value) {
guestCount.value++;
}
}
async function handleSubmit() {
if (!contactName.value) {
uni.showToast({ title: '请输入入住人姓名', icon: 'none' });
return;
}
if (!contactPhone.value || !/^1[3-9]\d{9}$/.test(contactPhone.value)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
try {
const res = await createOrder({
roomId: roomId.value,
checkInDate: checkInDate.value,
checkOutDate: checkOutDate.value,
roomCount: roomCount.value,
guestCount: guestCount.value,
contactName: contactName.value,
contactPhone: contactPhone.value,
contactIdCard: contactIdCard.value || undefined,
remark: remark.value,
});
uni.showToast({ title: '订单创建成功', icon: 'success' });
const orderId = res.data?.id;
if (orderId) {
uni.redirectTo({ url: `/pages/order-detail/index?id=${orderId}` });
}
} catch (e: any) {
uni.showToast({ title: e.message || '订单创建失败', icon: 'none' });
}
}
function openCalendarPicker() {
calendarPickerRef.value?.open();
}
function onDateChange(checkIn: string, checkOut: string) {
checkInDate.value = checkIn;
checkOutDate.value = checkOut;
}
</script>
<style lang="scss" scoped>
@import '@/static/styles/common.scss';
.page-order-create {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
padding-bottom: 160rpx;
}
// 房源卡片
.room-card {
display: flex;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
margin: 24rpx;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4rpx;
background: linear-gradient(90deg, $primary-color 0%, #ff6b9d 100%);
}
}
.room-cover-wrapper {
position: relative;
flex-shrink: 0;
}
.room-cover {
width: 180rpx;
height: 180rpx;
border-radius: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.room-badge {
position: absolute;
top: 8rpx;
left: 8rpx;
background: linear-gradient(135deg, $primary-color 0%, #ff6b9d 100%);
color: #ffffff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-weight: 600;
box-shadow: 0 2rpx 8rpx rgba(255, 107, 157, 0.3);
}
.room-info {
flex: 1;
margin-left: 24rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.room-name {
font-size: 32rpx;
font-weight: 700;
color: $text-primary;
line-height: 1.4;
margin-bottom: 12rpx;
}
.room-price-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.room-stock-tip {
font-size: 22rpx;
color: #ff4d4f;
background: #fff1f0;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
}
// 通用区块
.section {
background: #ffffff;
margin: 24rpx;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 32rpx;
}
.section-icon {
font-size: 40rpx;
margin-right: 12rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: $text-primary;
}
// 日期选择器
.date-selector {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border-radius: 20rpx;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
position: relative;
border: 2rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
}
}
.date-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.date-label {
font-size: 24rpx;
color: $text-secondary;
margin-bottom: 8rpx;
}
.date-value {
font-size: 32rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 4rpx;
}
.date-weekday {
font-size: 22rpx;
color: $text-tertiary;
}
.date-divider {
display: flex;
flex-direction: column;
align-items: center;
margin: 0 16rpx;
}
.divider-line {
width: 1rpx;
height: 20rpx;
background: #e8e8e8;
}
.divider-icon {
font-size: 28rpx;
color: $primary-color;
margin: 4rpx 0;
}
.night-badge {
position: absolute;
top: -12rpx;
right: 24rpx;
background: linear-gradient(135deg, $primary-color 0%, #ff6b9d 100%);
padding: 8rpx 20rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.3);
}
.night-text {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
// 数量选择
.count-section {
margin-top: 24rpx;
background: #f8f9fa;
border-radius: 20rpx;
padding: 8rpx 0;
}
.count-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx;
}
.count-left {
display: flex;
align-items: center;
flex: 1;
}
.count-icon {
font-size: 40rpx;
margin-right: 16rpx;
}
.count-text-group {
display: flex;
flex-direction: column;
}
.count-label {
font-size: 28rpx;
font-weight: 600;
color: $text-primary;
margin-bottom: 4rpx;
}
.count-sublabel {
font-size: 22rpx;
color: $text-tertiary;
}
.count-divider {
height: 1rpx;
background: #e8e8e8;
margin: 0 24rpx;
}
.count-stepper {
display: flex;
align-items: center;
background: #ffffff;
border-radius: 16rpx;
padding: 4rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.stepper-btn {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
background: linear-gradient(135deg, $primary-color 0%, #ff6b9d 100%);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:active {
transform: scale(0.9);
}
&.disabled {
background: #f0f0f0;
opacity: 0.5;
}
}
.stepper-icon {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
}
.stepper-value {
min-width: 80rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: $text-primary;
}
// 价格明细
.price-detail {
margin-top: 32rpx;
background: linear-gradient(135deg, #fff7e6 0%, #fff1f0 100%);
border-radius: 20rpx;
padding: 24rpx;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
&.total {
margin-top: 16rpx;
padding-top: 16rpx;
}
}
.price-label {
font-size: 26rpx;
color: $text-secondary;
}
.price-value {
font-size: 28rpx;
font-weight: 600;
color: $text-primary;
}
.price-divider {
height: 1rpx;
background: rgba(0, 0, 0, 0.06);
margin: 16rpx 0;
}
.price-total {
display: flex;
align-items: baseline;
}
.price-symbol {
font-size: 28rpx;
font-weight: 700;
color: $primary-color;
margin-right: 4rpx;
}
.price-amount {
font-size: 40rpx;
font-weight: 700;
color: $primary-color;
}
// 表单
.form-group {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.form-item {
display: flex;
flex-direction: column;
}
.form-label-row {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.form-label {
font-size: 28rpx;
font-weight: 600;
color: $text-primary;
}
.form-required {
font-size: 28rpx;
color: #ff4d4f;
margin-left: 4rpx;
}
.form-optional {
font-size: 22rpx;
color: $text-tertiary;
margin-left: 8rpx;
background: #f0f0f0;
padding: 2rpx 8rpx;
border-radius: 4rpx;
}
.form-input {
height: 88rpx;
background: #f8f9fa;
border: 2rpx solid #e8e8e8;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: $text-primary;
transition: all 0.3s ease;
&:focus {
background: #ffffff;
border-color: $primary-color;
box-shadow: 0 0 0 4rpx rgba(255, 107, 157, 0.1);
}
}
.form-textarea {
min-height: 160rpx;
background: #f8f9fa;
border: 2rpx solid #e8e8e8;
border-radius: 16rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: $text-primary;
line-height: 1.6;
transition: all 0.3s ease;
&:focus {
background: #ffffff;
border-color: $primary-color;
box-shadow: 0 0 0 4rpx rgba(255, 107, 157, 0.1);
}
}
.input-placeholder {
color: #bfbfbf;
}
// 温馨提示
.tips-section {
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
margin: 24rpx;
border-radius: 20rpx;
padding: 24rpx;
border-left: 6rpx solid #1890ff;
}
.tips-title {
font-size: 28rpx;
font-weight: 700;
color: #1890ff;
margin-bottom: 16rpx;
}
.tips-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.tips-item {
font-size: 24rpx;
color: #595959;
line-height: 1.6;
}
// 底部提交栏
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
display: flex;
align-items: center;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.submit-left {
flex: 1;
display: flex;
flex-direction: column;
}
.submit-label {
font-size: 24rpx;
color: $text-secondary;
margin-bottom: 4rpx;
}
.submit-price {
display: flex;
align-items: baseline;
}
.submit-symbol {
font-size: 28rpx;
font-weight: 700;
color: $primary-color;
margin-right: 4rpx;
}
.submit-amount {
font-size: 48rpx;
font-weight: 700;
color: $primary-color;
}
.submit-btn {
background: linear-gradient(135deg, $primary-color 0%, #ff6b9d 100%);
border-radius: 48rpx;
height: 96rpx;
width: 280rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 157, 0.4);
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.3);
}
}
.submit-text {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
</style>