wengan01 1 неделя назад
Родитель
Сommit
2b09fc3a1e

+ 18 - 0
src/api/content/bookingRecord.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request';
+
+// 查询订房记录列表
+export function listBooking(query) {
+  return request({
+    url: '/content/hotelBooking/list',
+    method: 'get',
+    params: query
+  });
+}
+
+// 查询订房记录详细
+export function getBooking(id) {
+  return request({
+    url: '/content/hotelBooking/' + id,
+    method: 'get'
+  });
+}

+ 44 - 0
src/api/content/hotel.js

@@ -0,0 +1,44 @@
+import request from '@/utils/request';
+
+// 查询酒店订房列表
+export function listHotel(query) {
+  return request({
+    url: '/content/hotel/list',
+    method: 'get',
+    params: query
+  });
+}
+
+// 查询酒店详细
+export function getHotel(id) {
+  return request({
+    url: '/content/hotel/' + id,
+    method: 'get'
+  });
+}
+
+// 新增酒店
+export function addHotel(data) {
+  return request({
+    url: '/content/hotel',
+    method: 'post',
+    data: data
+  });
+}
+
+// 修改酒店
+export function updateHotel(data) {
+  return request({
+    url: '/content/hotel',
+    method: 'put',
+    data: data
+  });
+}
+
+// 删除酒店
+export function delHotel(ids) {
+  return request({
+    url: '/content/hotel/' + ids,
+    method: 'delete'
+  });
+}

+ 129 - 0
src/views/content/bookingRecord/index.vue

@@ -0,0 +1,129 @@
+<template>
+  <div class="app-container">
+    <!-- 搜索区域 -->
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" @submit.prevent>
+      <el-form-item label="选手姓名" prop="contactName">
+        <el-input v-model="queryParams.contactName" placeholder="请输入选手姓名" clearable @keyup.enter="handleQuery" />
+      </el-form-item>
+      <el-form-item label="手机号码" prop="contactPhone">
+        <el-input v-model="queryParams.contactPhone" placeholder="请输入手机号码" clearable @keyup.enter="handleQuery" />
+      </el-form-item>
+      <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 v-hasPermi="['content:bookingRecord:export']" type="warning" plain icon="Download" @click="handleExport"> 导出 </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="用户ID" align="center" prop="userId" min-width="100" show-overflow-tooltip />
+      <el-table-column label="选手姓名" align="center" prop="contactName" min-width="100" show-overflow-tooltip>
+        <template #default="scope">
+          {{ scope.row.contactName || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="选手身份证号码" align="center" prop="idCard" min-width="160" show-overflow-tooltip>
+        <template #default="scope">
+          {{ scope.row.idCard || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="手机号码" align="center" prop="contactPhone" min-width="120" show-overflow-tooltip>
+        <template #default="scope">
+          {{ scope.row.contactPhone || '-' }}
+        </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="roomName" min-width="100" show-overflow-tooltip />
+      <el-table-column label="房型时间段" align="center" min-width="200">
+        <template #default="scope">
+          <span v-if="scope.row.startDate && scope.row.endDate">
+            {{ scope.row.startDate }}-{{ scope.row.endDate }}({{ stayDays(scope.row) }}天)
+          </span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status" width="90">
+        <template #default="scope">
+          <el-tag :type="statusTagType[scope.row.status] || 'info'">{{ statusMap[scope.row.status] || '未知' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="订房时间" align="center" prop="createTime" min-width="160" show-overflow-tooltip />
+    </el-table>
+
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
+  </div>
+</template>
+
+<script setup name="BookingRecord">
+import { listBooking } from '@/api/content/bookingRecord';
+
+const { proxy } = getCurrentInstance();
+
+const loading = ref(true);
+const showSearch = ref(true);
+const total = ref(0);
+const dataList = ref([]);
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  contactName: null,
+  contactPhone: null,
+  hotelName: null
+});
+const queryRef = ref(null);
+
+const statusMap = { 0: '已预订', 1: '已取消', 2: '已完成' };
+const statusTagType = { 0: 'success', 1: 'info', 2: 'primary' };
+
+// 入住天数(含首尾两天)
+function stayDays(row) {
+  const days = Math.round((new Date(row.endDate).getTime() - new Date(row.startDate).getTime()) / 86400000) + 1;
+  return days > 0 ? days : '-';
+}
+
+async function getList() {
+  loading.value = true;
+  try {
+    const res = await listBooking(queryParams);
+    dataList.value = res.rows || [];
+    total.value = res.total;
+  } catch (err) {
+    console.error('加载数据失败:', err);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1;
+  getList();
+}
+
+function resetQuery() {
+  if (queryRef.value) queryRef.value.resetFields();
+  handleQuery();
+}
+
+function handleExport() {
+  proxy?.download('content/hotelBooking/export', { ...queryParams }, `booking_${new Date().getTime()}.xlsx`);
+}
+
+getList();
+</script>

+ 797 - 0
src/views/content/hotelBooking/index.vue

@@ -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>