|
@@ -0,0 +1,797 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="app-container">
|
|
|
|
|
+ <!-- 搜索区域 -->
|
|
|
|
|
+ <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" @submit.prevent>
|
|
|
|
|
+ <el-form-item label="酒店名称" prop="hotelName">
|
|
|
|
|
+ <el-input v-model="queryParams.hotelName" placeholder="请输入酒店名称" clearable @keyup.enter="handleQuery" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item>
|
|
|
|
|
+ <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
|
|
|
|
+ <el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 操作按钮 -->
|
|
|
|
|
+ <el-row :gutter="10" class="mb8">
|
|
|
|
|
+ <el-col :span="1.5">
|
|
|
|
|
+ <el-button type="primary" plain icon="Plus" @click="handleAdd">新增</el-button>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 表格 -->
|
|
|
|
|
+ <el-table v-loading="loading" border :data="dataList">
|
|
|
|
|
+ <el-table-column label="序号" align="center" width="60">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ {{ (queryParams.pageNum - 1) * queryParams.pageSize + scope.$index + 1 }}
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="酒店名称" align="center" prop="hotelName" min-width="120" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="酒店简介" align="center" prop="introduction" min-width="140" show-overflow-tooltip>
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ {{ scope.row.introduction || '-' }}
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="宣传图片" align="center" width="180">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ <div v-if="scope.row.imageList && scope.row.imageList.length > 0" class="cell-images">
|
|
|
|
|
+ <el-image
|
|
|
|
|
+ v-for="(img, idx) in scope.row.imageList.slice(0, 3)"
|
|
|
|
|
+ :key="idx"
|
|
|
|
|
+ :src="img.imageUrl"
|
|
|
|
|
+ style="width: 44px; height: 44px; border-radius: 3px; cursor: pointer; border: 1px solid #e4e7ed"
|
|
|
|
|
+ fit="cover"
|
|
|
|
|
+ :preview-src-list="scope.row.imageList.map((i) => i.imageUrl)"
|
|
|
|
|
+ :preview-teleported="true"
|
|
|
|
|
+ :initial-index="idx"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span v-if="scope.row.imageList.length > 3" class="cell-images-more">+{{ scope.row.imageList.length - 3 }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span v-else class="cell-empty">暂无</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="房型与时段" align="center" min-width="220">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
|
|
|
|
|
+ <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-item">
|
|
|
|
|
+ <div class="cell-room-title">{{ ri + 1 }}.{{ room.roomName }}:</div>
|
|
|
|
|
+ <div v-for="(p, pi) in room.periodList || []" :key="p.id || pi" class="cell-room-line">
|
|
|
|
|
+ 时间段{{ pi + 1 }}:{{ p.startDate }}-{{ p.endDate }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="!room.periodList || room.periodList.length === 0" class="cell-room-line">未配置时段</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span v-else class="cell-empty">暂无</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="剩余数量" align="center" min-width="150">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
|
|
|
|
|
+ <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-item">
|
|
|
|
|
+ <div v-for="(p, pi) in room.periodList || []" :key="p.id || pi" class="cell-room-line">
|
|
|
|
|
+ 时间段{{ pi + 1 }}剩余房间:{{ p.remainingCount }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span v-else class="cell-empty">暂无</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="房间介绍" align="center" min-width="140">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
|
|
|
|
|
+ <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-line cell-room-item">
|
|
|
|
|
+ {{ ri + 1 }}.{{ room.roomIntro || '暂无' }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span v-else class="cell-empty">暂无</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="房间图片" align="center" width="180">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
|
|
|
|
|
+ <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-images-row cell-room-item">
|
|
|
|
|
+ <span class="cell-room-img-index">{{ ri + 1 }}.</span>
|
|
|
|
|
+ <div class="cell-images">
|
|
|
|
|
+ <el-image
|
|
|
|
|
+ v-for="(img, idx) in (room.imageList || []).slice(0, 3)"
|
|
|
|
|
+ :key="idx"
|
|
|
|
|
+ :src="img.imageUrl"
|
|
|
|
|
+ style="width: 36px; height: 36px; border-radius: 3px; cursor: pointer; border: 1px solid #e4e7ed"
|
|
|
|
|
+ fit="cover"
|
|
|
|
|
+ :preview-src-list="(room.imageList || []).map((i) => i.imageUrl)"
|
|
|
|
|
+ :preview-teleported="true"
|
|
|
|
|
+ :initial-index="idx"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span v-if="(room.imageList || []).length > 3" class="cell-images-more">+{{ room.imageList.length - 3 }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span v-else class="cell-empty">暂无</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="置顶排序" align="center" prop="sortOrder" width="90" />
|
|
|
|
|
+ <el-table-column label="操作" align="center" width="140" fixed="right">
|
|
|
|
|
+ <template #default="scope">
|
|
|
|
|
+ <div class="action-buttons">
|
|
|
|
|
+ <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
|
|
|
|
+ <el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+
|
|
|
|
|
+ <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 新增/修改对话框 -->
|
|
|
|
|
+ <el-dialog :title="dialogTitle" v-model="dialogVisible" width="860px" append-to-body :close-on-click-modal="false">
|
|
|
|
|
+ <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
|
|
|
|
+ <el-form-item label="酒店" prop="hotelName">
|
|
|
|
|
+ <el-input v-model="form.hotelName" placeholder="请输入酒店名称" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="酒店简介" prop="introduction">
|
|
|
|
|
+ <el-input v-model="form.introduction" placeholder="请输入酒店简介" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="排序" prop="sortOrder">
|
|
|
|
|
+ <el-input-number
|
|
|
|
|
+ v-model="form.sortOrder"
|
|
|
|
|
+ :min="1"
|
|
|
|
|
+ :value-on-clear="null"
|
|
|
|
|
+ placeholder="留空自动排到最后"
|
|
|
|
|
+ controls-position="right"
|
|
|
|
|
+ style="width: 200px"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span class="form-inline-tip">数字越小越置顶</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 宣传图片(多张,第一张为封面) -->
|
|
|
|
|
+ <el-form-item label="宣传图片">
|
|
|
|
|
+ <div class="image-upload-container">
|
|
|
|
|
+ <div class="upload-buttons-row">
|
|
|
|
|
+ <el-upload
|
|
|
|
|
+ ref="promoUploadRef"
|
|
|
|
|
+ action="#"
|
|
|
|
|
+ :auto-upload="false"
|
|
|
|
|
+ :on-change="handlePromoFileChange"
|
|
|
|
|
+ accept="image/*"
|
|
|
|
|
+ :show-file-list="false"
|
|
|
|
|
+ multiple
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-button type="primary" size="small">上传新图片</el-button>
|
|
|
|
|
+ </el-upload>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-tip">支持多张,最多{{ MAX_IMAGES }}张,第一张为封面图片</div>
|
|
|
|
|
+ <div v-if="form.promoImages.length > 0" class="content-images-grid">
|
|
|
|
|
+ <div v-for="(img, index) in form.promoImages" :key="index" class="content-image-item">
|
|
|
|
|
+ <el-image :src="img" style="width: 100%; height: 100px" fit="cover" :preview-src-list="form.promoImages" :preview-teleported="true" />
|
|
|
|
|
+ <div class="content-image-actions">
|
|
|
|
|
+ <el-button type="danger" size="small" circle icon="Delete" @click="form.promoImages.splice(index, 1)" />
|
|
|
|
|
+ <el-button v-if="index > 0" type="warning" size="small" circle @click="moveImageUp(form.promoImages, index)">↑</el-button>
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ v-if="index < form.promoImages.length - 1"
|
|
|
|
|
+ type="warning"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ circle
|
|
|
|
|
+ @click="moveImageDown(form.promoImages, index)"
|
|
|
|
|
+ >↓</el-button
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="content-image-index">{{ index + 1 }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else class="no-image-tip">未添加宣传图片</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+
|
|
|
|
|
+ <el-form-item label="位置" prop="location">
|
|
|
|
|
+ <el-input v-model="form.location" placeholder="请输入位置" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 房型列表 -->
|
|
|
|
|
+ <el-form-item label="房型">
|
|
|
|
|
+ <div style="width: 100%">
|
|
|
|
|
+ <div v-for="(room, ri) in form.rooms" :key="ri" class="room-block">
|
|
|
|
|
+ <div class="room-block-header">
|
|
|
|
|
+ <span class="room-block-title">房型{{ ri + 1 }}</span>
|
|
|
|
|
+ <el-button type="danger" size="small" plain @click="removeRoom(ri)">删除房间</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-form-item label="房间" :prop="'rooms.' + ri + '.roomName'" :rules="rules.roomName" label-width="80px">
|
|
|
|
|
+ <el-input v-model="room.roomName" placeholder="请输入房间名称" style="width: 260px" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="房间介绍" label-width="80px">
|
|
|
|
|
+ <el-input v-model="room.roomIntro" placeholder="请输入房间介绍" style="width: 400px" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="房间图片" label-width="80px">
|
|
|
|
|
+ <div class="image-upload-container">
|
|
|
|
|
+ <div class="upload-buttons-row">
|
|
|
|
|
+ <el-upload
|
|
|
|
|
+ action="#"
|
|
|
|
|
+ :auto-upload="false"
|
|
|
|
|
+ :on-change="(file) => handleRoomFileChange(file, room)"
|
|
|
|
|
+ accept="image/*"
|
|
|
|
|
+ :show-file-list="false"
|
|
|
|
|
+ multiple
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-button type="primary" size="small">上传新图片</el-button>
|
|
|
|
|
+ </el-upload>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-tip">最多上传{{ MAX_IMAGES }}张房间图片</div>
|
|
|
|
|
+ <div v-if="room.roomImages.length > 0" class="content-images-grid">
|
|
|
|
|
+ <div v-for="(img, index) in room.roomImages" :key="index" class="content-image-item">
|
|
|
|
|
+ <el-image
|
|
|
|
|
+ :src="img"
|
|
|
|
|
+ style="width: 100%; height: 100px"
|
|
|
|
|
+ fit="cover"
|
|
|
|
|
+ :preview-src-list="room.roomImages"
|
|
|
|
|
+ :preview-teleported="true"
|
|
|
|
|
+ />
|
|
|
|
|
+ <div class="content-image-actions">
|
|
|
|
|
+ <el-button type="danger" size="small" circle icon="Delete" @click="room.roomImages.splice(index, 1)" />
|
|
|
|
|
+ <el-button v-if="index > 0" type="warning" size="small" circle @click="moveImageUp(room.roomImages, index)">↑</el-button>
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ v-if="index < room.roomImages.length - 1"
|
|
|
|
|
+ type="warning"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ circle
|
|
|
|
|
+ @click="moveImageDown(room.roomImages, index)"
|
|
|
|
|
+ >↓</el-button
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="content-image-index">{{ index + 1 }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else class="no-image-tip">未添加房间图片</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <!-- 房型时段 -->
|
|
|
|
|
+ <el-form-item v-for="(p, pi) in room.periods" :key="pi" :label="'房间时段' + (pi + 1)" label-width="80px">
|
|
|
|
|
+ <div class="period-row">
|
|
|
|
|
+ <el-date-picker
|
|
|
|
|
+ :model-value="p.startDate && p.endDate ? [p.startDate, p.endDate] : null"
|
|
|
|
|
+ @update:model-value="(v) => setPeriodRange(p, v)"
|
|
|
|
|
+ type="daterange"
|
|
|
|
|
+ range-separator="至"
|
|
|
|
|
+ start-placeholder="开始日期"
|
|
|
|
|
+ end-placeholder="结束日期"
|
|
|
|
|
+ value-format="YYYY-MM-DD"
|
|
|
|
|
+ style="width: 260px"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span class="period-count-label">剩余间数:</span>
|
|
|
|
|
+ <el-input-number v-model="p.remainingCount" :min="0" controls-position="right" placeholder="剩余间数" style="width: 110px" />
|
|
|
|
|
+ <el-button type="danger" size="small" plain @click="removePeriod(room, pi)">删除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label-width="80px" class="add-period-item">
|
|
|
|
|
+ <el-button type="primary" size="small" plain icon="Plus" @click="addPeriod(room)">增加时段</el-button>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="add-room-row">
|
|
|
|
|
+ <el-button type="primary" icon="Plus" @click="addRoom">添加房间</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button type="primary" @click="submitForm">保 存</el-button>
|
|
|
|
|
+ <el-button @click="dialogVisible = false">取 消</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup name="HotelBooking">
|
|
|
|
|
+import { listHotel, getHotel, addHotel, updateHotel, delHotel } from '@/api/content/hotel';
|
|
|
|
|
+import { uploadImageOnly } from '@/api/content/competitionZoneImage';
|
|
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus';
|
|
|
|
|
+
|
|
|
|
|
+const loading = ref(true);
|
|
|
|
|
+const showSearch = ref(true);
|
|
|
|
|
+const total = ref(0);
|
|
|
|
|
+const dataList = ref([]);
|
|
|
|
|
+
|
|
|
|
|
+const queryParams = reactive({
|
|
|
|
|
+ pageNum: 1,
|
|
|
|
|
+ pageSize: 10,
|
|
|
|
|
+ hotelName: null
|
|
|
|
|
+});
|
|
|
|
|
+const queryRef = ref(null);
|
|
|
|
|
+
|
|
|
|
|
+// ========== 弹窗表单 ==========
|
|
|
|
|
+const dialogVisible = ref(false);
|
|
|
|
|
+const dialogTitle = ref('');
|
|
|
|
|
+const formRef = ref(null);
|
|
|
|
|
+const promoUploadRef = ref(null);
|
|
|
|
|
+
|
|
|
|
|
+// 每个上传位最多5张图片(宣传图、每个房型的房间图片各自限5张)
|
|
|
|
|
+const MAX_IMAGES = 5;
|
|
|
|
|
+
|
|
|
|
|
+// 时段:只含当天,剩余间数创建后可二次修改
|
|
|
|
|
+const createPeriod = () => ({ id: null, startDate: null, endDate: null, remainingCount: null });
|
|
|
|
|
+const createRoom = () => ({ id: null, roomName: '', roomIntro: '', roomImages: [], periods: [createPeriod()] });
|
|
|
|
|
+
|
|
|
|
|
+// 空白时段行/空白房型块:保存时过滤掉,不提交给后端
|
|
|
|
|
+function isPeriodEmpty(p) {
|
|
|
|
|
+ return !p.startDate && !p.endDate && (p.remainingCount === null || p.remainingCount === undefined);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isRoomEmpty(room) {
|
|
|
|
|
+ const nameEmpty = !room.roomName || !room.roomName.trim();
|
|
|
|
|
+ const noImages = !room.roomImages || room.roomImages.length === 0;
|
|
|
|
|
+ return nameEmpty && noImages && (room.periods || []).every((p) => isPeriodEmpty(p));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const initForm = {
|
|
|
|
|
+ id: null,
|
|
|
|
|
+ hotelName: '',
|
|
|
|
|
+ introduction: '',
|
|
|
|
|
+ location: '',
|
|
|
|
|
+ // 留空由后端自动分配为「当前最大值+1」,避免默认值与已有记录撞车导致新增失败
|
|
|
|
|
+ sortOrder: null,
|
|
|
|
|
+ promoImages: [],
|
|
|
|
|
+ rooms: []
|
|
|
|
|
+};
|
|
|
|
|
+const form = ref({ ...initForm, promoImages: [], rooms: [] });
|
|
|
|
|
+
|
|
|
|
|
+// 置顶排序校验:新增允许留空(后端自动排到最后),修改必须是一个不小于1的有效值
|
|
|
|
|
+function validateSortOrder(rule, value, callback) {
|
|
|
|
|
+ const empty = value === null || value === undefined || value === '';
|
|
|
|
|
+ if (empty) {
|
|
|
|
|
+ if (!form.value.id) return callback();
|
|
|
|
|
+ return callback(new Error('请填写置顶排序值,最小为1且不可与其他记录重复'));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (Number(value) < 1) {
|
|
|
|
|
+ return callback(new Error('置顶排序最小为1'));
|
|
|
|
|
+ }
|
|
|
|
|
+ callback();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const rules = {
|
|
|
|
|
+ hotelName: [{ required: true, message: '酒店名称不能为空', trigger: 'blur' }],
|
|
|
|
|
+ sortOrder: [{ validator: validateSortOrder, trigger: 'blur' }],
|
|
|
|
|
+ roomName: [{ required: true, message: '房间名称不能为空', trigger: 'blur' }]
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ========== 列表操作 ==========
|
|
|
|
|
+async function getList() {
|
|
|
|
|
+ loading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await listHotel(queryParams);
|
|
|
|
|
+ dataList.value = res.rows || [];
|
|
|
|
|
+ total.value = res.total;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('加载数据失败:', err);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 等表格渲染完成后拉齐分割线高度
|
|
|
|
|
+ nextTick(() => equalizeRoomRows());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 拉齐四列的房型分割线:同一行内各列同序号房型块取最大高度统一,
|
|
|
|
|
+// 再统一整块高度(单元格内容垂直居中,整块等高后起点才一致),框高自适应内容
|
|
|
|
|
+function equalizeRoomRows() {
|
|
|
|
|
+ // 先清掉上次设置的高度,避免旧值影响本次测量
|
|
|
|
|
+ document.querySelectorAll('.cell-room-item, .cell-room-block').forEach((el) => {
|
|
|
|
|
+ el.style.minHeight = '';
|
|
|
|
|
+ });
|
|
|
|
|
+ document.querySelectorAll('.el-table__body-wrapper tbody tr').forEach((tr) => {
|
|
|
|
|
+ const groups = {};
|
|
|
|
|
+ tr.querySelectorAll('.cell-room-block').forEach((block) => {
|
|
|
|
|
+ Array.from(block.children).forEach((child, idx) => {
|
|
|
|
|
+ (groups[idx] = groups[idx] || []).push(child);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ Object.values(groups).forEach((group) => {
|
|
|
|
|
+ const max = Math.max(...group.map((el) => el.offsetHeight));
|
|
|
|
|
+ group.forEach((el) => {
|
|
|
|
|
+ el.style.minHeight = `${max}px`;
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ const blocks = tr.querySelectorAll('.cell-room-block');
|
|
|
|
|
+ if (blocks.length > 0) {
|
|
|
|
|
+ const maxBlock = Math.max(...Array.from(blocks).map((el) => el.offsetHeight));
|
|
|
|
|
+ blocks.forEach((el) => {
|
|
|
|
|
+ el.style.minHeight = `${maxBlock}px`;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ window.addEventListener('resize', equalizeRoomRows);
|
|
|
|
|
+});
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ window.removeEventListener('resize', equalizeRoomRows);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function handleQuery() {
|
|
|
|
|
+ queryParams.pageNum = 1;
|
|
|
|
|
+ getList();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resetQuery() {
|
|
|
|
|
+ if (queryRef.value) queryRef.value.resetFields();
|
|
|
|
|
+ handleQuery();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleDelete(row) {
|
|
|
|
|
+ ElMessageBox.confirm(`确定要删除「${row.hotelName}」吗?其房型、时段与图片将一并删除`, '系统提示', {
|
|
|
|
|
+ type: 'warning',
|
|
|
|
|
+ confirmButtonText: '确认删除'
|
|
|
|
|
+ })
|
|
|
|
|
+ .then(async () => {
|
|
|
|
|
+ await delHotel(row.id);
|
|
|
|
|
+ ElMessage.success('删除成功');
|
|
|
|
|
+ getList();
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ========== 新增/修改 ==========
|
|
|
|
|
+function handleAdd() {
|
|
|
|
|
+ // 默认带一个房型块,与原型一致:房间、房间介绍、房间图片、时段直接可见
|
|
|
|
|
+ form.value = { ...initForm, promoImages: [], rooms: [createRoom()] };
|
|
|
|
|
+ dialogTitle.value = '新增酒店订房';
|
|
|
|
|
+ dialogVisible.value = true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleUpdate(row) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getHotel(row.id);
|
|
|
|
|
+ const data = res.data;
|
|
|
|
|
+ form.value = {
|
|
|
|
|
+ id: data.id,
|
|
|
|
|
+ hotelName: data.hotelName || '',
|
|
|
|
|
+ introduction: data.introduction || '',
|
|
|
|
|
+ location: data.location || '',
|
|
|
|
|
+ // 库里若为 0/NULL 这类无效值,保持为空强制用户重填,不能兜底成 0(后端会拒绝保存)
|
|
|
|
|
+ sortOrder: data.sortOrder && data.sortOrder >= 1 ? data.sortOrder : null,
|
|
|
|
|
+ promoImages: (data.imageList || []).map((img) => img.imageUrl),
|
|
|
|
|
+ rooms: (data.roomList || []).map((room) => ({
|
|
|
|
|
+ id: room.id,
|
|
|
|
|
+ roomName: room.roomName || '',
|
|
|
|
|
+ roomIntro: room.roomIntro || '',
|
|
|
|
|
+ roomImages: (room.imageList || []).map((img) => img.imageUrl),
|
|
|
|
|
+ periods: (room.periodList || []).map((p) => ({
|
|
|
|
|
+ id: p.id,
|
|
|
|
|
+ startDate: p.startDate,
|
|
|
|
|
+ endDate: p.endDate,
|
|
|
|
|
+ remainingCount: p.remainingCount ?? 0
|
|
|
|
|
+ }))
|
|
|
|
|
+ }))
|
|
|
|
|
+ };
|
|
|
|
|
+ // 无房型的酒店同样展示一个空白房型块,便于直接录入
|
|
|
|
|
+ if (form.value.rooms.length === 0) form.value.rooms.push(createRoom());
|
|
|
|
|
+ dialogTitle.value = '修改酒店订房';
|
|
|
|
|
+ dialogVisible.value = true;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('加载详情失败:', err);
|
|
|
|
|
+ // 不能静默吞掉:否则接口失败/渲染异常时点修改毫无提示,看起来像按钮坏了
|
|
|
|
|
+ ElMessage.error('加载详情失败:' + (err?.message || err));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 提交前校验房型时段完整性(后端也会校验,这里提前拦住给出明确提示)
|
|
|
|
|
+function validateRooms(rooms) {
|
|
|
|
|
+ for (let ri = 0; ri < rooms.length; ri++) {
|
|
|
|
|
+ const room = rooms[ri];
|
|
|
|
|
+ if (!room.roomName || !room.roomName.trim()) {
|
|
|
|
|
+ ElMessage.warning(`房型${ri + 1}的房间名称不能为空`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let pi = 0; pi < room.periods.length; pi++) {
|
|
|
|
|
+ const p = room.periods[pi];
|
|
|
|
|
+ if (!p.startDate || !p.endDate) {
|
|
|
|
|
+ ElMessage.warning(`房型${ri + 1}的时段${pi + 1}请选择完整日期范围`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p.startDate > p.endDate) {
|
|
|
|
|
+ ElMessage.warning(`房型${ri + 1}的时段${pi + 1}开始日期不能晚于结束日期`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p.remainingCount === null || p.remainingCount === undefined || p.remainingCount < 0) {
|
|
|
|
|
+ ElMessage.warning(`房型${ri + 1}的时段${pi + 1}剩余间数不能为空`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function submitForm() {
|
|
|
|
|
+ formRef.value.validate(async (valid) => {
|
|
|
|
|
+ if (!valid) return;
|
|
|
|
|
+ // 过滤空白房型块与空白时段行后再校验与提交
|
|
|
|
|
+ const rooms = form.value.rooms
|
|
|
|
|
+ .filter((room) => !isRoomEmpty(room))
|
|
|
|
|
+ .map((room) => ({ ...room, periods: room.periods.filter((p) => !isPeriodEmpty(p)) }));
|
|
|
|
|
+ if (!validateRooms(rooms)) return;
|
|
|
|
|
+ const payload = { ...form.value, rooms };
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (payload.id) {
|
|
|
|
|
+ await updateHotel(payload);
|
|
|
|
|
+ ElMessage.success('修改成功');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await addHotel(payload);
|
|
|
|
|
+ ElMessage.success('新增成功');
|
|
|
|
|
+ }
|
|
|
|
|
+ dialogVisible.value = false;
|
|
|
|
|
+ getList();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('保存失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ========== 房型/时段编辑 ==========
|
|
|
|
|
+function addRoom() {
|
|
|
|
|
+ form.value.rooms.push(createRoom());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function removeRoom(ri) {
|
|
|
|
|
+ ElMessageBox.confirm(`确定要删除房型${ri + 1}吗?`, '系统提示', { type: 'warning', confirmButtonText: '确认删除' })
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ form.value.rooms.splice(ri, 1);
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function addPeriod(room) {
|
|
|
|
|
+ room.periods.push(createPeriod());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function removePeriod(room, pi) {
|
|
|
|
|
+ room.periods.splice(pi, 1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setPeriodRange(period, value) {
|
|
|
|
|
+ period.startDate = value && value.length === 2 ? value[0] : null;
|
|
|
|
|
+ period.endDate = value && value.length === 2 ? value[1] : null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ========== 图片上传(仅上传OSS,不存图库) ==========
|
|
|
|
|
+async function uploadFileToUrl(rawFile) {
|
|
|
|
|
+ if (!rawFile.type.startsWith('image/')) {
|
|
|
|
|
+ ElMessage.error('只能上传图片文件');
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (rawFile.size > 5 * 1024 * 1024) {
|
|
|
|
|
+ ElMessage.error('图片大小不能超过5MB');
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ const formData = new FormData();
|
|
|
|
|
+ formData.append('file', rawFile);
|
|
|
|
|
+ const res = await uploadImageOnly(formData);
|
|
|
|
|
+ if (res.code === 200 && res.data && res.data.imageUrl) {
|
|
|
|
|
+ return res.data.imageUrl;
|
|
|
|
|
+ }
|
|
|
|
|
+ ElMessage.error(res.msg || '上传失败');
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handlePromoFileChange(file) {
|
|
|
|
|
+ if (form.value.promoImages.length >= MAX_IMAGES) {
|
|
|
|
|
+ ElMessage.warning(`宣传图片最多上传${MAX_IMAGES}张`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = await uploadFileToUrl(file.raw);
|
|
|
|
|
+ if (url) {
|
|
|
|
|
+ form.value.promoImages.push(url);
|
|
|
|
|
+ ElMessage.success('图片上传成功');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('上传失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleRoomFileChange(file, room) {
|
|
|
|
|
+ if (room.roomImages.length >= MAX_IMAGES) {
|
|
|
|
|
+ ElMessage.warning(`每个房型的房间图片最多上传${MAX_IMAGES}张`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = await uploadFileToUrl(file.raw);
|
|
|
|
|
+ if (url) {
|
|
|
|
|
+ room.roomImages.push(url);
|
|
|
|
|
+ ElMessage.success('图片上传成功');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('上传失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function moveImageUp(arr, index) {
|
|
|
|
|
+ [arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function moveImageDown(arr, index) {
|
|
|
|
|
+ [arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+getList();
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.cell-images {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-images-more {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-empty {
|
|
|
|
|
+ color: #c0c4cc;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-room-block {
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ padding: 4px 8px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ line-height: 1.8;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 操作按钮一上一下竖排:两个按钮同宽且水平居中,图标与文字对齐在同一条中线上 */
|
|
|
|
|
+.action-buttons {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.action-buttons .el-button {
|
|
|
|
|
+ width: 64px;
|
|
|
|
|
+ margin-left: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 房型之间的分割线:单元格内每个房型块之间加虚线分隔,最后一个不加 */
|
|
|
|
|
+.cell-room-item:not(:last-child) {
|
|
|
|
|
+ border-bottom: 1px dashed #dcdfe6;
|
|
|
|
|
+ padding-bottom: 6px;
|
|
|
|
|
+ margin-bottom: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-room-title {
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: #303133;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-room-line {
|
|
|
|
|
+ color: #606266;
|
|
|
|
|
+ padding-left: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-room-images-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cell-room-img-index {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #606266;
|
|
|
|
|
+ width: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.form-inline-tip {
|
|
|
|
|
+ margin-left: 10px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.image-upload-container {
|
|
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ padding: 12px;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-buttons-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.no-image-tip {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ border: 1px dashed #dcdfe6;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.form-tip {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.content-images-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ margin-top: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.content-image-item {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.content-image-actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ padding: 6px;
|
|
|
|
|
+ background: #fafafa;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.content-image-index {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 4px;
|
|
|
|
|
+ left: 4px;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.6);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ width: 20px;
|
|
|
|
|
+ height: 20px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.room-block {
|
|
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ padding: 16px 12px 16px;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+ background: #fafafa;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.room-block-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.room-block-title {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: #303133;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.period-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.period-count-label {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #606266;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 增加时段按钮与上方时段行、卡片底边拉开距离 */
|
|
|
|
|
+.add-period-item {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ margin-bottom: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.add-room-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|