index.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  1. <template>
  2. <div class="app-container">
  3. <!-- 搜索区域 -->
  4. <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" @submit.prevent>
  5. <el-form-item label="酒店名称" prop="hotelName">
  6. <el-input v-model="queryParams.hotelName" placeholder="请输入酒店名称" clearable @keyup.enter="handleQuery" />
  7. </el-form-item>
  8. <el-form-item>
  9. <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
  10. <el-button icon="Refresh" @click="resetQuery">重置</el-button>
  11. </el-form-item>
  12. </el-form>
  13. <!-- 操作按钮 -->
  14. <el-row :gutter="10" class="mb8">
  15. <el-col :span="1.5">
  16. <el-button type="primary" plain icon="Plus" @click="handleAdd">新增</el-button>
  17. </el-col>
  18. <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
  19. </el-row>
  20. <!-- 表格 -->
  21. <el-table v-loading="loading" border :data="dataList">
  22. <el-table-column label="序号" align="center" width="60">
  23. <template #default="scope">
  24. {{ (queryParams.pageNum - 1) * queryParams.pageSize + scope.$index + 1 }}
  25. </template>
  26. </el-table-column>
  27. <el-table-column label="酒店名称" align="center" prop="hotelName" min-width="120" show-overflow-tooltip />
  28. <el-table-column label="酒店简介" align="center" prop="introduction" min-width="140" show-overflow-tooltip>
  29. <template #default="scope">
  30. {{ scope.row.introduction || '-' }}
  31. </template>
  32. </el-table-column>
  33. <el-table-column label="宣传图片" align="center" width="180">
  34. <template #default="scope">
  35. <div v-if="scope.row.imageList && scope.row.imageList.length > 0" class="cell-images">
  36. <el-image
  37. v-for="(img, idx) in scope.row.imageList.slice(0, 3)"
  38. :key="idx"
  39. :src="img.imageUrl"
  40. style="width: 44px; height: 44px; border-radius: 3px; cursor: pointer; border: 1px solid #e4e7ed"
  41. fit="cover"
  42. :preview-src-list="scope.row.imageList.map((i) => i.imageUrl)"
  43. :preview-teleported="true"
  44. :initial-index="idx"
  45. />
  46. <span v-if="scope.row.imageList.length > 3" class="cell-images-more">+{{ scope.row.imageList.length - 3 }}</span>
  47. </div>
  48. <span v-else class="cell-empty">暂无</span>
  49. </template>
  50. </el-table-column>
  51. <el-table-column label="房型与时段" align="center" min-width="220">
  52. <template #default="scope">
  53. <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
  54. <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-item">
  55. <div class="cell-room-title">{{ ri + 1 }}.{{ room.roomName }}:</div>
  56. <div v-for="(p, pi) in room.periodList || []" :key="p.id || pi" class="cell-room-line">
  57. 时间段{{ pi + 1 }}:{{ p.startDate }}-{{ p.endDate }}
  58. </div>
  59. <div v-if="!room.periodList || room.periodList.length === 0" class="cell-room-line">未配置时段</div>
  60. </div>
  61. </div>
  62. <span v-else class="cell-empty">暂无</span>
  63. </template>
  64. </el-table-column>
  65. <el-table-column label="剩余数量" align="center" min-width="150">
  66. <template #default="scope">
  67. <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
  68. <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-item">
  69. <div v-for="(p, pi) in room.periodList || []" :key="p.id || pi" class="cell-room-line">
  70. 时间段{{ pi + 1 }}剩余房间:{{ p.remainingCount }}
  71. </div>
  72. </div>
  73. </div>
  74. <span v-else class="cell-empty">暂无</span>
  75. </template>
  76. </el-table-column>
  77. <el-table-column label="房间介绍" align="center" min-width="140">
  78. <template #default="scope">
  79. <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
  80. <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-line cell-room-item" style="white-space: pre-line">
  81. {{ ri + 1 }}.{{ room.roomIntro || '暂无' }}
  82. </div>
  83. </div>
  84. <span v-else class="cell-empty">暂无</span>
  85. </template>
  86. </el-table-column>
  87. <el-table-column label="房间图片" align="center" width="180">
  88. <template #default="scope">
  89. <div v-if="scope.row.roomList && scope.row.roomList.length > 0" class="cell-room-block">
  90. <div v-for="(room, ri) in scope.row.roomList" :key="room.id || ri" class="cell-room-images-row cell-room-item">
  91. <span class="cell-room-img-index">{{ ri + 1 }}.</span>
  92. <div class="cell-images">
  93. <el-image
  94. v-for="(img, idx) in (room.imageList || []).slice(0, 3)"
  95. :key="idx"
  96. :src="img.imageUrl"
  97. style="width: 36px; height: 36px; border-radius: 3px; cursor: pointer; border: 1px solid #e4e7ed"
  98. fit="cover"
  99. :preview-src-list="(room.imageList || []).map((i) => i.imageUrl)"
  100. :preview-teleported="true"
  101. :initial-index="idx"
  102. />
  103. <span v-if="(room.imageList || []).length > 3" class="cell-images-more">+{{ room.imageList.length - 3 }}</span>
  104. </div>
  105. </div>
  106. </div>
  107. <span v-else class="cell-empty">暂无</span>
  108. </template>
  109. </el-table-column>
  110. <el-table-column label="置顶排序" align="center" prop="sortOrder" width="90" />
  111. <el-table-column label="操作" align="center" width="140" fixed="right">
  112. <template #default="scope">
  113. <div class="action-buttons">
  114. <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
  115. <el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
  116. </div>
  117. </template>
  118. </el-table-column>
  119. </el-table>
  120. <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
  121. <!-- 新增/修改对话框 -->
  122. <el-dialog :title="dialogTitle" v-model="dialogVisible" width="860px" append-to-body :close-on-click-modal="false">
  123. <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
  124. <el-form-item label="酒店" prop="hotelName">
  125. <el-input v-model="form.hotelName" placeholder="请输入酒店名称" />
  126. </el-form-item>
  127. <el-form-item label="酒店简介" prop="introduction">
  128. <el-input v-model="form.introduction" placeholder="请输入酒店简介" />
  129. </el-form-item>
  130. <el-form-item label="排序" prop="sortOrder">
  131. <el-input-number
  132. v-model="form.sortOrder"
  133. :min="1"
  134. :value-on-clear="null"
  135. placeholder="留空自动排到最后"
  136. controls-position="right"
  137. style="width: 200px"
  138. />
  139. <span class="form-inline-tip">数字越小越置顶</span>
  140. </el-form-item>
  141. <!-- 宣传图片(多张,第一张为封面) -->
  142. <el-form-item label="宣传图片">
  143. <div class="image-upload-container">
  144. <div class="upload-buttons-row">
  145. <el-upload
  146. ref="promoUploadRef"
  147. action="#"
  148. :auto-upload="false"
  149. :on-change="handlePromoFileChange"
  150. accept="image/*"
  151. :show-file-list="false"
  152. multiple
  153. >
  154. <el-button type="primary" size="small">上传新图片</el-button>
  155. </el-upload>
  156. </div>
  157. <div class="form-tip">支持多张,最多{{ MAX_IMAGES }}张,第一张为封面图片</div>
  158. <div v-if="form.promoImages.length > 0" class="content-images-grid">
  159. <div v-for="(img, index) in form.promoImages" :key="index" class="content-image-item">
  160. <el-image :src="img" style="width: 100%; height: 100px" fit="cover" :preview-src-list="form.promoImages" :preview-teleported="true" />
  161. <div class="content-image-actions">
  162. <el-button type="danger" size="small" circle icon="Delete" @click="form.promoImages.splice(index, 1)" />
  163. <el-button v-if="index > 0" type="warning" size="small" circle @click="moveImageUp(form.promoImages, index)">↑</el-button>
  164. <el-button
  165. v-if="index < form.promoImages.length - 1"
  166. type="warning"
  167. size="small"
  168. circle
  169. @click="moveImageDown(form.promoImages, index)"
  170. >↓</el-button
  171. >
  172. </div>
  173. <div class="content-image-index">{{ index + 1 }}</div>
  174. </div>
  175. </div>
  176. <div v-else class="no-image-tip">未添加宣传图片</div>
  177. </div>
  178. </el-form-item>
  179. <el-form-item label="位置" prop="location">
  180. <el-input v-model="form.location" placeholder="请输入位置" />
  181. </el-form-item>
  182. <!-- 房型列表 -->
  183. <el-form-item label="房型">
  184. <div style="width: 100%">
  185. <div v-for="(room, ri) in form.rooms" :key="ri" class="room-block">
  186. <div class="room-block-header">
  187. <span class="room-block-title">房型{{ ri + 1 }}</span>
  188. <el-button type="danger" size="small" plain @click="removeRoom(ri)">删除房间</el-button>
  189. </div>
  190. <el-form-item label="房间" :prop="'rooms.' + ri + '.roomName'" :rules="rules.roomName" label-width="80px">
  191. <el-input v-model="room.roomName" placeholder="请输入房间名称" style="width: 260px" />
  192. </el-form-item>
  193. <el-form-item label="房间介绍" label-width="80px">
  194. <el-input
  195. v-model="room.roomIntro"
  196. type="textarea"
  197. :autosize="{ minRows: 2, maxRows: 6 }"
  198. placeholder="请输入房间介绍,回车换行,APP端按换行显示"
  199. style="width: 400px"
  200. />
  201. </el-form-item>
  202. <el-form-item label="房间图片" label-width="80px">
  203. <div class="image-upload-container">
  204. <div class="upload-buttons-row">
  205. <el-upload
  206. action="#"
  207. :auto-upload="false"
  208. :on-change="(file) => handleRoomFileChange(file, room)"
  209. accept="image/*"
  210. :show-file-list="false"
  211. multiple
  212. >
  213. <el-button type="primary" size="small">上传新图片</el-button>
  214. </el-upload>
  215. </div>
  216. <div class="form-tip">最多上传{{ MAX_IMAGES }}张房间图片</div>
  217. <div v-if="room.roomImages.length > 0" class="content-images-grid">
  218. <div v-for="(img, index) in room.roomImages" :key="index" class="content-image-item">
  219. <el-image
  220. :src="img"
  221. style="width: 100%; height: 100px"
  222. fit="cover"
  223. :preview-src-list="room.roomImages"
  224. :preview-teleported="true"
  225. />
  226. <div class="content-image-actions">
  227. <el-button type="danger" size="small" circle icon="Delete" @click="room.roomImages.splice(index, 1)" />
  228. <el-button v-if="index > 0" type="warning" size="small" circle @click="moveImageUp(room.roomImages, index)">↑</el-button>
  229. <el-button
  230. v-if="index < room.roomImages.length - 1"
  231. type="warning"
  232. size="small"
  233. circle
  234. @click="moveImageDown(room.roomImages, index)"
  235. >↓</el-button
  236. >
  237. </div>
  238. <div class="content-image-index">{{ index + 1 }}</div>
  239. </div>
  240. </div>
  241. <div v-else class="no-image-tip">未添加房间图片</div>
  242. </div>
  243. </el-form-item>
  244. <!-- 房型时段 -->
  245. <el-form-item v-for="(p, pi) in room.periods" :key="pi" :label="'房间时段' + (pi + 1)" label-width="80px">
  246. <div class="period-row">
  247. <el-date-picker
  248. :model-value="p.startDate && p.endDate ? [p.startDate, p.endDate] : null"
  249. @update:model-value="(v) => setPeriodRange(p, v)"
  250. type="daterange"
  251. range-separator="至"
  252. start-placeholder="开始日期"
  253. end-placeholder="结束日期"
  254. value-format="YYYY-MM-DD"
  255. style="width: 260px"
  256. />
  257. <span class="period-count-label">剩余间数:</span>
  258. <el-input-number v-model="p.remainingCount" :min="0" controls-position="right" placeholder="剩余间数" style="width: 110px" />
  259. <el-button type="danger" size="small" plain @click="removePeriod(room, pi)">删除</el-button>
  260. </div>
  261. </el-form-item>
  262. <el-form-item label-width="80px" class="add-period-item">
  263. <el-button type="primary" size="small" plain icon="Plus" @click="addPeriod(room)">增加时段</el-button>
  264. </el-form-item>
  265. </div>
  266. <div class="add-room-row">
  267. <el-button type="primary" icon="Plus" @click="addRoom">添加房间</el-button>
  268. </div>
  269. </div>
  270. </el-form-item>
  271. </el-form>
  272. <template #footer>
  273. <el-button type="primary" @click="submitForm">保 存</el-button>
  274. <el-button @click="dialogVisible = false">取 消</el-button>
  275. </template>
  276. </el-dialog>
  277. </div>
  278. </template>
  279. <script setup name="HotelBooking">
  280. import { listHotel, getHotel, addHotel, updateHotel, delHotel } from '@/api/content/hotel';
  281. import { uploadImageOnly } from '@/api/content/competitionZoneImage';
  282. import { ElMessage, ElMessageBox } from 'element-plus';
  283. const loading = ref(true);
  284. const showSearch = ref(true);
  285. const total = ref(0);
  286. const dataList = ref([]);
  287. const queryParams = reactive({
  288. pageNum: 1,
  289. pageSize: 10,
  290. hotelName: null
  291. });
  292. const queryRef = ref(null);
  293. // ========== 弹窗表单 ==========
  294. const dialogVisible = ref(false);
  295. const dialogTitle = ref('');
  296. const formRef = ref(null);
  297. const promoUploadRef = ref(null);
  298. // 每个上传位最多5张图片(宣传图、每个房型的房间图片各自限5张)
  299. const MAX_IMAGES = 5;
  300. // 时段:只含当天,剩余间数创建后可二次修改
  301. const createPeriod = () => ({ id: null, startDate: null, endDate: null, remainingCount: null });
  302. const createRoom = () => ({ id: null, roomName: '', roomIntro: '', roomImages: [], periods: [createPeriod()] });
  303. // 空白时段行/空白房型块:保存时过滤掉,不提交给后端
  304. function isPeriodEmpty(p) {
  305. return !p.startDate && !p.endDate && (p.remainingCount === null || p.remainingCount === undefined);
  306. }
  307. function isRoomEmpty(room) {
  308. const nameEmpty = !room.roomName || !room.roomName.trim();
  309. const noImages = !room.roomImages || room.roomImages.length === 0;
  310. return nameEmpty && noImages && (room.periods || []).every((p) => isPeriodEmpty(p));
  311. }
  312. const initForm = {
  313. id: null,
  314. hotelName: '',
  315. introduction: '',
  316. location: '',
  317. // 留空由后端自动分配为「当前最大值+1」,避免默认值与已有记录撞车导致新增失败
  318. sortOrder: null,
  319. promoImages: [],
  320. rooms: []
  321. };
  322. const form = ref({ ...initForm, promoImages: [], rooms: [] });
  323. // 置顶排序校验:新增允许留空(后端自动排到最后),修改必须是一个不小于1的有效值
  324. function validateSortOrder(rule, value, callback) {
  325. const empty = value === null || value === undefined || value === '';
  326. if (empty) {
  327. if (!form.value.id) return callback();
  328. return callback(new Error('请填写置顶排序值,最小为1且不可与其他记录重复'));
  329. }
  330. if (Number(value) < 1) {
  331. return callback(new Error('置顶排序最小为1'));
  332. }
  333. callback();
  334. }
  335. const rules = {
  336. hotelName: [{ required: true, message: '酒店名称不能为空', trigger: 'blur' }],
  337. sortOrder: [{ validator: validateSortOrder, trigger: 'blur' }],
  338. roomName: [{ required: true, message: '房间名称不能为空', trigger: 'blur' }]
  339. };
  340. // ========== 列表操作 ==========
  341. async function getList() {
  342. loading.value = true;
  343. try {
  344. const res = await listHotel(queryParams);
  345. dataList.value = res.rows || [];
  346. total.value = res.total;
  347. } catch (err) {
  348. console.error('加载数据失败:', err);
  349. } finally {
  350. loading.value = false;
  351. }
  352. // 等表格渲染完成后拉齐分割线高度
  353. nextTick(() => equalizeRoomRows());
  354. }
  355. // 拉齐四列的房型分割线:同一行内各列同序号房型块取最大高度统一,
  356. // 再统一整块高度(单元格内容垂直居中,整块等高后起点才一致),框高自适应内容
  357. function equalizeRoomRows() {
  358. // 先清掉上次设置的高度,避免旧值影响本次测量
  359. document.querySelectorAll('.cell-room-item, .cell-room-block').forEach((el) => {
  360. el.style.minHeight = '';
  361. });
  362. document.querySelectorAll('.el-table__body-wrapper tbody tr').forEach((tr) => {
  363. const groups = {};
  364. tr.querySelectorAll('.cell-room-block').forEach((block) => {
  365. Array.from(block.children).forEach((child, idx) => {
  366. (groups[idx] = groups[idx] || []).push(child);
  367. });
  368. });
  369. Object.values(groups).forEach((group) => {
  370. const max = Math.max(...group.map((el) => el.offsetHeight));
  371. group.forEach((el) => {
  372. el.style.minHeight = `${max}px`;
  373. });
  374. });
  375. const blocks = tr.querySelectorAll('.cell-room-block');
  376. if (blocks.length > 0) {
  377. const maxBlock = Math.max(...Array.from(blocks).map((el) => el.offsetHeight));
  378. blocks.forEach((el) => {
  379. el.style.minHeight = `${maxBlock}px`;
  380. });
  381. }
  382. });
  383. }
  384. onMounted(() => {
  385. window.addEventListener('resize', equalizeRoomRows);
  386. });
  387. onBeforeUnmount(() => {
  388. window.removeEventListener('resize', equalizeRoomRows);
  389. });
  390. function handleQuery() {
  391. queryParams.pageNum = 1;
  392. getList();
  393. }
  394. function resetQuery() {
  395. if (queryRef.value) queryRef.value.resetFields();
  396. handleQuery();
  397. }
  398. function handleDelete(row) {
  399. ElMessageBox.confirm(`确定要删除「${row.hotelName}」吗?其房型、时段与图片将一并删除`, '系统提示', {
  400. type: 'warning',
  401. confirmButtonText: '确认删除'
  402. })
  403. .then(async () => {
  404. await delHotel(row.id);
  405. ElMessage.success('删除成功');
  406. getList();
  407. })
  408. .catch(() => {});
  409. }
  410. // ========== 新增/修改 ==========
  411. function handleAdd() {
  412. // 默认带一个房型块,与原型一致:房间、房间介绍、房间图片、时段直接可见
  413. form.value = { ...initForm, promoImages: [], rooms: [createRoom()] };
  414. dialogTitle.value = '新增酒店订房';
  415. dialogVisible.value = true;
  416. }
  417. async function handleUpdate(row) {
  418. try {
  419. const res = await getHotel(row.id);
  420. const data = res.data;
  421. form.value = {
  422. id: data.id,
  423. hotelName: data.hotelName || '',
  424. introduction: data.introduction || '',
  425. location: data.location || '',
  426. // 库里若为 0/NULL 这类无效值,保持为空强制用户重填,不能兜底成 0(后端会拒绝保存)
  427. sortOrder: data.sortOrder && data.sortOrder >= 1 ? data.sortOrder : null,
  428. promoImages: (data.imageList || []).map((img) => img.imageUrl),
  429. rooms: (data.roomList || []).map((room) => ({
  430. id: room.id,
  431. roomName: room.roomName || '',
  432. roomIntro: room.roomIntro || '',
  433. roomImages: (room.imageList || []).map((img) => img.imageUrl),
  434. periods: (room.periodList || []).map((p) => ({
  435. id: p.id,
  436. startDate: p.startDate,
  437. endDate: p.endDate,
  438. remainingCount: p.remainingCount ?? 0
  439. }))
  440. }))
  441. };
  442. // 无房型的酒店同样展示一个空白房型块,便于直接录入
  443. if (form.value.rooms.length === 0) form.value.rooms.push(createRoom());
  444. dialogTitle.value = '修改酒店订房';
  445. dialogVisible.value = true;
  446. } catch (err) {
  447. console.error('加载详情失败:', err);
  448. // 不能静默吞掉:否则接口失败/渲染异常时点修改毫无提示,看起来像按钮坏了
  449. ElMessage.error('加载详情失败:' + (err?.message || err));
  450. }
  451. }
  452. // 提交前校验房型时段完整性(后端也会校验,这里提前拦住给出明确提示)
  453. function validateRooms(rooms) {
  454. for (let ri = 0; ri < rooms.length; ri++) {
  455. const room = rooms[ri];
  456. if (!room.roomName || !room.roomName.trim()) {
  457. ElMessage.warning(`房型${ri + 1}的房间名称不能为空`);
  458. return false;
  459. }
  460. for (let pi = 0; pi < room.periods.length; pi++) {
  461. const p = room.periods[pi];
  462. if (!p.startDate || !p.endDate) {
  463. ElMessage.warning(`房型${ri + 1}的时段${pi + 1}请选择完整日期范围`);
  464. return false;
  465. }
  466. if (p.startDate > p.endDate) {
  467. ElMessage.warning(`房型${ri + 1}的时段${pi + 1}开始日期不能晚于结束日期`);
  468. return false;
  469. }
  470. if (p.remainingCount === null || p.remainingCount === undefined || p.remainingCount < 0) {
  471. ElMessage.warning(`房型${ri + 1}的时段${pi + 1}剩余间数不能为空`);
  472. return false;
  473. }
  474. }
  475. }
  476. return true;
  477. }
  478. function submitForm() {
  479. formRef.value.validate(async (valid) => {
  480. if (!valid) return;
  481. // 过滤空白房型块与空白时段行后再校验与提交
  482. const rooms = form.value.rooms
  483. .filter((room) => !isRoomEmpty(room))
  484. .map((room) => ({ ...room, periods: room.periods.filter((p) => !isPeriodEmpty(p)) }));
  485. if (!validateRooms(rooms)) return;
  486. const payload = { ...form.value, rooms };
  487. try {
  488. if (payload.id) {
  489. await updateHotel(payload);
  490. ElMessage.success('修改成功');
  491. } else {
  492. await addHotel(payload);
  493. ElMessage.success('新增成功');
  494. }
  495. dialogVisible.value = false;
  496. getList();
  497. } catch (err) {
  498. console.error('保存失败:', err);
  499. }
  500. });
  501. }
  502. // ========== 房型/时段编辑 ==========
  503. function addRoom() {
  504. form.value.rooms.push(createRoom());
  505. }
  506. function removeRoom(ri) {
  507. ElMessageBox.confirm(`确定要删除房型${ri + 1}吗?`, '系统提示', { type: 'warning', confirmButtonText: '确认删除' })
  508. .then(() => {
  509. form.value.rooms.splice(ri, 1);
  510. })
  511. .catch(() => {});
  512. }
  513. function addPeriod(room) {
  514. room.periods.push(createPeriod());
  515. }
  516. function removePeriod(room, pi) {
  517. room.periods.splice(pi, 1);
  518. }
  519. function setPeriodRange(period, value) {
  520. period.startDate = value && value.length === 2 ? value[0] : null;
  521. period.endDate = value && value.length === 2 ? value[1] : null;
  522. }
  523. // ========== 图片上传(仅上传OSS,不存图库) ==========
  524. async function uploadFileToUrl(rawFile) {
  525. if (!rawFile.type.startsWith('image/')) {
  526. ElMessage.error('只能上传图片文件');
  527. return null;
  528. }
  529. if (rawFile.size > 5 * 1024 * 1024) {
  530. ElMessage.error('图片大小不能超过5MB');
  531. return null;
  532. }
  533. const formData = new FormData();
  534. formData.append('file', rawFile);
  535. const res = await uploadImageOnly(formData);
  536. if (res.code === 200 && res.data && res.data.imageUrl) {
  537. return res.data.imageUrl;
  538. }
  539. ElMessage.error(res.msg || '上传失败');
  540. return null;
  541. }
  542. async function handlePromoFileChange(file) {
  543. if (form.value.promoImages.length >= MAX_IMAGES) {
  544. ElMessage.warning(`宣传图片最多上传${MAX_IMAGES}张`);
  545. return;
  546. }
  547. try {
  548. const url = await uploadFileToUrl(file.raw);
  549. if (url) {
  550. form.value.promoImages.push(url);
  551. ElMessage.success('图片上传成功');
  552. }
  553. } catch (err) {
  554. console.error('上传失败:', err);
  555. }
  556. }
  557. async function handleRoomFileChange(file, room) {
  558. if (room.roomImages.length >= MAX_IMAGES) {
  559. ElMessage.warning(`每个房型的房间图片最多上传${MAX_IMAGES}张`);
  560. return;
  561. }
  562. try {
  563. const url = await uploadFileToUrl(file.raw);
  564. if (url) {
  565. room.roomImages.push(url);
  566. ElMessage.success('图片上传成功');
  567. }
  568. } catch (err) {
  569. console.error('上传失败:', err);
  570. }
  571. }
  572. function moveImageUp(arr, index) {
  573. [arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
  574. }
  575. function moveImageDown(arr, index) {
  576. [arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
  577. }
  578. getList();
  579. </script>
  580. <style scoped>
  581. .cell-images {
  582. display: flex;
  583. align-items: center;
  584. justify-content: center;
  585. gap: 4px;
  586. flex-wrap: wrap;
  587. }
  588. .cell-images-more {
  589. font-size: 11px;
  590. color: #909399;
  591. }
  592. .cell-empty {
  593. color: #c0c4cc;
  594. font-size: 12px;
  595. }
  596. .cell-room-block {
  597. text-align: left;
  598. padding: 4px 8px;
  599. font-size: 13px;
  600. line-height: 1.8;
  601. }
  602. /* 操作按钮一上一下竖排:两个按钮同宽且水平居中,图标与文字对齐在同一条中线上 */
  603. .action-buttons {
  604. display: flex;
  605. flex-direction: column;
  606. align-items: center;
  607. gap: 6px;
  608. }
  609. .action-buttons .el-button {
  610. width: 64px;
  611. margin-left: 0;
  612. }
  613. /* 房型之间的分割线:单元格内每个房型块之间加虚线分隔,最后一个不加 */
  614. .cell-room-item:not(:last-child) {
  615. border-bottom: 1px dashed #dcdfe6;
  616. padding-bottom: 6px;
  617. margin-bottom: 6px;
  618. }
  619. .cell-room-title {
  620. font-weight: bold;
  621. color: #303133;
  622. }
  623. .cell-room-line {
  624. color: #606266;
  625. padding-left: 8px;
  626. }
  627. .cell-room-images-row {
  628. display: flex;
  629. align-items: center;
  630. gap: 4px;
  631. margin-bottom: 4px;
  632. }
  633. .cell-room-img-index {
  634. font-size: 13px;
  635. color: #606266;
  636. width: 14px;
  637. }
  638. .form-inline-tip {
  639. margin-left: 10px;
  640. font-size: 12px;
  641. color: #909399;
  642. }
  643. .image-upload-container {
  644. border: 1px solid #e4e7ed;
  645. border-radius: 6px;
  646. padding: 12px;
  647. width: 100%;
  648. }
  649. .upload-buttons-row {
  650. display: flex;
  651. align-items: center;
  652. margin-bottom: 12px;
  653. }
  654. .no-image-tip {
  655. text-align: center;
  656. padding: 20px;
  657. color: #909399;
  658. font-size: 13px;
  659. border: 1px dashed #dcdfe6;
  660. border-radius: 4px;
  661. }
  662. .form-tip {
  663. font-size: 12px;
  664. color: #909399;
  665. margin-bottom: 8px;
  666. }
  667. .content-images-grid {
  668. display: grid;
  669. grid-template-columns: repeat(4, 1fr);
  670. gap: 12px;
  671. margin-top: 8px;
  672. }
  673. .content-image-item {
  674. position: relative;
  675. border: 1px solid #e4e7ed;
  676. border-radius: 6px;
  677. overflow: hidden;
  678. }
  679. .content-image-actions {
  680. display: flex;
  681. justify-content: center;
  682. gap: 4px;
  683. padding: 6px;
  684. background: #fafafa;
  685. }
  686. .content-image-index {
  687. position: absolute;
  688. top: 4px;
  689. left: 4px;
  690. background: rgba(0, 0, 0, 0.6);
  691. color: #fff;
  692. font-size: 11px;
  693. width: 20px;
  694. height: 20px;
  695. border-radius: 50%;
  696. display: flex;
  697. align-items: center;
  698. justify-content: center;
  699. }
  700. .room-block {
  701. border: 1px solid #dcdfe6;
  702. border-radius: 6px;
  703. padding: 16px 12px 16px;
  704. margin-bottom: 16px;
  705. background: #fafafa;
  706. }
  707. .room-block-header {
  708. display: flex;
  709. align-items: center;
  710. justify-content: space-between;
  711. margin-bottom: 16px;
  712. }
  713. .room-block-title {
  714. font-size: 14px;
  715. font-weight: bold;
  716. color: #303133;
  717. }
  718. .period-row {
  719. display: flex;
  720. align-items: center;
  721. gap: 8px;
  722. }
  723. .period-count-label {
  724. font-size: 13px;
  725. color: #606266;
  726. }
  727. /* 增加时段按钮与上方时段行、卡片底边拉开距离 */
  728. .add-period-item {
  729. margin-top: 12px;
  730. margin-bottom: 0;
  731. }
  732. .add-room-row {
  733. display: flex;
  734. justify-content: center;
  735. margin-bottom: 16px;
  736. }
  737. </style>