This commit is contained in:
2026-04-23 00:48:07 +08:00
parent 7473fdcc03
commit 3284321919
21 changed files with 2703 additions and 31 deletions
+10
View File
@@ -7,6 +7,8 @@ export function createOrder(data: {
contactName: string;
contactPhone: string;
roomCount?: number;
guestCount?: number;
contactIdCard?: string;
couponId?: number;
remark?: string;
paymentMethod?: string;
@@ -25,3 +27,11 @@ export function getOrderDetail(id: number) {
export function cancelOrder(id: number, reason: string) {
return put(`/orders/${id}/cancel`, { reason });
}
export function refundOrder(id: number, reason: string) {
return put(`/orders/${id}/refund`, { reason });
}
export function payOrder(id: number, paymentMethod: 'wechat' | 'alipay' | 'balance' = 'wechat') {
return put(`/orders/${id}/pay`, { paymentMethod });
}
+4
View File
@@ -23,6 +23,10 @@ export function getRoomDetail(id: number) {
return get(`/rooms/${id}`);
}
export function getRoomCalendar(id: number, startDate: string, endDate: string) {
return get(`/rooms/${id}/calendar`, { startDate, endDate });
}
// 商家管理接口(需要 sellerToken
export function getMerchantRooms(params: any) {
return request({ url: '/seller/rooms', method: 'GET', data: params, useSellerToken: true });
+72
View File
@@ -0,0 +1,72 @@
import { request } from '@/utils/request';
// 商家订单列表
export function getSellerOrders(params: {
page?: number;
pageSize?: number;
status?: string;
orderNo?: string;
}) {
return request({
url: '/seller/orders',
method: 'GET',
data: params,
useSellerToken: true,
});
}
// 商家订单详情
export function getSellerOrderDetail(id: number) {
return request({
url: `/seller/orders/${id}`,
method: 'GET',
useSellerToken: true,
});
}
// 确认订单
export function confirmOrder(id: number) {
return request({
url: `/seller/orders/${id}/confirm`,
method: 'PUT',
useSellerToken: true,
});
}
// 拒绝订单
export function rejectOrder(id: number, reason: string) {
return request({
url: `/seller/orders/${id}/reject`,
method: 'PUT',
data: { reason },
useSellerToken: true,
});
}
// 办理入住
export function checkinOrder(id: number) {
return request({
url: `/seller/orders/${id}/checkin`,
method: 'PUT',
useSellerToken: true,
});
}
// 同意退款
export function approveRefund(id: number) {
return request({
url: `/seller/orders/${id}/approve-refund`,
method: 'PUT',
useSellerToken: true,
});
}
// 拒绝退款
export function rejectRefund(id: number, reason: string) {
return request({
url: `/seller/orders/${id}/reject-refund`,
method: 'PUT',
data: { reason },
useSellerToken: true,
});
}
@@ -0,0 +1,418 @@
<template>
<view v-if="visible" class="popup-mask" @tap="close">
<view class="picker-panel" @tap.stop>
<view class="picker-header">
<text class="picker-cancel" @tap="close">取消</text>
<text class="picker-title">选择入住日期</text>
<text class="picker-confirm" @tap="confirmDates">确定</text>
</view>
<!-- 月份切换 -->
<view class="month-nav">
<text class="nav-btn" @tap="prevMonth"></text>
<text class="month-title">{{ currentMonthLabel }}</text>
<text class="nav-btn" @tap="nextMonth"></text>
</view>
<!-- 星期标题 -->
<view class="week-header">
<text class="week-item" v-for="w in weekDays" :key="w">{{ w }}</text>
</view>
<!-- 日期网格 -->
<view class="calendar-grid">
<view
v-for="(day, idx) in calendarDays"
:key="idx"
class="day-cell"
:class="{
'empty': !day.date,
'disabled': day.disabled,
'selected-start': day.date === tempCheckIn,
'selected-end': day.date === tempCheckOut,
'in-range': day.inRange,
'full': day.isFull,
}"
@tap="handleDayTap(day)"
>
<text class="day-num" v-if="day.date">{{ day.dayNum }}</text>
<text class="day-price" v-if="day.date && !day.disabled">¥{{ day.price }}</text>
<text class="day-stock" v-if="day.date && !day.disabled && !day.isFull">{{ day.stock }}</text>
<text class="day-full" v-if="day.isFull"></text>
</view>
</view>
<!-- 提示信息 -->
<view class="picker-footer">
<view class="summary-row">
<text class="summary-text">入住: {{ tempCheckIn || '请选择' }} ~ 离店: {{ tempCheckOut || '请选择' }}</text>
<text class="summary-nights" v-if="tempNights > 0">{{ tempNights }}</text>
</view>
<view class="tip-row">
<text class="tip-text">点击日期选择入住/离店时间</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { getRoomCalendar } from '@/api/room';
const props = defineProps<{
roomId: number;
checkIn: string;
checkOut: string;
}>();
const emit = defineEmits<{
(e: 'change', checkIn: string, checkOut: string): void;
}>();
const visible = ref(false);
const tempCheckIn = ref(props.checkIn);
const tempCheckOut = ref(props.checkOut);
const currentMonth = ref(new Date().getMonth());
const currentYear = ref(new Date().getFullYear());
const calendarData = ref<Map<string, { price: number; stock: number; status: string }>>(new Map());
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
const currentMonthLabel = computed(() => {
return `${currentYear.value}${currentMonth.value + 1}`;
});
const tempNights = computed(() => {
if (!tempCheckIn.value || !tempCheckOut.value) return 0;
const diff = new Date(tempCheckOut.value).getTime() - new Date(tempCheckIn.value).getTime();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
});
// 生成日历网格数据
const calendarDays = computed(() => {
const year = currentYear.value;
const month = currentMonth.value;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startWeekday = firstDay.getDay();
const totalDays = lastDay.getDate();
const days: any[] = [];
// 前面空白填充
for (let i = 0; i < startWeekday; i++) {
days.push({ date: null });
}
// 当月每一天
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let d = 1; d <= totalDays; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const dateObj = new Date(year, month, d);
const calData = calendarData.value.get(dateStr);
// 判断是否可选(今天及以后,且有库存)
const isPast = dateObj < today;
const isFull = calData ? (calData.status === 'unavailable' || calData.stock <= 0) : true;
// 判断是否在选中范围内
const inRange = tempCheckIn.value && tempCheckOut.value &&
dateStr > tempCheckIn.value && dateStr < tempCheckOut.value;
days.push({
date: dateStr,
dayNum: d,
price: calData?.price ?? 0,
stock: calData?.stock ?? 0,
status: calData?.status ?? 'available',
disabled: isPast,
isFull,
inRange,
});
}
return days;
});
watch(() => [props.checkIn, props.checkOut], ([ci, co]) => {
tempCheckIn.value = ci;
tempCheckOut.value = co;
});
function open() {
tempCheckIn.value = props.checkIn;
tempCheckOut.value = props.checkOut;
visible.value = true;
// 初始化月份为入住日期所在月份
if (props.checkIn) {
const d = new Date(props.checkIn);
currentYear.value = d.getFullYear();
currentMonth.value = d.getMonth();
}
fetchCalendarData();
}
function close() {
visible.value = false;
}
function confirmDates() {
if (!tempCheckIn.value || !tempCheckOut.value) {
uni.showToast({ title: '请选择入住和离店日期', icon: 'none' });
return;
}
emit('change', tempCheckIn.value, tempCheckOut.value);
close();
}
function handleDayTap(day: any) {
if (!day.date || day.disabled || day.isFull) return;
if (!tempCheckIn.value) {
// 选择入住日期
tempCheckIn.value = day.date;
tempCheckOut.value = '';
} else if (!tempCheckOut.value) {
// 选择离店日期(必须大于入住日期)
if (day.date <= tempCheckIn.value) {
// 如果点击的是入住日期之前的,重新选择入住日期
tempCheckIn.value = day.date;
} else {
tempCheckOut.value = day.date;
}
} else {
// 已完成选择,重新开始
tempCheckIn.value = day.date;
tempCheckOut.value = '';
}
}
function prevMonth() {
if (currentMonth.value === 0) {
currentMonth.value = 11;
currentYear.value--;
} else {
currentMonth.value--;
}
fetchCalendarData();
}
function nextMonth() {
if (currentMonth.value === 11) {
currentMonth.value = 0;
currentYear.value++;
} else {
currentMonth.value++;
}
fetchCalendarData();
}
async function fetchCalendarData() {
const year = currentYear.value;
const month = currentMonth.value;
const startDate = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const endDate = `${year}-${String(month + 1).padStart(2, '0')}-${String(new Date(year, month + 1, 0).getDate()).padStart(2, '0')}`;
try {
const res = await getRoomCalendar(props.roomId, startDate, endDate);
const data = res.data || [];
const map = new Map();
for (const item of data) {
map.set(item.date, {
price: item.price,
stock: item.stock - item.sold, // 剩余库存
status: item.status,
});
}
calendarData.value = map;
} catch (e) {
console.error(e);
}
}
defineExpose({ open, close });
</script>
<style lang="scss" scoped>
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: flex-end;
}
.picker-panel {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
width: 100%;
max-height: 80vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #eee;
}
.picker-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.picker-cancel {
font-size: 28rpx;
color: #999;
}
.picker-confirm {
font-size: 28rpx;
color: #FF6B35;
font-weight: 600;
}
.month-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 32rpx;
}
.nav-btn {
font-size: 36rpx;
color: #666;
padding: 8rpx 16rpx;
}
.month-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.week-header {
display: flex;
padding: 16rpx 24rpx;
background: #f9f9f9;
}
.week-item {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #999;
}
.calendar-grid {
display: flex;
flex-wrap: wrap;
padding: 16rpx 24rpx;
}
.day-cell {
width: calc(100% / 7);
display: flex;
flex-direction: column;
align-items: center;
padding: 12rpx 0;
border-radius: 8rpx;
position: relative;
&.empty {
visibility: hidden;
}
&.disabled {
opacity: 0.4;
}
&.selected-start,
&.selected-end {
background: #FF6B35;
.day-num, .day-price, .day-stock {
color: #fff;
}
}
&.in-range {
background: #fff1eb;
}
&.full {
.day-num, .day-price {
color: #999;
}
.day-full {
display: block;
}
}
}
.day-num {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.day-price {
font-size: 20rpx;
color: #FF6B35;
margin-top: 2rpx;
}
.day-stock {
font-size: 18rpx;
color: #52c41a;
margin-top: 2rpx;
}
.day-full {
display: none;
font-size: 18rpx;
color: #ff4d4f;
margin-top: 2rpx;
}
.picker-footer {
padding: 20rpx 32rpx;
border-top: 1rpx solid #eee;
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-text {
font-size: 26rpx;
color: #666;
}
.summary-nights {
font-size: 26rpx;
color: #FF6B35;
font-weight: 600;
}
.tip-row {
margin-top: 12rpx;
}
.tip-text {
font-size: 24rpx;
color: #999;
}
</style>
+84 -8
View File
@@ -1,11 +1,16 @@
<template>
<view class="room-card" @tap="$emit('tap')">
<view class="room-card" :class="{ 'is-full': !isAvailable }">
<image
class="room-cover"
:src="room.coverImage || '/static/default-room.png'"
mode="aspectFill"
@tap="handleTap"
/>
<view class="room-info">
<!-- 满房遮罩 -->
<view v-if="!isAvailable" class="full-overlay" @tap="handleTap">
<text class="full-text">所选日期已满房</text>
</view>
<view class="room-info" @tap="handleTap">
<text class="room-name text-ellipsis">{{ room.name }}</text>
<view class="room-meta">
<text class="room-address text-ellipsis" v-if="room.address">{{ room.address }}</text>
@@ -20,10 +25,15 @@
<text class="rating-score">{{ room.rating }}</text>
<text class="rating-count" v-if="room.reviewCount">({{ room.reviewCount }}条评价)</text>
</view>
<view class="room-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ room.price }}</text>
<text class="price-unit">/</text>
<view class="room-price-row">
<view class="room-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ room.price }}</text>
<text class="price-unit">/</text>
</view>
<view class="book-btn" :class="{ 'disabled': !isAvailable }" @tap.stop="handleBook">
<text class="book-text"></text>
</view>
</view>
</view>
</view>
@@ -31,7 +41,9 @@
</template>
<script setup lang="ts">
defineProps<{
import { computed } from 'vue';
const props = defineProps<{
room: {
id: number;
name: string;
@@ -42,24 +54,38 @@ defineProps<{
rating?: number;
reviewCount?: number;
address?: string;
isAvailable?: boolean;
};
}>();
defineEmits<{
const emit = defineEmits<{
(e: 'tap'): void;
(e: 'book'): void;
}>();
const isAvailable = computed(() => props.room.isAvailable !== false);
const typeLabels: Record<string, string> = {
hotel: '酒店',
homestay: '民宿',
apartment: '公寓',
hostel: '青旅',
};
function handleTap() {
emit('tap');
}
function handleBook() {
if (!isAvailable.value) return;
emit('book');
}
</script>
<style lang="scss" scoped>
.room-card {
display: flex;
position: relative;
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
@@ -67,12 +93,36 @@ const typeLabels: Record<string, string> = {
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.room-card.is-full {
opacity: 0.85;
}
.room-cover {
width: 240rpx;
height: 220rpx;
flex-shrink: 0;
}
.full-overlay {
position: absolute;
top: 0;
left: 0;
width: 240rpx;
height: 220rpx;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.full-text {
font-size: 24rpx;
color: #fff;
background: rgba(255, 68, 68, 0.9);
padding: 8rpx 16rpx;
border-radius: 8rpx;
}
.room-info {
flex: 1;
padding: 16rpx 20rpx;
@@ -159,4 +209,30 @@ const typeLabels: Record<string, string> = {
font-size: 20rpx;
color: #999;
}
.room-price-row {
display: flex;
align-items: flex-end;
gap: 16rpx;
}
.book-btn {
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.book-btn.disabled {
background: #ccc;
}
.book-text {
font-size: 28rpx;
font-weight: 700;
color: #fff;
}
</style>
+18
View File
@@ -49,6 +49,12 @@
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/order-create/index",
"style": {
"navigationBarTitleText": "填写订单"
}
},
{
"path": "pages/mine/index",
"style": {
@@ -96,6 +102,18 @@
"style": {
"navigationBarTitleText": "房量房价"
}
},
{
"path": "pages/seller/orders",
"style": {
"navigationBarTitleText": "订单管理"
}
},
{
"path": "pages/seller/order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
}
],
"globalStyle": {
@@ -89,6 +89,7 @@
:key="room.id"
:room="room"
@tap="goRoomDetail(room.id)"
@book="goOrderCreate(room)"
/>
</view>
</view>
@@ -249,6 +250,20 @@ function copyAddress() {
function goRoomDetail(roomId: number) {
uni.navigateTo({ url: `/pages/room-detail/index?id=${roomId}` });
}
function goOrderCreate(room: any) {
if (room.isAvailable === false) {
uni.showToast({ title: '所选日期已满房', icon: 'none' });
return;
}
const params = [
`roomId=${room.id}`,
`merchantId=${merchantId.value}`,
`checkIn=${checkInDate.value}`,
`checkOut=${checkOutDate.value}`,
].join('&');
uni.navigateTo({ url: `/pages/order-create/index?${params}` });
}
</script>
<style lang="scss" scoped>
@@ -0,0 +1,578 @@
<template>
<view class="page-order-create">
<!-- 房源信息 -->
<view class="room-card" v-if="room">
<image class="room-cover" :src="room.coverImage || '/static/default-room.png'" mode="aspectFill" />
<view class="room-info">
<text class="room-name">{{ room.name }}</text>
<view class="room-tags" v-if="room.type || room.bedType">
<text class="tag" v-if="room.type">{{ typeLabels[room.type] || room.type }}</text>
<text class="tag" v-if="room.bedType">{{ room.bedType }}</text>
</view>
<view class="room-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ room.price }}</text>
<text class="price-unit">/</text>
</view>
</view>
</view>
<!-- 入住信息 -->
<view class="section">
<view class="section-title">入住信息</view>
<view class="date-row" @tap="openCalendarPicker">
<view class="date-item">
<text class="date-label">入住</text>
<text class="date-value">{{ checkInLabel }}</text>
</view>
<view class="date-item">
<text class="date-label">离店</text>
<text class="date-value">{{ checkOutLabel }}</text>
</view>
<view class="night-badge">
<text class="night-text">{{ nightCount }}</text>
</view>
</view>
<!-- 房间套数选择 -->
<view class="count-row">
<view class="count-item">
<text class="count-label">房间套数</text>
<view class="count-stepper">
<text class="stepper-btn" :class="{ disabled: roomCount <= 1 }" @tap="decreaseRoomCount">-</text>
<text class="stepper-value">{{ roomCount }}</text>
<text class="stepper-btn" :class="{ disabled: roomCount >= maxRoomCount }" @tap="increaseRoomCount">+</text>
</view>
<text class="count-tip">最多{{ maxRoomCount }}</text>
</view>
</view>
<!-- 入住人数选择 -->
<view class="count-row">
<view class="count-item">
<text class="count-label">入住人数</text>
<view class="count-stepper">
<text class="stepper-btn" :class="{ disabled: guestCount <= 1 }" @tap="decreaseGuestCount">-</text>
<text class="stepper-value">{{ guestCount }}</text>
<text class="stepper-btn" :class="{ disabled: guestCount >= maxGuestCount }" @tap="increaseGuestCount">+</text>
</view>
<text class="count-tip">最多{{ maxGuestCount }}</text>
</view>
</view>
<view class="price-summary">
<text class="summary-label">房费合计</text>
<text class="summary-value">¥{{ totalPrice }}</text>
</view>
</view>
<!-- 联系人信息 -->
<view class="section">
<view class="section-title">联系人信息</view>
<view class="form-item">
<text class="form-label">姓名</text>
<input class="form-input" v-model="contactName" placeholder="请输入入住人姓名" />
</view>
<view class="form-item">
<text class="form-label">手机号</text>
<input class="form-input" v-model="contactPhone" type="number" placeholder="请输入手机号" maxlength="11" />
</view>
<view class="form-item">
<text class="form-label">身份证号</text>
<input class="form-input" v-model="contactIdCard" placeholder="请输入身份证号(选填)" maxlength="18" />
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea class="form-textarea" v-model="remark" placeholder="如有特殊需求请备注" />
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-bar">
<view class="total-row">
<text class="total-label">应付金额</text>
<text class="total-value">¥{{ totalPrice }}</text>
</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/room';
import { createOrder } from '@/api/order';
import RoomCalendarPicker from '@/components/RoomCalendarPicker.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 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 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>
.page-order-create {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.room-card {
display: flex;
background: #fff;
margin: 24rpx;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.room-cover {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.room-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.room-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.room-tags {
display: flex;
gap: 10rpx;
}
.tag {
font-size: 20rpx;
color: #FF6B35;
background: #fff1eb;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
.room-price {
display: flex;
align-items: baseline;
}
.price-symbol {
font-size: 24rpx;
color: #FF6B35;
}
.price-value {
font-size: 36rpx;
font-weight: 700;
color: #FF6B35;
}
.price-unit {
font-size: 22rpx;
color: #999;
margin-left: 4rpx;
}
.section {
background: #fff;
margin: 24rpx;
border-radius: 16rpx;
padding: 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.date-row {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.date-item {
flex: 1;
display: flex;
flex-direction: column;
}
.date-label {
font-size: 24rpx;
color: #999;
}
.date-value {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-top: 4rpx;
}
.night-badge {
background: #fff1eb;
padding: 8rpx 16rpx;
border-radius: 8rpx;
}
.night-text {
font-size: 24rpx;
color: #FF6B35;
}
.count-row {
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.count-item {
display: flex;
align-items: center;
}
.count-label {
font-size: 26rpx;
color: #333;
flex-shrink: 0;
width: 140rpx;
}
.count-stepper {
display: flex;
align-items: center;
margin-left: 16rpx;
}
.stepper-btn {
width: 56rpx;
height: 56rpx;
border-radius: 8rpx;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #666;
}
.stepper-btn.disabled {
color: #ccc;
background: #f0f0f0;
}
.stepper-value {
min-width: 60rpx;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.count-tip {
font-size: 22rpx;
color: #999;
margin-left: 16rpx;
}
.price-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16rpx;
}
.summary-label {
font-size: 26rpx;
color: #666;
}
.summary-value {
font-size: 36rpx;
font-weight: 700;
color: #FF6B35;
}
.form-item {
display: flex;
flex-direction: column;
margin-bottom: 20rpx;
}
.form-label {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
}
.form-input {
height: 80rpx;
background: #f5f7fa;
border-radius: 8rpx;
padding: 0 16rpx;
font-size: 28rpx;
}
.form-textarea {
height: 120rpx;
background: #f5f7fa;
border-radius: 8rpx;
padding: 16rpx;
font-size: 28rpx;
}
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 24rpx;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
}
.total-row {
flex: 1;
display: flex;
align-items: center;
}
.total-label {
font-size: 26rpx;
color: #666;
}
.total-value {
font-size: 40rpx;
font-weight: 700;
color: #FF6B35;
margin-left: 8rpx;
}
.submit-btn {
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
border-radius: 44rpx;
height: 88rpx;
width: 240rpx;
display: flex;
align-items: center;
justify-content: center;
}
.submit-text {
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
</style>
+235 -2
View File
@@ -32,6 +32,14 @@
<text class="info-label">入住晚数</text>
<text class="info-value">{{ order.nights }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">房间套数</text>
<text class="info-value">{{ order.roomCount || 1 }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">入住人数</text>
<text class="info-value">{{ order.guestCount || 1 }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">联系人</text>
<text class="info-value">{{ order.contactName }}</text>
@@ -40,6 +48,10 @@
<text class="info-label">联系电话</text>
<text class="info-value">{{ order.contactPhone }}</text>
</view>
<view class="info-row flex-between" v-if="order.contactIdCard">
<text class="info-label">身份证号</text>
<text class="info-value">{{ order.contactIdCard }}</text>
</view>
</view>
<!-- 费用明细 -->
@@ -74,22 +86,64 @@
<text class="info-label">下单时间</text>
<text class="info-value">{{ order.createdAt }}</text>
</view>
<view class="info-row flex-between" v-if="order.paymentMethod">
<text class="info-label">支付方式</text>
<text class="info-value">{{ paymentMethodLabels[order.paymentMethod] }}</text>
</view>
<view class="info-row flex-between" v-if="order.paidAt">
<text class="info-label">支付时间</text>
<text class="info-value">{{ order.paidAt }}</text>
</view>
<view class="info-row flex-between" v-if="order.cancelReason">
<text class="info-label">取消原因</text>
<text class="info-value">{{ order.cancelReason }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="actions" v-if="order.status === 'pending_pay'">
<button class="btn-cancel" @tap="handleCancel">取消订单</button>
<button class="btn-pay">去支付</button>
<button class="btn-pay" @tap="handlePay">去支付</button>
</view>
<view class="actions" v-if="order.status === 'pending_confirm' || order.status === 'pending_checkin'">
<button class="btn-refund" @tap="handleRefund">申请退款</button>
</view>
</view>
<view v-else class="loading">加载中...</view>
<!-- 支付弹窗 -->
<view v-if="showPayModal" class="pay-modal" @tap="closePayModal">
<view class="pay-panel" @tap.stop>
<view class="pay-header">
<text class="pay-title">选择支付方式</text>
<text class="pay-close" @tap="closePayModal">×</text>
</view>
<view class="pay-amount">
<text class="pay-label">支付金额</text>
<text class="pay-value">¥{{ order.payAmount }}</text>
</view>
<view class="pay-methods">
<view class="pay-method" :class="{ active: payMethod === 'wechat' }" @tap="payMethod = 'wechat'">
<text class="method-icon">💳</text>
<text class="method-name">微信支付</text>
<text class="method-check" v-if="payMethod === 'wechat'"></text>
</view>
<view class="pay-method" :class="{ active: payMethod === 'alipay' }" @tap="payMethod = 'alipay'">
<text class="method-icon">💳</text>
<text class="method-name">支付宝</text>
<text class="method-check" v-if="payMethod === 'alipay'"></text>
</view>
</view>
<button class="pay-btn" @tap="confirmPay">确认支付</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getOrderDetail, cancelOrder } from '@/api/order';
import { getOrderDetail, cancelOrder, payOrder, refundOrder } from '@/api/order';
const statusLabels: Record<string, string> = {
pending_pay: '待支付',
@@ -102,8 +156,16 @@ const statusLabels: Record<string, string> = {
refunded: '已退款',
};
const paymentMethodLabels: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
balance: '余额支付',
};
const order = ref<any>({});
const orderId = ref(0);
const showPayModal = ref(false);
const payMethod = ref<'wechat' | 'alipay'>('wechat');
onMounted(() => {
const pages = getCurrentPages();
@@ -138,6 +200,55 @@ function handleCancel() {
},
});
}
function handlePay() {
showPayModal.value = true;
}
function closePayModal() {
showPayModal.value = false;
}
async function confirmPay() {
uni.showLoading({ title: '支付中...' });
try {
await payOrder(orderId.value, payMethod.value);
uni.hideLoading();
uni.showToast({ title: '支付成功', icon: 'success' });
showPayModal.value = false;
fetchDetail();
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '支付失败', icon: 'none' });
}
}
function handleRefund() {
uni.showModal({
title: '申请退款',
editable: true,
placeholderText: '请输入退款原因',
success: async (res) => {
if (res.confirm) {
const reason = res.content?.trim();
if (!reason) {
uni.showToast({ title: '请输入退款原因', icon: 'none' });
return;
}
uni.showLoading({ title: '提交中...' });
try {
await refundOrder(orderId.value, reason);
uni.hideLoading();
uni.showToast({ title: '退款申请已提交', icon: 'success' });
fetchDetail();
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '申请失败', icon: 'none' });
}
}
},
});
}
</script>
<style lang="scss" scoped>
@@ -272,4 +383,126 @@ function handleCancel() {
color: #fff;
border: none;
}
.btn-refund {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
background: #fff;
color: #ff4d4f;
border: 2rpx solid #ff4d4f;
}
.status-refunding { background: #ff4d4f; }
.status-refunded { background: #722ed1; }
/* 支付弹窗 */
.pay-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: flex-end;
}
.pay-panel {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
width: 100%;
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.pay-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
}
.pay-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.pay-close {
font-size: 40rpx;
color: #999;
padding: 0 8rpx;
}
.pay-amount {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
background: #fff9f0;
border-radius: 12rpx;
margin-bottom: 32rpx;
}
.pay-label {
font-size: 28rpx;
color: #666;
}
.pay-value {
font-size: 40rpx;
font-weight: 700;
color: #FF6B35;
}
.pay-methods {
margin-bottom: 32rpx;
}
.pay-method {
display: flex;
align-items: center;
padding: 24rpx;
border: 2rpx solid #eee;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
.pay-method.active {
border-color: #FF6B35;
background: #fff9f0;
}
.method-icon {
font-size: 40rpx;
margin-right: 16rpx;
}
.method-name {
flex: 1;
font-size: 30rpx;
color: #333;
}
.method-check {
font-size: 32rpx;
color: #FF6B35;
font-weight: 700;
}
.pay-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 600;
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
color: #fff;
border: none;
}
</style>
+1 -1
View File
@@ -185,7 +185,7 @@ function contactMerchant() {
}
function handleBook() {
uni.navigateTo({ url: `/pages/order-detail/index?roomId=${roomId.value}` });
uni.navigateTo({ url: `/pages/order-create/index?roomId=${roomId.value}` });
}
</script>
+1 -1
View File
@@ -107,7 +107,7 @@
<!-- 功能菜单仅审核通过显示 -->
<view v-if="merchant.status === 'approved'" class="menu-section">
<view class="menu-group">
<view class="menu-item" @tap="navigateTo('/pages/order/index')">
<view class="menu-item" @tap="navigateTo('/pages/seller/orders')">
<text class="menu-label">订单管理</text>
<text class="menu-arrow">></text>
</view>
@@ -0,0 +1,542 @@
<template>
<view class="page-seller-order-detail">
<view v-if="order.id" class="detail-content">
<!-- 状态栏 -->
<view :class="['status-bar', `status-${order.status}`]">
<text class="status-text">{{ statusLabels[order.status] }}</text>
</view>
<!-- 房源信息 -->
<view class="section">
<view class="room-card flex-row">
<image class="room-thumb" :src="order.room?.coverImage || '/static/default-room.png'" mode="aspectFill" />
<view class="room-info">
<text class="room-name text-ellipsis">{{ order.room?.name }}</text>
<text class="merchant-name">{{ order.merchant?.shopName }}</text>
</view>
</view>
</view>
<!-- 入住人信息 -->
<view class="section">
<view class="section-title">入住人信息</view>
<view class="info-row flex-between">
<text class="info-label">联系人</text>
<text class="info-value">{{ order.contactName }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">联系电话</text>
<text class="info-value">{{ order.contactPhone }}</text>
</view>
<view class="info-row flex-between" v-if="order.contactIdCard">
<text class="info-label">身份证号</text>
<text class="info-value">{{ order.contactIdCard }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">入住人数</text>
<text class="info-value">{{ order.guestCount || 1 }}</text>
</view>
</view>
<!-- 入住信息 -->
<view class="section">
<view class="section-title">入住信息</view>
<view class="info-row flex-between">
<text class="info-label">入住日期</text>
<text class="info-value">{{ order.checkInDate }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">离店日期</text>
<text class="info-value">{{ order.checkOutDate }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">入住晚数</text>
<text class="info-value">{{ order.nights }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">房间套数</text>
<text class="info-value">{{ order.roomCount || 1 }}</text>
</view>
</view>
<!-- 费用明细 -->
<view class="section">
<view class="section-title">费用明细</view>
<view class="info-row flex-between">
<text class="info-label">房费</text>
<text class="info-value">¥{{ order.roomAmount }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">服务费</text>
<text class="info-value">¥{{ order.serviceFee }}</text>
</view>
<view v-if="order.couponDiscount > 0" class="info-row flex-between">
<text class="info-label">优惠券</text>
<text class="info-value discount">-¥{{ order.couponDiscount }}</text>
</view>
<view class="info-row flex-between total-row">
<text class="info-label">实付金额</text>
<text class="total-price">¥{{ order.payAmount }}</text>
</view>
</view>
<!-- 订单信息 -->
<view class="section">
<view class="section-title">订单信息</view>
<view class="info-row flex-between">
<text class="info-label">订单号</text>
<text class="info-value">{{ order.orderNo }}</text>
</view>
<view class="info-row flex-between">
<text class="info-label">下单时间</text>
<text class="info-value">{{ order.createdAt }}</text>
</view>
<view class="info-row flex-between" v-if="order.paymentMethod">
<text class="info-label">支付方式</text>
<text class="info-value">{{ paymentMethodLabels[order.paymentMethod] }}</text>
</view>
<view class="info-row flex-between" v-if="order.paidAt">
<text class="info-label">支付时间</text>
<text class="info-value">{{ order.paidAt }}</text>
</view>
<view class="info-row flex-between" v-if="order.confirmedAt">
<text class="info-label">确认时间</text>
<text class="info-value">{{ order.confirmedAt }}</text>
</view>
<view class="info-row flex-between" v-if="order.checkinAt">
<text class="info-label">入住时间</text>
<text class="info-value">{{ order.checkinAt }}</text>
</view>
<view class="info-row flex-between" v-if="order.cancelReason">
<text class="info-label">取消原因</text>
<text class="info-value">{{ order.cancelReason }}</text>
</view>
<view class="info-row flex-between" v-if="order.remark">
<text class="info-label">备注</text>
<text class="info-value">{{ order.remark }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="actions" v-if="order.status === 'pending_confirm'">
<button class="btn-reject" @tap="handleReject">拒绝订单</button>
<button class="btn-confirm" @tap="handleConfirm">确认接单</button>
</view>
<view class="actions" v-if="order.status === 'pending_checkin'">
<button class="btn-checkin" @tap="handleCheckin">办理入住</button>
</view>
<view class="actions" v-if="order.status === 'refunding'">
<button class="btn-reject-refund" @tap="handleRejectRefund">拒绝退款</button>
<button class="btn-approve-refund" @tap="handleApproveRefund">同意退款</button>
</view>
</view>
<view v-else class="loading">加载中...</view>
<!-- 拒绝原因弹窗 -->
<view v-if="showRejectModal" class="modal-mask" @tap="closeRejectModal">
<view class="modal-panel" @tap.stop>
<view class="modal-header">
<text class="modal-title">拒绝订单</text>
<text class="modal-close" @tap="closeRejectModal">×</text>
</view>
<view class="modal-body">
<textarea class="reject-input" v-model="rejectReason" placeholder="请输入拒绝原因" />
</view>
<view class="modal-footer">
<button class="modal-btn cancel" @tap="closeRejectModal">取消</button>
<button class="modal-btn confirm" @tap="submitReject">确认拒绝</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getSellerOrderDetail, confirmOrder, rejectOrder, checkinOrder, approveRefund, rejectRefund } from '@/api/seller-order';
const statusLabels: Record<string, string> = {
pending_pay: '待支付',
pending_confirm: '待确认',
pending_checkin: '待入住',
checked_in: '已入住',
completed: '已完成',
cancelled: '已取消',
refunding: '退款中',
refunded: '已退款',
};
const paymentMethodLabels: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
balance: '余额支付',
};
const order = ref<any>({});
const orderId = ref(0);
const showRejectModal = ref(false);
const rejectReason = ref('');
onMounted(() => {
const pages = getCurrentPages();
const page = pages[pages.length - 1] as any;
orderId.value = page.options?.id || page.$page?.options?.id;
if (orderId.value) fetchDetail();
});
async function fetchDetail() {
try {
const res = await getSellerOrderDetail(orderId.value);
order.value = res.data;
} catch (e) {
console.error(e);
}
}
async function handleConfirm() {
uni.showModal({
title: '提示',
content: '确认接单?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' });
try {
await confirmOrder(orderId.value);
uni.hideLoading();
uni.showToast({ title: '已确认', icon: 'success' });
fetchDetail();
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
},
});
}
function handleReject() {
rejectReason.value = '';
showRejectModal.value = true;
}
function closeRejectModal() {
showRejectModal.value = false;
}
async function handleCheckin() {
uni.showModal({
title: '提示',
content: '确认办理入住?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' });
try {
await checkinOrder(orderId.value);
uni.hideLoading();
uni.showToast({ title: '已入住', icon: 'success' });
fetchDetail();
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
},
});
}
async function handleApproveRefund() {
uni.showModal({
title: '提示',
content: '确认同意退款?退款金额将原路返回',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' });
try {
await approveRefund(orderId.value);
uni.hideLoading();
uni.showToast({ title: '退款成功', icon: 'success' });
fetchDetail();
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
},
});
}
function handleRejectRefund() {
rejectReason.value = '';
showRejectModal.value = true;
}
async function submitReject() {
if (!rejectReason.value.trim()) {
uni.showToast({ title: '请输入拒绝原因', icon: 'none' });
return;
}
uni.showLoading({ title: '处理中...' });
try {
if (order.value.status === 'refunding') {
await rejectRefund(orderId.value, rejectReason.value);
uni.hideLoading();
uni.showToast({ title: '已拒绝退款', icon: 'success' });
} else {
await rejectOrder(orderId.value, rejectReason.value);
uni.hideLoading();
uni.showToast({ title: '已拒绝', icon: 'success' });
}
showRejectModal.value = false;
fetchDetail();
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
</script>
<style lang="scss" scoped>
.page-seller-order-detail {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 140rpx;
}
.status-bar {
padding: 40rpx 24rpx;
color: #fff;
font-size: 36rpx;
font-weight: 700;
}
.status-pending_pay { background: #faad14; }
.status-pending_confirm { background: #1890ff; }
.status-pending_checkin { background: #52c41a; }
.status-checked_in { background: #FF6B35; }
.status-completed { background: #52c41a; }
.status-cancelled { background: #999; }
.status-refunding { background: #ff4d4f; }
.status-refunded { background: #722ed1; }
.section {
background: #fff;
margin: 24rpx;
border-radius: 16rpx;
padding: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.room-card {
padding: 8rpx 0;
}
.room-thumb {
width: 160rpx;
height: 120rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.room-info {
flex: 1;
margin-left: 20rpx;
}
.room-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.merchant-name {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.info-row {
padding: 16rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child { border-bottom: none; }
}
.info-label {
font-size: 28rpx;
color: #999;
}
.info-value {
font-size: 28rpx;
color: #333;
}
.discount { color: #52c41a; }
.total-row {
padding-top: 20rpx;
border-top: 2rpx solid #eee;
}
.total-price {
font-size: 36rpx;
font-weight: 700;
color: #FF6B35;
}
.loading {
text-align: center;
padding: 200rpx 0;
color: #999;
}
.actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 24rpx;
padding: 24rpx;
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.btn-reject {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
background: #fff;
color: #ff4d4f;
border: 2rpx solid #ff4d4f;
}
.btn-confirm {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
background: linear-gradient(135deg, #FF6B35, #ff8a5c);
color: #fff;
border: none;
}
.btn-checkin {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
background: linear-gradient(135deg, #52c41a, #73d13d);
color: #fff;
border: none;
}
.btn-approve-refund {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
background: linear-gradient(135deg, #52c41a, #73d13d);
color: #fff;
border: none;
}
.btn-reject-refund {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
background: #fff;
color: #ff4d4f;
border: 2rpx solid #ff4d4f;
}
/* 拒绝弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-panel {
width: 80%;
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.modal-close {
font-size: 40rpx;
color: #999;
}
.modal-body {
margin-bottom: 24rpx;
}
.reject-input {
width: 100%;
height: 160rpx;
background: #f5f5f5;
border-radius: 12rpx;
padding: 16rpx;
font-size: 28rpx;
}
.modal-footer {
display: flex;
gap: 24rpx;
}
.modal-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
border-radius: 40rpx;
border: none;
&.cancel {
background: #f5f5f5;
color: #666;
}
&.confirm {
background: #ff4d4f;
color: #fff;
}
}
</style>
+525
View File
@@ -0,0 +1,525 @@
<template>
<view class="page-seller-orders">
<!-- 订单状态Tab -->
<scroll-view scroll-x class="tab-bar">
<view
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: currentTab === tab.value }]"
@tap="switchTab(tab.value)"
>
{{ tab.label }}
</view>
</scroll-view>
<!-- 搜索栏 -->
<view class="search-bar">
<input class="search-input" v-model="searchOrderNo" placeholder="搜索订单号" @confirm="handleSearch" />
<button class="search-btn" @tap="handleSearch">搜索</button>
</view>
<!-- 订单列表 -->
<scroll-view scroll-y class="order-list" @scrolltolower="loadMore">
<view v-for="order in orderList" :key="order.id" class="order-card" @tap="goDetail(order.id)">
<view class="order-header flex-between">
<text class="order-no">订单号: {{ order.orderNo }}</text>
<text :class="['order-status', `status-${order.status}`]">
{{ statusLabels[order.status] }}
</text>
</view>
<view class="order-body">
<view class="room-info-row">
<text class="room-name">{{ order.room?.name }}</text>
<text class="order-amount">¥{{ order.payAmount }}</text>
</view>
<view class="date-info">
<text class="date-text">{{ order.checkInDate }} ~ {{ order.checkOutDate }} ({{ order.nights }})</text>
</view>
<view class="guest-info">
<text class="guest-name">入住人: {{ order.contactName }}</text>
<text class="guest-phone">{{ order.contactPhone }}</text>
</view>
</view>
<view class="order-footer flex-between">
<text class="order-time">{{ formatTime(order.createdAt) }}</text>
<view class="order-actions">
<button v-if="order.status === 'pending_confirm'" size="mini" class="action-btn primary" @tap.stop="handleConfirm(order.id)">确认</button>
<button v-if="order.status === 'pending_confirm'" size="mini" class="action-btn danger" @tap.stop="handleReject(order.id)">拒绝</button>
<button v-if="order.status === 'pending_checkin'" size="mini" class="action-btn primary" @tap.stop="handleCheckin(order.id)">办理入住</button>
<button v-if="order.status === 'refunding'" size="mini" class="action-btn primary" @tap.stop="handleApproveRefund(order.id)">同意退款</button>
<button v-if="order.status === 'refunding'" size="mini" class="action-btn danger" @tap.stop="handleRejectRefund(order.id)">拒绝退款</button>
</view>
</view>
</view>
<view v-if="orderList.length === 0 && !loading" class="empty">暂无订单</view>
<view v-if="loading" class="loading">加载中...</view>
</scroll-view>
<!-- 拒绝原因弹窗 -->
<view v-if="showRejectModal" class="modal-mask" @tap="closeRejectModal">
<view class="modal-panel" @tap.stop>
<view class="modal-header">
<text class="modal-title">拒绝订单</text>
<text class="modal-close" @tap="closeRejectModal">×</text>
</view>
<view class="modal-body">
<textarea class="reject-input" v-model="rejectReason" placeholder="请输入拒绝原因" />
</view>
<view class="modal-footer">
<button class="modal-btn cancel" @tap="closeRejectModal">取消</button>
<button class="modal-btn confirm" @tap="submitReject">确认拒绝</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getSellerOrders, confirmOrder, rejectOrder, checkinOrder, approveRefund, rejectRefund } from '@/api/seller-order';
const tabs = [
{ label: '全部', value: '' },
{ label: '待确认', value: 'pending_confirm' },
{ label: '待入住', value: 'pending_checkin' },
{ label: '已入住', value: 'checked_in' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
{ label: '退款中', value: 'refunding' },
];
const statusLabels: Record<string, string> = {
pending_pay: '待支付',
pending_confirm: '待确认',
pending_checkin: '待入住',
checked_in: '已入住',
completed: '已完成',
cancelled: '已取消',
refunding: '退款中',
refunded: '已退款',
};
const orderList = ref<any[]>([]);
const currentTab = ref('');
const searchOrderNo = ref('');
const page = ref(1);
const loading = ref(false);
const showRejectModal = ref(false);
const rejectOrderId = ref(0);
const rejectReason = ref('');
function formatTime(time: string) {
if (!time) return '';
const d = new Date(time);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
}
async function fetchOrders(reset = false) {
if (loading.value) return;
loading.value = true;
if (reset) page.value = 1;
try {
const res = await getSellerOrders({
page: page.value,
pageSize: 10,
status: currentTab.value || undefined,
orderNo: searchOrderNo.value || undefined,
});
const list = res.data?.list || [];
orderList.value = reset ? list : [...orderList.value, ...list];
page.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function switchTab(value: string) {
currentTab.value = value;
fetchOrders(true);
}
function handleSearch() {
fetchOrders(true);
}
function loadMore() {
fetchOrders();
}
function goDetail(id: number) {
uni.navigateTo({ url: `/pages/seller/order-detail?id=${id}` });
}
async function handleConfirm(id: number) {
uni.showModal({
title: '提示',
content: '确认接单?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' });
try {
await confirmOrder(id);
uni.hideLoading();
uni.showToast({ title: '已确认', icon: 'success' });
fetchOrders(true);
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
},
});
}
function handleReject(id: number) {
rejectOrderId.value = id;
rejectReason.value = '';
showRejectModal.value = true;
}
function closeRejectModal() {
showRejectModal.value = false;
}
async function submitReject() {
if (!rejectReason.value.trim()) {
uni.showToast({ title: '请输入拒绝原因', icon: 'none' });
return;
}
uni.showLoading({ title: '处理中...' });
try {
const order = orderList.value.find(o => o.id === rejectOrderId.value);
if (order?.status === 'refunding') {
await rejectRefund(rejectOrderId.value, rejectReason.value);
uni.hideLoading();
uni.showToast({ title: '已拒绝退款', icon: 'success' });
} else {
await rejectOrder(rejectOrderId.value, rejectReason.value);
uni.hideLoading();
uni.showToast({ title: '已拒绝', icon: 'success' });
}
showRejectModal.value = false;
fetchOrders(true);
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
async function handleCheckin(id: number) {
uni.showModal({
title: '提示',
content: '确认办理入住?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' });
try {
await checkinOrder(id);
uni.hideLoading();
uni.showToast({ title: '已入住', icon: 'success' });
fetchOrders(true);
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
},
});
}
async function handleApproveRefund(id: number) {
uni.showModal({
title: '提示',
content: '确认同意退款?退款金额将原路返回',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' });
try {
await approveRefund(id);
uni.hideLoading();
uni.showToast({ title: '退款成功', icon: 'success' });
fetchOrders(true);
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
},
});
}
function handleRejectRefund(id: number) {
rejectOrderId.value = id;
rejectReason.value = '';
showRejectModal.value = true;
}
onMounted(() => {
fetchOrders(true);
});
</script>
<style lang="scss" scoped>
.page-seller-orders {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.tab-bar {
white-space: nowrap;
background: #fff;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #eee;
}
.tab-item {
display: inline-block;
padding: 12rpx 28rpx;
margin-right: 16rpx;
font-size: 26rpx;
color: #666;
border-radius: 32rpx;
&.active {
background: #fff1eb;
color: #FF6B35;
font-weight: 600;
}
}
.search-bar {
display: flex;
padding: 16rpx 24rpx;
background: #fff;
gap: 16rpx;
}
.search-input {
flex: 1;
height: 64rpx;
background: #f5f5f5;
border-radius: 32rpx;
padding: 0 24rpx;
font-size: 26rpx;
}
.search-btn {
height: 64rpx;
line-height: 64rpx;
padding: 0 32rpx;
background: #FF6B35;
color: #fff;
font-size: 26rpx;
border-radius: 32rpx;
border: none;
}
.order-list {
flex: 1;
padding: 24rpx;
}
.order-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
}
.order-header {
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.order-no {
font-size: 24rpx;
color: #999;
}
.order-status {
font-size: 26rpx;
font-weight: 600;
}
.status-pending_pay { color: #faad14; }
.status-pending_confirm { color: #1890ff; }
.status-pending_checkin { color: #52c41a; }
.status-checked_in { color: #FF6B35; }
.status-completed { color: #52c41a; }
.status-cancelled { color: #999; }
.status-refunding { color: #ff4d4f; }
.status-refunded { color: #722ed1; }
.order-body {
padding: 16rpx 0;
}
.room-info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.room-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.order-amount {
font-size: 32rpx;
font-weight: 700;
color: #FF6B35;
}
.date-info {
margin-top: 12rpx;
}
.date-text {
font-size: 26rpx;
color: #666;
}
.guest-info {
display: flex;
gap: 24rpx;
margin-top: 12rpx;
}
.guest-name {
font-size: 26rpx;
color: #333;
}
.guest-phone {
font-size: 26rpx;
color: #999;
}
.order-footer {
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.order-time {
font-size: 24rpx;
color: #999;
}
.order-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
font-size: 24rpx;
border-radius: 32rpx;
padding: 0 24rpx;
&.primary {
background: #FF6B35;
color: #fff;
}
&.danger {
background: #fff;
color: #ff4d4f;
border: 1rpx solid #ff4d4f;
}
}
.empty {
text-align: center;
padding: 100rpx 0;
color: #999;
}
.loading {
text-align: center;
padding: 40rpx 0;
color: #999;
}
/* 拒绝弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-panel {
width: 80%;
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.modal-close {
font-size: 40rpx;
color: #999;
}
.modal-body {
margin-bottom: 24rpx;
}
.reject-input {
width: 100%;
height: 160rpx;
background: #f5f5f5;
border-radius: 12rpx;
padding: 16rpx;
font-size: 28rpx;
}
.modal-footer {
display: flex;
gap: 24rpx;
}
.modal-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
border-radius: 40rpx;
border: none;
&.cancel {
background: #f5f5f5;
color: #666;
}
&.confirm {
background: #ff4d4f;
color: #fff;
}
}
</style>
+6
View File
@@ -78,6 +78,12 @@ export class Order {
@Column({ name: 'contact_phone', length: 20, comment: '联系人手机' })
contactPhone: string;
@Column({ name: 'contact_id_card', length: 18, nullable: true, comment: '联系人身份证号' })
contactIdCard?: string;
@Column({ name: 'guest_count', type: 'tinyint', unsigned: true, default: 1, comment: '入住人数' })
guestCount: number;
@Column({ length: 500, default: '', comment: '备注' })
remark: string;
@@ -17,6 +17,10 @@ export class CreateOrderDto {
@IsNumber()
roomCount?: number = 1;
@IsOptional()
@IsNumber()
guestCount?: number = 1;
@IsOptional()
@IsNumber()
couponId?: number;
@@ -29,6 +33,10 @@ export class CreateOrderDto {
@IsNotEmpty({ message: '联系人手机不能为空' })
contactPhone: string;
@IsOptional()
@IsString()
contactIdCard?: string;
@IsOptional()
@IsString()
remark?: string;
@@ -48,10 +48,14 @@ export class UserOrderController {
return this.orderService.findByUser(userId, query);
}
@Get(':id')
@ApiOperation({ summary: '订单详情' })
async findById(@Param('id') id: number) {
return this.orderService.findById(id);
@Put(':id/pay')
@ApiOperation({ summary: '模拟支付' })
async pay(
@CurrentUser('sub') userId: number,
@Param('id') id: number,
@Body('paymentMethod') paymentMethod: 'wechat' | 'alipay' | 'balance',
) {
return this.orderService.pay(userId, id, paymentMethod || 'wechat');
}
@Put(':id/cancel')
@@ -63,6 +67,22 @@ export class UserOrderController {
) {
return this.orderService.cancel(userId, id, reason);
}
@Put(':id/refund')
@ApiOperation({ summary: '申请退款' })
async refund(
@CurrentUser('sub') userId: number,
@Param('id') id: number,
@Body('reason') reason: string,
) {
return this.orderService.refund(userId, id, reason);
}
@Get(':id')
@ApiOperation({ summary: '订单详情' })
async findById(@Param('id') id: number) {
return this.orderService.findById(id);
}
}
@ApiTags('订单管理(商家)')
@@ -86,6 +106,12 @@ export class MerchantOrderController {
return this.orderService.findByMerchant(merchant.id, query);
}
@Get(':id')
@ApiOperation({ summary: '商家订单详情' })
async findById(@Param('id') id: number) {
return this.orderService.findById(id);
}
@Put(':id/confirm')
@ApiOperation({ summary: '确认订单' })
async confirm(
@@ -119,6 +145,29 @@ export class MerchantOrderController {
if (!merchant) throw new NotFoundException('店铺不存在');
return this.orderService.checkin(merchant.id, id);
}
@Put(':id/approve-refund')
@ApiOperation({ summary: '同意退款' })
async approveRefund(
@CurrentSeller('sub') sellerId: number,
@Param('id') id: number,
) {
const merchant = await this.merchantService.findBySellerId(sellerId);
if (!merchant) throw new NotFoundException('店铺不存在');
return this.orderService.approveRefund(merchant.id, id);
}
@Put(':id/reject-refund')
@ApiOperation({ summary: '拒绝退款' })
async rejectRefund(
@CurrentSeller('sub') sellerId: number,
@Param('id') id: number,
@Body('reason') reason: string,
) {
const merchant = await this.merchantService.findBySellerId(sellerId);
if (!merchant) throw new NotFoundException('店铺不存在');
return this.orderService.rejectRefund(merchant.id, id, reason);
}
}
@ApiTags('订单管理(管理员)')
@@ -55,6 +55,7 @@ export class OrderService {
checkOutDate: dto.checkOutDate,
nights,
roomCount,
guestCount: dto.guestCount || 1,
roomPrice: room.price,
roomAmount,
serviceFee,
@@ -63,6 +64,7 @@ export class OrderService {
payAmount,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
contactIdCard: dto.contactIdCard || undefined,
remark: dto.remark || '',
paymentMethod: dto.paymentMethod || null,
});
@@ -187,6 +189,103 @@ export class OrderService {
return { message: '订单已完成' };
}
// 用户申请退款(待确认、待入住状态)
async refund(userId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (!['pending_confirm', 'pending_checkin'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可申请退款');
}
await this.orderRepo.update(id, {
status: 'refunding',
cancelReason: reason,
});
return { message: '退款申请已提交' };
}
// 商家同意退款
async approveRefund(merchantId: number, id: number) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'refunding') {
throw new BadRequestException('当前订单状态不可处理退款');
}
// 恢复房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await this.calendarRepo.findOne({
where: { roomId: order.roomId, date: dateStr },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: Math.max(0, calendar.sold - order.roomCount),
});
}
}
await this.orderRepo.update(id, {
status: 'refunded',
refundAmount: order.payAmount,
refundAt: new Date(),
cancelledAt: new Date(),
});
return { message: '退款成功' };
}
// 商家拒绝退款
async rejectRefund(merchantId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'refunding') {
throw new BadRequestException('当前订单状态不可处理退款');
}
await this.orderRepo.update(id, {
status: 'cancelled',
cancelReason: `退款被拒: ${reason}`,
cancelledAt: new Date(),
});
return { message: '已拒绝退款' };
}
async pay(userId: number, id: number, paymentMethod: 'wechat' | 'alipay' | 'balance') {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'pending_pay') {
throw new BadRequestException('当前订单状态不可支付');
}
// 模拟支付成功
const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.orderRepo.update(id, {
status: 'pending_confirm',
paymentMethod,
paymentNo,
paidAt: new Date(),
});
// 扣减房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await this.calendarRepo.findOne({
where: { roomId: order.roomId, date: dateStr },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: calendar.sold + order.roomCount,
});
}
}
return { message: '支付成功', paymentNo };
}
async findAll(query: QueryOrderDto) {
const { page = 1, pageSize = 10, status, orderNo, startDate, endDate } = query;
const qb = this.orderRepo.createQueryBuilder('o')
@@ -17,12 +17,16 @@ import { JwtAuthGuard, RolesGuard } from '@/common';
import { Roles } from '@/common/decorators/roles.decorator';
import { CurrentSeller } from '@/common/decorators/current-seller.decorator';
import { MerchantService } from '../merchant/merchant.service';
import { RoomCalendarService } from '../room-calendar/room-calendar.service';
import { CreateRoomDto, UpdateRoomDto, QueryRoomDto } from './dto/room.dto';
@ApiTags('房源')
@Controller('rooms')
export class RoomPublicController {
constructor(private readonly roomService: RoomService) {}
constructor(
private readonly roomService: RoomService,
private readonly calendarService: RoomCalendarService,
) {}
@Get()
@ApiOperation({ summary: '房源列表(公开)' })
@@ -35,6 +39,16 @@ export class RoomPublicController {
async findById(@Param('id') id: number) {
return this.roomService.findById(id);
}
@Get(':id/calendar')
@ApiOperation({ summary: '房源日历(公开)' })
async getCalendar(
@Param('id') id: number,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.calendarService.getCalendar(Number(id), { startDate, endDate });
}
}
@ApiTags('房源管理(商家)')
+2 -1
View File
@@ -9,9 +9,10 @@ import {
AdminRoomController,
} from './room.controller';
import { MerchantModule } from '../merchant/merchant.module';
import { RoomCalendarModule } from '../room-calendar/room-calendar.module';
@Module({
imports: [TypeOrmModule.forFeature([Room, RoomCalendar]), MerchantModule],
imports: [TypeOrmModule.forFeature([Room, RoomCalendar]), MerchantModule, RoomCalendarModule],
controllers: [
RoomPublicController,
MerchantRoomController,
+15 -13
View File
@@ -135,8 +135,8 @@ export class RoomService {
qb.skip((safePage - 1) * safePageSize).take(safePageSize);
const [list, total] = await qb.getManyAndCount();
// 日期筛选:检查时间区间内是否有库存
let filteredList = list;
// 日期筛选:检查时间区间内是否有库存,添加 isAvailable 标记
let resultList: (Room & { isAvailable: boolean })[] = list.map(r => ({ ...r, isAvailable: true }));
if (checkIn && checkOut && list.length > 0) {
const roomIds = list.map(r => r.id);
const checkInDate = new Date(checkIn);
@@ -164,30 +164,32 @@ export class RoomService {
}
}
// 计算需要的入住天数
const daysNeeded = Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24));
// 过滤出日期区间内每天都有库存的房间
filteredList = list.filter(room => {
// 为每个房间标记是否可用(日期区间内每天都有库存)
resultList = list.map(room => {
const availableDays = roomAvailableDays[room.id];
if (!availableDays) return false;
if (!availableDays) {
return { ...room, isAvailable: false };
}
// 检查每一天是否都有库存
for (let d = new Date(checkInDate); d < checkOutDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
if (!availableDays.has(dateStr)) {
return false;
return { ...room, isAvailable: false };
}
}
return true;
return { ...room, isAvailable: true };
});
}
// 有库存的排前面,满房的排后面
resultList.sort((a, b) => (a.isAvailable === b.isAvailable ? 0 : a.isAvailable ? -1 : 1));
return {
list: filteredList,
total: filteredList.length,
list: resultList,
total,
page: safePage,
pageSize: safePageSize,
totalPages: Math.ceil(filteredList.length / safePageSize),
totalPages: Math.ceil(total / safePageSize),
};
}
+2
View File
@@ -216,6 +216,8 @@ CREATE TABLE `orders` (
`cancel_reason` VARCHAR(500) DEFAULT NULL COMMENT '取消原因',
`contact_name` VARCHAR(50) NOT NULL COMMENT '联系人姓名',
`contact_phone` VARCHAR(20) NOT NULL COMMENT '联系人手机',
`contact_id_card` VARCHAR(18) DEFAULT NULL COMMENT '联系人身份证号',
`guest_count` TINYINT UNSIGNED DEFAULT 1 COMMENT '入住人数',
`remark` VARCHAR(500) DEFAULT '' COMMENT '备注',
`source` ENUM('miniapp','web','third_party') DEFAULT 'miniapp' COMMENT '订单来源',
`confirmed_at` DATETIME DEFAULT NULL COMMENT '商家确认时间',