dev
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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('房源管理(商家)')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user