Files
rent/apps/miniapp/src/pages/seller/room-calendar.vue
T
2026-05-11 17:59:19 +08:00

861 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page-room-calendar">
<!-- 顶部栏 -->
<view class="top-bar">
<view class="page-title">房态日历</view>
<picker :range="roomList" range-key="name" :value="roomIndex" @change="onRoomChange">
<view class="room-picker">
<text class="room-text">{{ roomList[roomIndex]?.name || '选择房源' }}</text>
<text class="picker-icon"></text>
</view>
</picker>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="switch-btn" @tap="prevMonth">
<text class="switch-icon"></text>
</view>
<view class="month-info">
<text class="current-month">{{ currentMonth }}</text>
<text class="current-year">{{ currentYear }}</text>
</view>
<view class="switch-btn" @tap="nextMonth">
<text class="switch-icon"></text>
</view>
</view>
<!-- 日历 -->
<view class="calendar-wrapper">
<view class="weekdays">
<text v-for="w in weekDays" :key="w" class="weekday">{{ w }}</text>
</view>
<view class="days-grid">
<view
v-for="(day, i) in calendarDays"
:key="i"
:class="getDayClass(day)"
@tap="day && !day.isPast && onDayTap(day)"
>
<template v-if="day">
<view class="day-header">
<text class="day-num">{{ day.day }}</text>
<view v-if="day.isToday" class="today-dot"></view>
</view>
<view v-if="day.initialized" class="day-content">
<text class="day-price">{{ day.price }}</text>
<text :class="['day-stock', day.stock === 0 ? 'stock-empty' : '']">
{{ day.stock === 0 ? '满' : day.stock }}
</text>
</view>
<view v-else class="day-empty">-</view>
</template>
</view>
</view>
</view>
<!-- 批量操作 -->
<view class="batch-section">
<view class="section-title">批量设置</view>
<view class="batch-form">
<view class="form-row">
<view class="form-item">
<text class="item-label">起始</text>
<picker mode="date" :value="batchStart" @change="(e: any) => batchStart = e.detail.value">
<view class="item-value">{{ batchStart || '选择日期' }}</view>
</picker>
</view>
<view class="form-item">
<text class="item-label">结束</text>
<picker mode="date" :value="batchEnd" @change="(e: any) => batchEnd = e.detail.value">
<view class="item-value">{{ batchEnd || '选择日期' }}</view>
</picker>
</view>
</view>
<view class="form-row">
<view class="form-item">
<text class="item-label">价格</text>
<input class="item-input" type="digit" v-model="batchPrice" placeholder="¥" />
</view>
<view class="form-item">
<text class="item-label">库存</text>
<input class="item-input" type="number" v-model="batchStock" placeholder="间数" />
</view>
</view>
<button class="batch-submit" @tap="handleBatchUpdate">应用设置</button>
</view>
</view>
<!-- 编辑弹窗 -->
<view v-if="editDay" class="edit-mask" @tap="closeEditModal">
<view class="edit-panel" @tap.stop>
<view class="edit-top">
<text class="edit-title">{{ formatModalDate(editDay.date) }}</text>
<view class="edit-close" @tap="closeEditModal">×</view>
</view>
<view class="edit-form">
<view class="edit-row">
<text class="edit-label">价格</text>
<view class="edit-input-box">
<text class="input-unit">¥</text>
<input class="edit-input" type="digit" v-model="editDay.price" placeholder="0" />
</view>
</view>
<view class="edit-row">
<text class="edit-label">库存</text>
<view class="edit-input-box">
<input class="edit-input" type="number" v-model="editDay.stock" placeholder="0" />
<text class="input-unit"></text>
</view>
</view>
<view class="edit-row">
<text class="edit-label">状态</text>
<picker
:range="statusOptions"
range-key="label"
:value="editDay.statusIndex"
@change="(e: any) => editDay.statusIndex = e.detail.value"
>
<view class="edit-status">
<text :class="['status-label', editDay.statusIndex === 1 ? 'unavailable' : '']">
{{ statusOptions[editDay.statusIndex].label }}
</text>
</view>
</picker>
</view>
</view>
<view class="edit-actions">
<button class="action-btn cancel-btn" @tap="closeEditModal">取消</button>
<button class="action-btn confirm-btn" @tap="handleSingleUpdate">确定</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getMerchantRooms } from '@/api/seller/room';
import { getRoomCalendar, batchUpdateCalendar, singleDayUpdate } from '@/api/seller/room-calendar';
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
const statusOptions = [
{ label: '可售', value: 'available' },
{ label: '不可售', value: 'unavailable' },
];
const roomList = ref<any[]>([]);
const roomIndex = ref(0);
const currentYear = ref(new Date().getFullYear());
const currentMonth = ref(new Date().getMonth() + 1);
const calendarData = ref<any[]>([]);
const loading = ref(false);
const batchStart = ref('');
const batchEnd = ref('');
const batchPrice = ref('');
const batchStock = ref('');
const editDay = ref<any>(null);
const currentRoomId = computed(() => {
const room = roomList.value[roomIndex.value];
return room ? room.id : null;
});
const monthRange = computed(() => {
const year = currentYear.value;
const month = currentMonth.value;
const pad = (n: number) => String(n).padStart(2, '0');
const startDate = `${year}-${pad(month)}-01`;
const lastDay = new Date(year, month, 0).getDate();
const endDate = `${year}-${pad(month)}-${pad(lastDay)}`;
return { startDate, endDate };
});
const calendarDays = computed(() => {
const year = currentYear.value;
const month = currentMonth.value;
const firstDayWeek = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
const today = new Date().toISOString().slice(0, 10);
const days: any[] = [];
for (let i = 0; i < firstDayWeek; i++) {
days.push(null);
}
const dataMap = new Map(calendarData.value.map((d: any) => [d.date, d]));
const pad = (n: number) => String(n).padStart(2, '0');
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${pad(month)}-${pad(d)}`;
const rec = dataMap.get(dateStr);
days.push({
day: d,
date: dateStr,
price: rec ? rec.price : 0,
stock: rec ? rec.stock : 0,
sold: rec ? rec.sold : 0,
status: rec ? rec.status : 'available',
initialized: rec ? rec.initialized : false,
isToday: dateStr === today,
isPast: dateStr < today,
});
}
return days;
});
function getDayClass(day: any) {
if (!day) return 'day-item empty';
const classes = ['day-item'];
if (day.isToday) classes.push('today');
if (day.isPast) classes.push('past');
if (day.status === 'unavailable') classes.push('unavailable');
if (!day.initialized) classes.push('unset');
if (day.stock === 0 && day.initialized) classes.push('sold-out');
return classes.join(' ');
}
function formatModalDate(dateStr: string) {
const date = new Date(dateStr);
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const month = date.getMonth() + 1;
const day = date.getDate();
const weekDay = weekNames[date.getDay()];
return `${month}${day}${weekDay}`;
}
async function fetchRooms() {
try {
const res = await getMerchantRooms({ page: 1, pageSize: 100 });
roomList.value = res.data?.list || [];
const pages = getCurrentPages();
const page = pages[pages.length - 1] as any;
const roomId = page.options?.roomId;
if (roomId) {
const idx = roomList.value.findIndex((r: any) => String(r.id) === String(roomId));
if (idx > -1) roomIndex.value = idx;
}
} catch (e) {
console.error(e);
}
}
async function fetchCalendar() {
if (!currentRoomId.value) return;
loading.value = true;
try {
const res = await getRoomCalendar(
currentRoomId.value,
monthRange.value.startDate,
monthRange.value.endDate,
);
calendarData.value = res.data || [];
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function onRoomChange(e: any) {
roomIndex.value = e.detail.value;
fetchCalendar();
}
function prevMonth() {
if (currentMonth.value === 1) {
currentMonth.value = 12;
currentYear.value--;
} else {
currentMonth.value--;
}
}
function nextMonth() {
if (currentMonth.value === 12) {
currentMonth.value = 1;
currentYear.value++;
} else {
currentMonth.value++;
}
}
watch([currentYear, currentMonth], () => {
fetchCalendar();
});
function onDayTap(day: any) {
editDay.value = {
date: day.date,
price: String(day.price || ''),
stock: String(day.stock || ''),
statusIndex: day.status === 'unavailable' ? 1 : 0,
};
}
function closeEditModal() {
editDay.value = null;
}
async function handleSingleUpdate() {
if (!currentRoomId.value || !editDay.value) return;
if (!editDay.value.price || !editDay.value.stock) {
uni.showToast({ title: '请填写完整', icon: 'none' });
return;
}
try {
const data: any = {
roomId: currentRoomId.value,
date: editDay.value.date,
price: Number(editDay.value.price),
stock: Number(editDay.value.stock),
status: statusOptions[editDay.value.statusIndex].value,
};
await singleDayUpdate(data);
uni.showToast({ title: '已保存', icon: 'success' });
editDay.value = null;
fetchCalendar();
} catch (e: any) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' });
}
}
async function handleBatchUpdate() {
if (!currentRoomId.value) {
uni.showToast({ title: '请先选择房源', icon: 'none' });
return;
}
if (!batchStart.value || !batchEnd.value) {
uni.showToast({ title: '请选择日期', icon: 'none' });
return;
}
if (!batchPrice.value && !batchStock.value) {
uni.showToast({ title: '请填写价格或库存', icon: 'none' });
return;
}
try {
const data: any = {
roomId: currentRoomId.value,
startDate: batchStart.value,
endDate: batchEnd.value,
};
if (batchPrice.value) data.price = Number(batchPrice.value);
if (batchStock.value) data.stock = Number(batchStock.value);
await batchUpdateCalendar(data);
uni.showToast({ title: '设置成功', icon: 'success' });
batchPrice.value = '';
batchStock.value = '';
fetchCalendar();
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' });
}
}
onShow(() => {
fetchRooms().then(() => {
fetchCalendar();
});
});
</script>
<style lang="scss" scoped>
@import '@/static/styles/common.scss';
.page-room-calendar {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 32rpx;
}
// 顶部栏
.top-bar {
background: #fff;
padding: 32rpx 24rpx 24rpx;
border-bottom: 1rpx solid #eee;
}
.page-title {
font-size: 34rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 20rpx;
}
.room-picker {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f9fa;
padding: 20rpx 24rpx;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
}
.room-text {
font-size: 28rpx;
color: #333;
}
.picker-icon {
font-size: 20rpx;
color: #999;
}
// 月份切换
.month-switcher {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
margin: 16rpx 24rpx;
padding: 24rpx 32rpx;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.switch-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 12rpx;
transition: all 0.2s;
&:active {
background: #e9ecef;
transform: scale(0.95);
}
}
.switch-icon {
font-size: 28rpx;
color: #495057;
}
.month-info {
display: flex;
align-items: baseline;
gap: 12rpx;
}
.current-month {
font-size: 44rpx;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -1rpx;
}
.current-year {
font-size: 26rpx;
color: #868e96;
}
// 日历
.calendar-wrapper {
background: #fff;
margin: 0 24rpx 16rpx;
border-radius: 16rpx;
padding: 20rpx 12rpx 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.weekdays {
display: flex;
padding: 12rpx 0 20rpx;
border-bottom: 1rpx solid #f1f3f5;
}
.weekday {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #adb5bd;
font-weight: 500;
}
.days-grid {
display: flex;
flex-wrap: wrap;
padding-top: 8rpx;
}
.day-item {
width: calc(100% / 7);
padding: 12rpx 4rpx;
display: flex;
flex-direction: column;
align-items: center;
min-height: 120rpx;
position: relative;
&.empty {
min-height: 0;
padding: 0;
}
&:not(.empty):not(.past):active {
opacity: 0.7;
}
&.past {
opacity: 0.3;
pointer-events: none;
}
&.today {
.day-num {
background: #4263eb;
color: #fff;
border-radius: 50%;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
&.unavailable {
.day-price {
color: #fa5252;
text-decoration: line-through;
}
}
&.sold-out {
.day-content {
opacity: 0.5;
}
}
&.unset {
.day-num {
color: #adb5bd;
}
}
}
.day-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
position: relative;
}
.day-num {
font-size: 28rpx;
color: #212529;
font-weight: 500;
}
.today-dot {
width: 8rpx;
height: 8rpx;
background: #4263eb;
border-radius: 50%;
position: absolute;
bottom: -12rpx;
}
.day-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.day-price {
font-size: 22rpx;
color: #4263eb;
font-weight: 600;
}
.day-stock {
font-size: 20rpx;
color: #51cf66;
background: #d3f9d8;
padding: 2rpx 10rpx;
border-radius: 6rpx;
font-weight: 500;
&.stock-empty {
color: #fa5252;
background: #ffe3e3;
}
}
.day-empty {
font-size: 24rpx;
color: #ced4da;
margin-top: 8rpx;
}
// 批量操作
.batch-section {
background: #fff;
margin: 0 24rpx;
border-radius: 16rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 24rpx;
}
.batch-form {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.form-row {
display: flex;
gap: 16rpx;
}
.form-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.item-label {
font-size: 24rpx;
color: #868e96;
font-weight: 500;
}
.item-value {
background: #f8f9fa;
padding: 20rpx 16rpx;
border-radius: 10rpx;
font-size: 26rpx;
color: #495057;
border: 1rpx solid #e9ecef;
}
.item-input {
background: #f8f9fa;
padding: 20rpx 16rpx;
border-radius: 10rpx;
font-size: 26rpx;
color: #495057;
border: 1rpx solid #e9ecef;
&:focus {
border-color: #4263eb;
background: #fff;
}
}
.batch-submit {
background: linear-gradient(135deg, #4c6ef5 0%, #4263eb 100%);
color: #fff;
border-radius: 12rpx;
height: 88rpx;
font-size: 30rpx;
font-weight: 600;
border: none;
margin-top: 8rpx;
box-shadow: 0 8rpx 20rpx rgba(66, 99, 235, 0.25);
letter-spacing: 2rpx;
position: relative;
overflow: hidden;
width: 100%;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
&:active {
background: linear-gradient(135deg, #3b5bdb 0%, #364fc7 100%);
transform: translateY(2rpx);
box-shadow: 0 4rpx 12rpx rgba(66, 99, 235, 0.3);
&::before {
left: 100%;
}
}
}
// 编辑弹窗
.edit-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.edit-panel {
width: 600rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, 0.15);
}
.edit-top {
background: #4263eb;
padding: 28rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.edit-title {
font-size: 30rpx;
color: #fff;
font-weight: 600;
}
.edit-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&:active {
background: rgba(255, 255, 255, 0.2);
}
}
.edit-form {
padding: 32rpx 24rpx;
}
.edit-row {
margin-bottom: 28rpx;
&:last-child {
margin-bottom: 0;
}
}
.edit-label {
font-size: 26rpx;
color: #868e96;
font-weight: 500;
margin-bottom: 12rpx;
display: block;
}
.edit-input-box {
display: flex;
align-items: center;
background: #f8f9fa;
padding: 0 20rpx;
border-radius: 10rpx;
border: 1rpx solid #e9ecef;
&:focus-within {
border-color: #4263eb;
background: #fff;
}
}
.input-unit {
font-size: 26rpx;
color: #868e96;
font-weight: 500;
}
.edit-input {
flex: 1;
height: 76rpx;
font-size: 28rpx;
color: #212529;
padding: 0 12rpx;
}
.edit-status {
background: #f8f9fa;
padding: 20rpx;
border-radius: 10rpx;
border: 1rpx solid #e9ecef;
}
.status-label {
font-size: 26rpx;
color: #51cf66;
font-weight: 500;
&.unavailable {
color: #fa5252;
}
}
.edit-actions {
display: flex;
border-top: 1rpx solid #f1f3f5;
}
.action-btn {
flex: 1;
height: 88rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
&.cancel-btn {
background: #fff;
color: #868e96;
border-right: 1rpx solid #f1f3f5;
&:active {
background: #f8f9fa;
}
}
&.confirm-btn {
background: #4263eb;
color: #fff;
&:active {
background: #3b5bdb;
}
}
}
</style>