Преглед изворни кода

酒店管理。菜品管理、房间管理部分代码

徐滕 пре 3 недеља
родитељ
комит
b8a1217cfd

+ 2 - 1
package.json

@@ -36,7 +36,8 @@
     "jquery": "^3.6.0",
     "js-base64": "^3.7.5",
     "js-cookie": "3.0.0",
-	"keycloak-js": "26.2.4",
+    "jszip": "^3.10.1",
+    "keycloak-js": "26.2.4",
     "lodash": "^4.17.21",
     "lodash.omit": "^4.5.0",
     "lodash.pick": "^4.4.0",

+ 2 - 0
src/api/AppPath.js

@@ -19,3 +19,5 @@ export const CCPM_PATH = "/ccpm-server";
 export const CONSULTANCY_PATH = "/consultancy-server";
 //进销存系统
 export const PSI_MANAGEMANT = "/psi-management-server";
+//酒店客房送餐系统
+export const HOTEL_ORDER_PATH = "/hotel";

+ 136 - 0
src/api/hotel/HotelDishService.js

@@ -0,0 +1,136 @@
+import httpRequest from "@/utils/httpRequest";
+import { HOTEL_ORDER_PATH as prefix } from "../AppPath";
+
+export default class HotelDishService {
+    // ============ 菜品管理 ============
+
+    /** 管理后台-菜品分页查询 */
+    page(params) {
+        return httpRequest({
+            url: prefix + "/dish/page",
+            method: "get",
+            params,
+        });
+    }
+
+    /** 客户端-菜品分页查询 */
+    clientPage(params) {
+        return httpRequest({
+            url: prefix + "/dish/clientPage",
+            method: "get",
+            params,
+        });
+    }
+
+    /** 查询菜品详情 */
+    queryById(id) {
+        return httpRequest({
+            url: prefix + "/dish/detail/" + id,
+            method: "get",
+        });
+    }
+
+    /** 新增/编辑菜品 */
+    save(data) {
+        return httpRequest({
+            url: prefix + "/dish/save",
+            method: "post",
+            data,
+        });
+    }
+
+    /** 删除菜品 */
+    delete(id) {
+        return httpRequest({
+            url: prefix + "/dish/delete/" + id,
+            method: "post",
+        });
+    }
+
+    /** 更新菜品状态 */
+    updateStatus(id, status) {
+        return httpRequest({
+            url: prefix + "/dish/updateStatus",
+            method: "post",
+            data: { id: id, status: status },
+        });
+    }
+
+    /** 标记售罄 */
+    markSoldOut(id) {
+        return httpRequest({
+            url: prefix + "/dish/soldOut/" + id,
+            method: "post",
+        });
+    }
+
+    /** 恢复上架 */
+    restore(id) {
+        return httpRequest({
+            url: prefix + "/dish/restore/" + id,
+            method: "post",
+        });
+    }
+
+    /** 批量操作 */
+    batch(data) {
+        return httpRequest({
+            url: prefix + "/dish/batch",
+            method: "post",
+            data,
+        });
+    }
+
+    /** 销量排行 */
+    ranking(limit) {
+        return httpRequest({
+            url: prefix + "/dish/ranking",
+            method: "get",
+            params: { limit: limit || 10 },
+        });
+    }
+
+    // ============ 菜品分类 ============
+
+    /** 分页查询分类 */
+    categoryPage(params) {
+        return httpRequest({
+            url: prefix + "/dishCategory/page",
+            method: "get",
+            params,
+        });
+    }
+
+    /** 查询所有启用的分类 */
+    categoryAll() {
+        return httpRequest({
+            url: prefix + "/dishCategory/all",
+            method: "get",
+        });
+    }
+
+    /** 新增/编辑分类 */
+    categorySave(data) {
+        return httpRequest({
+            url: prefix + "/dishCategory/save",
+            method: "post",
+            data,
+        });
+    }
+
+    /** 切换分类状态 */
+    categoryToggleStatus(id) {
+        return httpRequest({
+            url: prefix + "/dishCategory/toggleStatus/" + id,
+            method: "post",
+        });
+    }
+
+    /** 删除分类 */
+    categoryDelete(id) {
+        return httpRequest({
+            url: prefix + "/dishCategory/delete/" + id,
+            method: "post",
+        });
+    }
+}

+ 76 - 0
src/api/hotel/HotelRoomService.js

@@ -0,0 +1,76 @@
+import httpRequest from "@/utils/httpRequest";
+import { HOTEL_ORDER_PATH as prefix } from "../AppPath";
+
+export default class HotelRoomService {
+	/** 分页查询房间列表 */
+	page(params) {
+		return httpRequest({
+			url: prefix + "/room/list",
+			method: "get",
+			params,
+		});
+	}
+
+	/** 查询房间详情 */
+	queryById(id) {
+		return httpRequest({
+			url: prefix + "/room/queryById",
+			method: "get",
+			params: { id },
+		});
+	}
+
+	/** 新增/编辑房间 */
+	save(data) {
+		return httpRequest({
+			url: prefix + "/room/save",
+			method: "post",
+			data,
+		});
+	}
+
+	/** 切换房间状态 */
+	toggleStatus(id, status) {
+		return httpRequest({
+			url: prefix + "/room/toggleStatus",
+			method: "post",
+			params: { id, status },
+		});
+	}
+
+	/** 补打二维码(内容不变) */
+	reprintQr(id) {
+		return httpRequest({
+			url: prefix + "/room/reprintQr",
+			method: "get",
+			params: { id },
+		});
+	}
+
+	/** 重新生成二维码(内容改变) */
+	regenerateQr(id) {
+		return httpRequest({
+			url: prefix + "/room/regenerateQr",
+			method: "post",
+			params: { id },
+		});
+	}
+
+	/** 删除房间 */
+	delete(id) {
+		return httpRequest({
+			url: prefix + "/room/delete",
+			method: "post",
+			params: { id },
+		});
+	}
+
+	/** 批量删除房间 */
+	batchDelete(ids) {
+		return httpRequest({
+			url: prefix + "/room/batchDelete",
+			method: "post",
+			params: { ids },
+		});
+	}
+}

+ 57 - 0
src/api/hotel/HotelRoomTypeService.js

@@ -0,0 +1,57 @@
+import httpRequest from "@/utils/httpRequest";
+import { HOTEL_ORDER_PATH as prefix } from "../AppPath";
+
+export default class HotelRoomTypeService {
+	/** 分页查询房间类型列表 */
+	page(params) {
+		return httpRequest({
+			url: prefix + "/roomType/list",
+			method: "get",
+			params,
+		});
+	}
+
+	/** 查询所有启用的房间类型(下拉框用) */
+	all() {
+		return httpRequest({
+			url: prefix + "/roomType/all",
+			method: "get",
+		});
+	}
+
+	/** 查询房间类型详情 */
+	queryById(id) {
+		return httpRequest({
+			url: prefix + "/roomType/queryById",
+			method: "get",
+			params: { id },
+		});
+	}
+
+	/** 新增/编辑房间类型 */
+	save(data) {
+		return httpRequest({
+			url: prefix + "/roomType/save",
+			method: "post",
+			data,
+		});
+	}
+
+	/** 切换状态 */
+	toggleStatus(id) {
+		return httpRequest({
+			url: prefix + "/roomType/toggleStatus",
+			method: "post",
+			params: { id },
+		});
+	}
+
+	/** 删除房间类型 */
+	delete(id) {
+		return httpRequest({
+			url: prefix + "/roomType/delete",
+			method: "post",
+			params: { id },
+		});
+	}
+}

+ 165 - 0
src/store/modules/hotel.js

@@ -0,0 +1,165 @@
+/**
+ * 酒店客房送餐系统 - Vuex Store Module
+ * 管理主题、订单状态等全局状态
+ */
+
+const state = {
+  // 主题配置:blue(商务深蓝) / gold(暖金雅致) / green(墨绿清新)
+  theme: localStorage.getItem('hotel_theme') || 'blue',
+  
+  // WebSocket连接状态
+  wsConnected: false,
+  wsToken: null,
+  
+  // 当前用户信息(餐厅/厨房/前台人员)
+  currentUser: null,
+  
+  // 未读新订单通知
+  unreadOrders: [],
+  
+  // 语音播报开关
+  voiceEnabled: true
+}
+
+const mutations = {
+  // 设置主题
+  SET_THEME(state, theme) {
+    state.theme = theme
+    localStorage.setItem('hotel_theme', theme)
+    // 更新HTML data-theme属性
+    document.documentElement.setAttribute('data-theme', theme)
+  },
+  
+  // 设置WebSocket连接状态
+  SET_WS_CONNECTED(state, connected) {
+    state.wsConnected = connected
+  },
+  
+  // 设置WebSocket Token
+  SET_WS_TOKEN(state, token) {
+    state.wsToken = token
+  },
+  
+  // 设置当前用户
+  SET_CURRENT_USER(state, user) {
+    state.currentUser = user
+  },
+  
+  // 添加未读订单
+  ADD_UNREAD_ORDER(state, order) {
+    state.unreadOrders.unshift(order)
+  },
+  
+  // 移除未读订单
+  REMOVE_UNREAD_ORDER(state, orderId) {
+    const index = state.unreadOrders.findIndex(o => o.id === orderId)
+    if (index > -1) {
+      state.unreadOrders.splice(index, 1)
+    }
+  },
+  
+  // 清空未读订单
+  CLEAR_UNREAD_ORDERS(state) {
+    state.unreadOrders = []
+  },
+  
+  // 切换语音播报
+  TOGGLE_VOICE(state) {
+    state.voiceEnabled = !state.voiceEnabled
+  }
+}
+
+const actions = {
+  // 初始化主题
+  initTheme({ commit }) {
+    const savedTheme = localStorage.getItem('hotel_theme') || 'blue'
+    commit('SET_THEME', savedTheme)
+  },
+  
+  // 连接WebSocket
+  connectWebSocket({ commit, state }) {
+    if (!state.wsToken) {
+      console.warn('WebSocket Token未获取,无法连接')
+      return
+    }
+    
+    const wsUrl = `ws://127.0.0.1:9529/ws/hotel?token=${state.wsToken}`
+    const ws = new WebSocket(wsUrl)
+    
+    ws.onopen = () => {
+      commit('SET_WS_CONNECTED', true)
+      console.log('WebSocket连接成功')
+    }
+    
+    ws.onmessage = (event) => {
+      const message = JSON.parse(event.data)
+      handleWebSocketMessage(message, commit)
+    }
+    
+    ws.onerror = (error) => {
+      console.error('WebSocket错误:', error)
+      commit('SET_WS_CONNECTED', false)
+    }
+    
+    ws.onclose = () => {
+      commit('SET_WS_CONNECTED', false)
+      console.log('WebSocket连接关闭,尝试重连...')
+      // 3秒后重连
+      setTimeout(() => {
+        if (state.wsToken) {
+          dispatch('connectWebSocket')
+        }
+      }, 3000)
+    }
+    
+    // 将ws实例保存到window供全局访问
+    window.hotelWS = ws
+  },
+  
+  // 断开WebSocket
+  disconnectWebSocket({ commit }) {
+    if (window.hotelWS) {
+      window.hotelWS.close()
+      window.hotelWS = null
+    }
+    commit('SET_WS_CONNECTED', false)
+  }
+}
+
+// 处理WebSocket消息
+function handleWebSocketMessage(message, commit) {
+  switch (message.type) {
+    case 'NEW_ORDER':
+      // 新订单通知
+      commit('ADD_UNREAD_ORDER', message.data)
+      playVoiceNotification(message.data)
+      break
+    case 'ORDER_STATUS_CHANGED':
+      // 订单状态变更
+      console.log('订单状态变更:', message.data)
+      break
+    default:
+      console.log('未知消息类型:', message.type)
+  }
+}
+
+// 语音播报新订单
+function playVoiceNotification(order) {
+  if (!('speechSynthesis' in window)) {
+    console.warn('浏览器不支持Web Speech API')
+    return
+  }
+  
+  const text = `新订单!房号${order.roomNo},金额${order.totalAmount}元`
+  const utterance = new SpeechSynthesisUtterance(text)
+  utterance.lang = 'zh-CN'
+  utterance.rate = 1.0
+  speechSynthesis.speak(utterance)
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 64 - 0
src/utils/versionCheck.js

@@ -0,0 +1,64 @@
+import { ElNotification } from 'element-plus';
+import tool from '@/utils/tool';
+
+const VERSION_KEY = 'APP_VERSION';
+const VERSION_URL = '/version.json';
+const POLL_INTERVAL = 5 * 60 * 1000; // 5分钟
+
+let pollingTimer = null;
+
+async function getServerVersion() {
+	try {
+		const res = await fetch(VERSION_URL, { cache: 'no-cache' });
+		if (!res.ok) return null;
+		const data = await res.json();
+		return data.version;
+	} catch (err) {
+		console.warn('[VersionCheck] Failed to fetch version:', err);
+		return null;
+	}
+}
+
+export async function checkVersion() {
+	const serverVersion = await getServerVersion();
+	if (!serverVersion) return false;
+
+	const localVersion = tool.data.get(VERSION_KEY);
+
+	if (localVersion && localVersion !== serverVersion) {
+		console.log('[VersionCheck] New version detected:', serverVersion);
+		tool.data.set(VERSION_KEY, serverVersion);
+		return true;
+	}
+
+	if (!localVersion) {
+		tool.data.set(VERSION_KEY, serverVersion);
+	}
+
+	return false;
+}
+
+export function startPolling(interval = POLL_INTERVAL) {
+	stopPolling();
+	pollingTimer = setInterval(async () => {
+		const hasUpdate = await checkVersion();
+		if (hasUpdate) {
+			stopPolling();
+			ElNotification.info({
+				title: '系统更新',
+				message: '检测到新版本,正在刷新...',
+				duration: 2000,
+			});
+			setTimeout(() => {
+				window.location.reload();
+			}, 2000);
+		}
+	}, interval);
+}
+
+export function stopPolling() {
+	if (pollingTimer) {
+		clearInterval(pollingTimer);
+		pollingTimer = null;
+	}
+}

+ 158 - 0
src/utils/websocket.js

@@ -0,0 +1,158 @@
+/**
+ * WebSocket工具类 - 酒店客房送餐系统
+ * 用于实时接收新订单通知和状态变更
+ */
+
+class HotelWebSocket {
+  constructor() {
+    this.ws = null
+    this.reconnectTimer = null
+    this.heartbeatTimer = null
+    this.isConnected = false
+    this.token = null
+  }
+  
+  /**
+   * 连接WebSocket
+   * @param {string} token - WebSocket认证Token
+   * @param {Function} onMessage - 消息回调
+   * @param {Function} onError - 错误回调
+   */
+  connect(token, onMessage, onError) {
+    if (this.isConnected) {
+      console.warn('WebSocket已连接')
+      return
+    }
+    
+    this.token = token
+    const wsUrl = `ws://127.0.0.1:9529/ws/hotel?token=${token}`
+    
+    try {
+      this.ws = new WebSocket(wsUrl)
+      
+      this.ws.onopen = () => {
+        this.isConnected = true
+        console.log('✅ WebSocket连接成功')
+        this.startHeartbeat()
+        if (onMessage) {
+          this.ws.addEventListener('message', onMessage)
+        }
+      }
+      
+      this.ws.onmessage = (event) => {
+        const message = JSON.parse(event.data)
+        console.log('📨 收到WebSocket消息:', message)
+        
+        // 根据消息类型处理
+        if (message.type === 'NEW_ORDER') {
+          this.handleNewOrder(message.data)
+        } else if (message.type === 'ORDER_STATUS_CHANGED') {
+          this.handleStatusChange(message.data)
+        }
+      }
+      
+      this.ws.onerror = (error) => {
+        console.error('❌ WebSocket错误:', error)
+        this.isConnected = false
+        if (onError) {
+          onError(error)
+        }
+      }
+      
+      this.ws.onclose = () => {
+        console.log('🔌 WebSocket连接关闭')
+        this.isConnected = false
+        this.stopHeartbeat()
+        this.attemptReconnect(onMessage, onError)
+      }
+    } catch (error) {
+      console.error('WebSocket连接失败:', error)
+      this.attemptReconnect(onMessage, onError)
+    }
+  }
+  
+  /**
+   * 断开连接
+   */
+  disconnect() {
+    if (this.ws) {
+      this.ws.close()
+      this.ws = null
+    }
+    this.isConnected = false
+    this.stopHeartbeat()
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer)
+      this.reconnectTimer = null
+    }
+  }
+  
+  /**
+   * 发送消息
+   */
+  send(data) {
+    if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
+      this.ws.send(JSON.stringify(data))
+    } else {
+      console.warn('WebSocket未连接,无法发送消息')
+    }
+  }
+  
+  /**
+   * 尝试重连
+   */
+  attemptReconnect(onMessage, onError) {
+    if (this.reconnectTimer) {
+      return
+    }
+    
+    console.log('⏳ 3秒后尝试重连...')
+    this.reconnectTimer = setTimeout(() => {
+      this.reconnectTimer = null
+      if (this.token) {
+        this.connect(this.token, onMessage, onError)
+      }
+    }, 3000)
+  }
+  
+  /**
+   * 开始心跳
+   */
+  startHeartbeat() {
+    this.heartbeatTimer = setInterval(() => {
+      if (this.isConnected) {
+        this.send({ type: 'PING' })
+      }
+    }, 30000) // 每30秒发送一次心跳
+  }
+  
+  /**
+   * 停止心跳
+   */
+  stopHeartbeat() {
+    if (this.heartbeatTimer) {
+      clearInterval(this.heartbeatTimer)
+      this.heartbeatTimer = null
+    }
+  }
+  
+  /**
+   * 处理新订单
+   */
+  handleNewOrder(order) {
+    console.log('🆕 新订单:', order)
+    // 触发自定义事件,供Vue组件监听
+    window.dispatchEvent(new CustomEvent('hotel-new-order', { detail: order }))
+  }
+  
+  /**
+   * 处理订单状态变更
+   */
+  handleStatusChange(data) {
+    console.log('📊 订单状态变更:', data)
+    window.dispatchEvent(new CustomEvent('hotel-order-status-changed', { detail: data }))
+  }
+}
+
+// 导出单例
+export default new HotelWebSocket()

+ 151 - 0
src/views/hotel/admin/QrCodeDialog.vue

@@ -0,0 +1,151 @@
+<template>
+	<div>
+		<el-dialog title="房间二维码" :close-on-click-modal="false" draggable width="480px" @close="close"
+			v-model="visible">
+			<div v-loading="loading" class="qr-dialog-content">
+				<div class="qr-info">
+					<span class="qr-room-label">房间号</span>
+					<span class="qr-room-no">{{ roomNo }}</span>
+				</div>
+				<div class="qr-image-wrapper">
+					<img v-if="qrCodeImage" :src="'data:image/png;base64,' + qrCodeImage" alt="二维码" />
+					<div v-else class="qr-placeholder">
+						<el-icon style="font-size: 48px; color: #c0c4cc;"><Picture /></el-icon>
+						<p>暂无二维码</p>
+					</div>
+				</div>
+				<div class="qr-url-box" v-if="qrCodeUrl" :title="qrCodeUrl">
+					<el-icon><Link /></el-icon>
+					<span>{{ qrCodeUrl }}</span>
+				</div>
+				<div class="qr-actions">
+					<el-button type="primary" icon="el-icon-download" @click="downloadQr">下载二维码</el-button>
+				</div>
+			</div>
+			<template #footer>
+				<span class="dialog-footer">
+					<el-button @click="close()" icon="el-icon-circle-close">关闭</el-button>
+				</span>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			visible: false,
+			loading: false,
+			roomNo: '',
+			qrCodeUrl: '',
+			qrCodeImage: ''
+		}
+	},
+	methods: {
+		init(roomNo, qrCodeUrl, qrCodeImage) {
+			this.roomNo = roomNo
+			this.qrCodeUrl = qrCodeUrl || ''
+			this.qrCodeImage = qrCodeImage || ''
+			this.visible = true
+			this.loading = false
+		},
+		downloadQr() {
+			if (!this.qrCodeImage) return
+			const link = document.createElement('a')
+			const byteCharacters = atob(this.qrCodeImage)
+			const byteNumbers = new Array(byteCharacters.length)
+			for (let i = 0; i < byteCharacters.length; i++) {
+				byteNumbers[i] = byteCharacters.charCodeAt(i)
+			}
+			const byteArray = new Uint8Array(byteNumbers)
+			const blob = new Blob([byteArray], { type: 'image/png' })
+			link.href = URL.createObjectURL(blob)
+			link.download = `二维码_房间${this.roomNo}.png`
+			link.click()
+			URL.revokeObjectURL(link.href)
+		},
+		close() {
+			this.visible = false
+			this.qrCodeImage = ''
+			this.qrCodeUrl = ''
+		}
+	}
+}
+</script>
+
+<style scoped>
+.qr-dialog-content {
+	text-align: center;
+}
+.qr-info {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	gap: 12px;
+	margin-bottom: 16px;
+}
+.qr-room-label {
+	font-size: 14px;
+	color: #909399;
+}
+.qr-room-no {
+	font-size: 22px;
+	font-weight: 700;
+	color: #303133;
+	letter-spacing: 2px;
+}
+.qr-image-wrapper {
+	display: flex;
+	justify-content: center;
+	margin-bottom: 12px;
+}
+.qr-image-wrapper img {
+	width: 260px;
+	height: 260px;
+	border: 1px solid #ebeef5;
+	border-radius: 8px;
+	box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+}
+.qr-placeholder {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	width: 260px;
+	height: 260px;
+	border: 1px dashed #dcdfe6;
+	border-radius: 8px;
+	color: #c0c4cc;
+}
+.qr-placeholder p {
+	margin-top: 8px;
+	font-size: 14px;
+}
+.qr-url-box {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	padding: 8px 12px;
+	background: #f5f7fa;
+	border-radius: 4px;
+	font-size: 12px;
+	color: #909399;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	margin-bottom: 16px;
+}
+.qr-url-box .el-icon {
+	flex-shrink: 0;
+}
+.qr-url-box span {
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.qr-actions {
+	display: flex;
+	justify-content: center;
+}
+</style>

+ 146 - 0
src/views/hotel/admin/RoomDetail.vue

@@ -0,0 +1,146 @@
+<template>
+	<div>
+		<el-dialog title="房间详情" :close-on-click-modal="false" draggable width="560px" @close="close"
+			v-model="visible">
+			<div v-loading="loading">
+				<el-descriptions :column="2" border>
+					<el-descriptions-item label="房间号">{{ detail.roomNo }}</el-descriptions-item>
+					<el-descriptions-item label="房间类型">{{ detail.roomTypeName || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="楼层">{{ detail.floor || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="状态">
+						<el-tag :type="statusTagType(detail.status)" effect="plain">
+							{{ statusLabel(detail.status) }}
+						</el-tag>
+					</el-descriptions-item>
+					<el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="更新时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
+				</el-descriptions>
+				<div class="qr-section" v-if="detail.qrCodeImage">
+					<div class="qr-section-title">点餐二维码</div>
+					<div class="qr-detail-image">
+						<img :src="'data:image/png;base64,' + detail.qrCodeImage" alt="二维码" />
+					</div>
+					<div class="qr-url-box" v-if="detail.qrCodeUrl">
+						<span class="qr-url-label">二维码链接:</span>
+						<el-input :model-value="detail.qrCodeUrl" readonly size="small">
+							<template #append>
+								<el-button icon="el-icon-copy-document" @click="copyUrl(detail.qrCodeUrl)"></el-button>
+							</template>
+						</el-input>
+					</div>
+					<div class="qr-actions">
+						<el-button type="primary" size="small" icon="el-icon-download" @click="downloadQr">下载二维码</el-button>
+					</div>
+				</div>
+				<div v-else class="qr-empty-section">
+					<el-empty description="暂无二维码" :image-size="60"></el-empty>
+				</div>
+			</div>
+			<template #footer>
+				<el-button @click="close()" icon="el-icon-circle-close">关闭</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+import HotelRoomService from '@/api/hotel/HotelRoomService'
+export default {
+	data() {
+		return {
+			visible: false,
+			loading: false,
+			detail: {}
+		}
+	},
+	hotelRoomService: null,
+	created() {
+		this.hotelRoomService = new HotelRoomService()
+	},
+	methods: {
+		init(id) {
+			this.visible = true
+			this.loading = true
+			this.detail = {}
+			this.hotelRoomService.queryById(id).then((data) => {
+				this.detail = data
+				this.loading = false
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		statusLabel(status) {
+			const map = { '0': '正常', '1': '暂停', '2': '停用' }
+			return map[status] || '未知'
+		},
+		statusTagType(status) {
+			const map = { '0': 'success', '1': 'warning', '2': 'info' }
+			return map[status] || 'info'
+		},
+		copyUrl(url) {
+			navigator.clipboard.writeText(url).then(() => {
+				this.$message.success('已复制到剪贴板')
+			}).catch(() => {
+				this.$message.error('复制失败')
+			})
+		},
+		downloadQr() {
+			const link = document.createElement('a')
+			const byteCharacters = atob(this.detail.qrCodeImage)
+			const byteNumbers = new Array(byteCharacters.length)
+			for (let i = 0; i < byteCharacters.length; i++) {
+				byteNumbers[i] = byteCharacters.charCodeAt(i)
+			}
+			const byteArray = new Uint8Array(byteNumbers)
+			const blob = new Blob([byteArray], { type: 'image/png' })
+			link.href = URL.createObjectURL(blob)
+			link.download = `二维码_房间${this.detail.roomNo}.png`
+			link.click()
+			URL.revokeObjectURL(link.href)
+		},
+		close() {
+			this.visible = false
+		}
+	}
+}
+</script>
+
+<style scoped>
+.qr-section {
+	margin-top: 20px;
+	padding: 16px;
+	background: #f5f7fa;
+	border-radius: 6px;
+}
+.qr-section-title {
+	font-size: 14px;
+	font-weight: 600;
+	color: #303133;
+	margin-bottom: 12px;
+}
+.qr-detail-image {
+	text-align: center;
+	margin-bottom: 12px;
+}
+.qr-detail-image img {
+	width: 160px;
+	height: 160px;
+	border: 1px solid #ebeef5;
+	border-radius: 8px;
+}
+.qr-url-box {
+	margin-bottom: 12px;
+}
+.qr-url-label {
+	font-size: 12px;
+	color: #909399;
+	margin-bottom: 4px;
+	display: block;
+}
+.qr-actions {
+	text-align: center;
+}
+.qr-empty-section {
+	margin-top: 20px;
+}
+</style>

+ 139 - 0
src/views/hotel/admin/RoomForm.vue

@@ -0,0 +1,139 @@
+<template>
+	<div>
+		<el-dialog :title="title" :close-on-click-modal="false" draggable width="560px" @close="close"
+			@keyup.enter.native="doSubmit" v-model="visible">
+			<el-form :model="inputForm" ref="inputForm" v-loading="loading" :rules="rules" label-width="100px"
+				@submit.native.prevent>
+				<el-row :gutter="15">
+					<el-col :span="22">
+						<el-form-item label="房间号" prop="roomNo">
+							<el-input v-model="inputForm.roomNo" placeholder="请输入房间号" clearable></el-input>
+						</el-form-item>
+					</el-col>
+					<el-col :span="22">
+						<el-form-item label="房间类型" prop="roomTypeId">
+							<el-select v-model="inputForm.roomTypeId" placeholder="请选择房间类型" clearable style="width: 140px">
+								<el-option v-for="item in roomTypeList" :key="item.id" :label="item.name" :value="item.id"></el-option>
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="22">
+						<el-form-item label="楼层" prop="floor">
+							<el-input-number v-model="inputForm.floor" :min="1" :max="99" style="width: 100%"></el-input-number>
+						</el-form-item>
+					</el-col>
+					<el-col :span="22">
+						<el-form-item label="状态" prop="status">
+							<el-radio-group v-model="inputForm.status">
+								<el-radio label="0">正常</el-radio>
+								<el-radio label="1">暂停</el-radio>
+								<el-radio label="2">停用</el-radio>
+							</el-radio-group>
+						</el-form-item>
+					</el-col>
+				</el-row>
+			</el-form>
+			<template #footer>
+				<span class="dialog-footer">
+					<el-button @click="close()" icon="el-icon-circle-close">关闭</el-button>
+					<el-button type="primary" @click="doSubmit()" icon="el-icon-circle-check"
+						v-noMoreClick>确定</el-button>
+				</span>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+import HotelRoomService from '@/api/hotel/HotelRoomService'
+import HotelRoomTypeService from '@/api/hotel/HotelRoomTypeService'
+export default {
+	data() {
+		return {
+			title: '',
+			method: '',
+			visible: false,
+			loading: false,
+			rules: {
+				roomNo: [{ required: true, message: '房间号不能为空', trigger: 'blur' }]
+			},
+			roomTypeList: [],
+			inputForm: {
+				roomNo: '',
+				roomTypeId: '',
+				floor: 1,
+				status: '0'
+			}
+		}
+	},
+	hotelRoomService: null,
+	hotelRoomTypeService: null,
+	created() {
+		this.hotelRoomService = new HotelRoomService()
+		this.hotelRoomTypeService = new HotelRoomTypeService()
+		this.loadRoomTypes()
+	},
+	methods: {
+		init(method, id) {
+			this.method = method
+			this.inputForm = {
+				roomNo: '',
+				roomTypeId: '',
+				floor: 1,
+				status: '0'
+			}
+			if (method === 'add') {
+				this.title = '新增房间'
+			} else if (method === 'edit') {
+				this.title = '编辑房间'
+				this.inputForm.id = id
+			}
+			this.visible = true
+			this.loading = false
+			this.loadRoomTypes()
+			this.$nextTick(() => {
+				this.$refs.inputForm.resetFields()
+				if (method === 'edit') {
+					this.loading = true
+					this.hotelRoomService.queryById(id).then((data) => {
+						this.inputForm = {
+							id: data.id,
+							roomNo: data.roomNo,
+							roomTypeId: data.roomTypeId,
+							floor: data.floor,
+							status: data.status || '0'
+						}
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		doSubmit() {
+			this.$refs.inputForm.validate((valid) => {
+				if (valid) {
+					this.loading = true
+					this.hotelRoomService.save(this.inputForm).then(() => {
+						this.$message.success('操作成功')
+						this.close()
+						this.$emit('refreshList')
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		close() {
+			this.$refs.inputForm && this.$refs.inputForm.resetFields()
+			this.visible = false
+		},
+		loadRoomTypes() {
+			this.hotelRoomTypeService.all().then((data) => {
+				this.roomTypeList = Array.isArray(data) ? data : (data.list || [])
+			})
+		}
+	}
+}
+</script>

+ 310 - 0
src/views/hotel/admin/RoomManagement.vue

@@ -0,0 +1,310 @@
+<template>
+	<div class="page">
+		<el-form :inline="true" v-if="searchVisible" class="query-form m-b-10" ref="searchForm" :model="searchForm"
+			@keyup.enter.native="refreshList()" @submit.native.prevent>
+			<el-form-item label="房间号" prop="roomNo">
+				<el-input v-model="searchForm.roomNo" placeholder="请输入房间号" clearable></el-input>
+			</el-form-item>
+			<el-form-item label="房间类型" prop="roomTypeId">
+				<el-select v-model="searchForm.roomTypeId" placeholder="请选择类型" clearable style="width: 140px">
+					<el-option v-for="item in roomTypeList" :key="item.id" :label="item.name" :value="item.id"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item label="状态" prop="status">
+				<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 120px">
+					<el-option label="正常" value="0"></el-option>
+					<el-option label="暂停" value="1"></el-option>
+					<el-option label="停用" value="2"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="refreshList()" icon="el-icon-search">查询</el-button>
+				<el-button @click="resetSearch()" icon="el-icon-refresh-right">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="jp-table top">
+			<vxe-toolbar :refresh="{ query: refreshList }" custom>
+				<template #buttons>
+					<el-button v-if="hasPermission('hotel:admin:room:add')" type="primary" icon="el-icon-plus"
+						@click="add()">新增房间</el-button>
+					<el-button v-if="hasPermission('hotel:admin:room:list')" type="success" icon="el-icon-download"
+						:disabled="selectedRows.length === 0"
+						@click="batchDownloadQr()" plain>批量下载二维码</el-button>
+				</template>
+				<template #tools>
+					<vxe-button text type="primary" :title="searchVisible ? '收起检索' : '展开检索'" icon="vxe-icon-search"
+						class="tool-btn" @click="searchVisible = !searchVisible"></vxe-button>
+				</template>
+			</vxe-toolbar>
+			<div style="height: calc(100% - 90px)">
+				<vxe-table border="inner" auto-resize resizable height="auto" :loading="loading" ref="dataTable"
+					show-header-overflow show-overflow highlight-hover-row :menu-config="{}" :data="dataList"
+					:checkbox-config="{}"
+					@checkbox-change="handleCheckboxChange"
+					@checkbox-all="handleCheckboxChange">
+					<vxe-column type="seq" width="60" title="序号"></vxe-column>
+					<vxe-column type="checkbox" width="60"></vxe-column>
+					<vxe-column title="房间号" field="roomNo" min-width="120" align="center">
+						<template #default="scope">
+							<el-button text type="primary" @click="viewDetail(scope.row.id)">{{ scope.row.roomNo }}</el-button>
+						</template>
+					</vxe-column>
+					<vxe-column title="房间类型" field="roomTypeName" min-width="120" align="center"></vxe-column>
+					<vxe-column title="楼层" field="floor" min-width="80" align="center"></vxe-column>
+					<vxe-column title="状态" field="status" min-width="100" align="center">
+						<template #default="scope">
+							<el-tag :type="statusTagType(scope.row.status)" effect="plain">
+								{{ statusLabel(scope.row.status) }}
+							</el-tag>
+						</template>
+					</vxe-column>
+					<vxe-column title="二维码" field="qrCodeImage" min-width="100" align="center">
+						<template #default="scope">
+							<div v-if="scope.row.qrCodeImage" class="qr-thumb" @click="viewQr(scope.row)">
+								<img :src="'data:image/png;base64,' + scope.row.qrCodeImage" alt="二维码" />
+							</div>
+							<span v-else class="qr-empty">未生成</span>
+						</template>
+					</vxe-column>
+					<vxe-column title="生成时间" field="qrGeneratedTime" min-width="170" align="center"></vxe-column>
+					<vxe-column title="操作" width="280" fixed="right" align="center">
+						<template #default="scope">
+							<el-button v-if="hasPermission('hotel:admin:room:edit')" text type="primary" size="small"
+								@click="edit(scope.row.id)">编辑</el-button>
+							<el-button v-if="hasPermission('hotel:admin:room:edit')" text type="primary" size="small"
+								@click="doToggleStatus(scope.row)">{{ statusAction(scope.row.status) }}</el-button>
+							<el-button v-if="hasPermission('hotel:admin:room:list') && scope.row.qrCodeImage" text
+								type="primary" size="small" @click="viewQr(scope.row)">查看</el-button>
+							<el-button v-if="hasPermission('hotel:admin:room:edit') && scope.row.qrCodeImage" text
+								type="primary" size="small" @click="doRegenerateQr(scope.row)">重新生成</el-button>
+							<el-button v-if="hasPermission('hotel:admin:room:delete')" text type="danger" size="small"
+								@click="doDelete(scope.row.id)">删除</el-button>
+						</template>
+					</vxe-column>
+				</vxe-table>
+				<vxe-pager background :current-page="tablePage.currentPage" :page-size="tablePage.pageSize"
+					:total="tablePage.total" :page-sizes="[10, 20, 100, 1000, { label: '全量数据', value: 1000000 }]"
+					:layouts="['PrevPage', 'JumpNumber', 'NextPage', 'FullJump', 'Sizes', 'Total']"
+					@page-change="currentChangeHandle">
+				</vxe-pager>
+			</div>
+		</div>
+		<RoomForm ref="roomForm" @refreshList="refreshList"></RoomForm>
+		<QrCodeDialog ref="qrCodeDialog"></QrCodeDialog>
+		<RoomDetail ref="roomDetail"></RoomDetail>
+	</div>
+</template>
+
+<script>
+import HotelRoomService from '@/api/hotel/HotelRoomService'
+import HotelRoomTypeService from '@/api/hotel/HotelRoomTypeService'
+import JSZip from 'jszip'
+import RoomForm from './RoomForm'
+import QrCodeDialog from './QrCodeDialog'
+import RoomDetail from './RoomDetail'
+export default {
+	data() {
+		return {
+			searchVisible: true,
+			searchForm: {
+				roomNo: '',
+				roomTypeId: '',
+				status: ''
+			},
+			roomTypeList: [],
+			dataList: [],
+			selectedRows: [],
+			tablePage: {
+				total: 0,
+				currentPage: 1,
+				pageSize: 10,
+				orders: []
+			},
+			loading: false
+		}
+	},
+	hotelRoomService: null,
+	hotelRoomTypeService: null,
+	created() {
+		this.hotelRoomService = new HotelRoomService()
+		this.hotelRoomTypeService = new HotelRoomTypeService()
+		this.loadRoomTypes()
+	},
+	components: {
+		RoomForm,
+		QrCodeDialog,
+		RoomDetail
+	},
+	mounted() {
+		this.refreshList()
+	},
+	activated() {
+		this.refreshList()
+		this.loadRoomTypes()
+	},
+	methods: {
+		add() {
+			this.$refs.roomForm.init('add', '')
+		},
+		edit(id) {
+			this.$refs.roomForm.init('edit', id)
+		},
+		viewDetail(id) {
+			this.$refs.roomDetail.init(id)
+		},
+		refreshList() {
+			this.loading = true
+			this.hotelRoomService.page({
+				'current': this.tablePage.currentPage,
+				'size': this.tablePage.pageSize,
+				'orders': this.tablePage.orders,
+				...this.searchForm
+			}).then((data) => {
+				this.dataList = data.records || []
+				this.tablePage.total = data.total || 0
+				this.tablePage.currentPage = data.current || 1
+				this.loading = false
+				this.selectedRows = []
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		currentChangeHandle({ currentPage, pageSize }) {
+			this.tablePage.currentPage = currentPage
+			this.tablePage.pageSize = pageSize
+			this.refreshList()
+		},
+		resetSearch() {
+			this.$refs.searchForm.resetFields()
+			this.searchForm.roomTypeId = ''
+			this.tablePage.currentPage = 1
+			this.refreshList()
+		},
+		handleCheckboxChange() {
+			this.selectedRows = this.$refs.dataTable.getCheckboxRecords()
+		},
+		doToggleStatus(row) {
+			const statusMap = { '0': '1', '1': '0', '2': '0' }
+			const targetStatus = statusMap[row.status] || '0'
+			const labelMap = { '0': '暂停', '1': '恢复', '2': '恢复' }
+			this.$confirm(`确定${labelMap[row.status]}房间【${row.roomNo}】吗?`, '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelRoomService.toggleStatus(row.id, targetStatus).then(() => {
+					this.$message.success('操作成功')
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		},
+		viewQr(row) {
+			this.$refs.qrCodeDialog.init(row.roomNo, row.qrCodeUrl, row.qrCodeImage)
+		},
+		doRegenerateQr(row) {
+			this.$confirm(`确定重新生成房间【${row.roomNo}】的二维码吗?旧二维码将失效。`, '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelRoomService.regenerateQr(row.id).then((data) => {
+					this.$message.success('重新生成成功')
+					this.$refs.qrCodeDialog.init(row.roomNo, data.qrCodeUrl || row.qrCodeUrl, data.qrCodeImage)
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		},
+		doDelete(id) {
+			this.$confirm('确定删除该房间吗?', '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelRoomService.delete(id).then(() => {
+					this.$message.success('删除成功')
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		},
+		async batchDownloadQr() {
+			const rows = this.selectedRows.filter(r => r.qrCodeImage)
+			if (rows.length === 0) {
+				this.$message.warning('所选房间中没有已生成二维码的房间')
+				return
+			}
+			this.loading = true
+			try {
+				const zip = new JSZip()
+				const folder = zip.folder('房间二维码')
+				rows.forEach((row) => {
+					folder.file(`房间${row.roomNo}.png`, row.qrCodeImage, { base64: true })
+				})
+				const content = await zip.generateAsync({ type: 'blob' })
+				const link = document.createElement('a')
+				link.href = URL.createObjectURL(content)
+				link.download = `房间二维码_${new Date().toLocaleDateString()}.zip`
+				link.click()
+				URL.revokeObjectURL(link.href)
+				this.$message.success(`已打包下载 ${rows.length} 个二维码`)
+			} catch (e) {
+				this.$message.error('打包失败:' + e.message)
+			} finally {
+				this.loading = false
+			}
+		},
+		statusLabel(status) {
+			const map = { '0': '正常', '1': '暂停', '2': '停用' }
+			return map[status] || '未知'
+		},
+		statusTagType(status) {
+			const map = { '0': 'success', '1': 'warning', '2': 'info' }
+			return map[status] || 'info'
+		},
+		statusAction(status) {
+			const map = { '0': '暂停', '1': '恢复', '2': '恢复' }
+			return map[status] || '恢复'
+		},
+		loadRoomTypes() {
+			this.hotelRoomTypeService.all().then((data) => {
+				this.roomTypeList = Array.isArray(data) ? data : (data.list || [])
+			})
+		}
+	}
+}
+</script>
+
+<style scoped>
+.qr-thumb {
+	display: inline-block;
+	cursor: pointer;
+	border: 1px solid #ebeef5;
+	border-radius: 4px;
+	overflow: hidden;
+	transition: box-shadow 0.2s;
+}
+.qr-thumb:hover {
+	box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+.qr-thumb img {
+	display: block;
+	width: 48px;
+	height: 48px;
+}
+.qr-empty {
+	color: #c0c4cc;
+	font-size: 12px;
+}
+</style>

+ 100 - 0
src/views/hotel/admin/RoomTypeForm.vue

@@ -0,0 +1,100 @@
+<template>
+	<div>
+		<el-dialog :title="title" :close-on-click-modal="false" draggable width="460px" @close="close"
+			@keyup.enter.native="doSubmit" v-model="visible">
+			<el-form :model="inputForm" ref="inputForm" v-loading="loading" :rules="rules" label-width="80px"
+				@submit.native.prevent>
+				<el-form-item label="类型名称" prop="name">
+					<el-input v-model="inputForm.name" placeholder="请输入类型名称" clearable></el-input>
+				</el-form-item>
+				<el-form-item label="排序号" prop="sortOrder">
+					<el-input-number v-model="inputForm.sortOrder" :min="0" :max="999" style="width: 100%"></el-input-number>
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<span class="dialog-footer">
+					<el-button @click="close()" icon="el-icon-circle-close">关闭</el-button>
+					<el-button type="primary" @click="doSubmit()" icon="el-icon-circle-check"
+						v-noMoreClick>确定</el-button>
+				</span>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+import HotelRoomTypeService from '@/api/hotel/HotelRoomTypeService'
+export default {
+	data() {
+		return {
+			title: '',
+			method: '',
+			visible: false,
+			loading: false,
+			rules: {
+				name: [{ required: true, message: '类型名称不能为空', trigger: 'blur' }]
+			},
+			inputForm: {
+				name: '',
+				sortOrder: 0
+			}
+		}
+	},
+	hotelRoomTypeService: null,
+	created() {
+		this.hotelRoomTypeService = new HotelRoomTypeService()
+	},
+	methods: {
+		init(method, id) {
+			this.method = method
+			this.inputForm = {
+				name: '',
+				sortOrder: 0
+			}
+			if (method === 'add') {
+				this.title = '新增房间类型'
+			} else if (method === 'edit') {
+				this.title = '编辑房间类型'
+				this.inputForm.id = id
+			}
+			this.visible = true
+			this.loading = false
+			this.$nextTick(() => {
+				this.$refs.inputForm.resetFields()
+				if (method === 'edit') {
+					this.loading = true
+					this.hotelRoomTypeService.queryById(id).then((data) => {
+						this.inputForm = {
+							id: data.id,
+							name: data.name,
+							sortOrder: data.sortOrder || 0
+						}
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		doSubmit() {
+			this.$refs.inputForm.validate((valid) => {
+				if (valid) {
+					this.loading = true
+					this.hotelRoomTypeService.save(this.inputForm).then(() => {
+						this.$message.success('操作成功')
+						this.close()
+						this.$emit('refreshList')
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		close() {
+			this.$refs.inputForm && this.$refs.inputForm.resetFields()
+			this.visible = false
+		}
+	}
+}
+</script>

+ 166 - 0
src/views/hotel/admin/RoomTypeManagement.vue

@@ -0,0 +1,166 @@
+<template>
+	<div class="page">
+		<el-form :inline="true" v-if="searchVisible" class="query-form m-b-10" ref="searchForm" :model="searchForm"
+			@keyup.enter.native="refreshList()" @submit.native.prevent>
+			<el-form-item label="类型名称" prop="name">
+				<el-input v-model="searchForm.name" placeholder="请输入类型名称" clearable></el-input>
+			</el-form-item>
+			<el-form-item label="状态" prop="status">
+				<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 120px">
+					<el-option label="启用" value="ENABLED"></el-option>
+					<el-option label="禁用" value="DISABLED"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="refreshList()" icon="el-icon-search">查询</el-button>
+				<el-button @click="resetSearch()" icon="el-icon-refresh-right">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="jp-table top">
+			<vxe-toolbar :refresh="{ query: refreshList }" custom>
+				<template #buttons>
+					<el-button v-if="hasPermission('hotel:admin:roomType:add')" type="primary" icon="el-icon-plus"
+						@click="add()">新增类型</el-button>
+				</template>
+				<template #tools>
+					<vxe-button text type="primary" :title="searchVisible ? '收起检索' : '展开检索'" icon="vxe-icon-search"
+						class="tool-btn" @click="searchVisible = !searchVisible"></vxe-button>
+				</template>
+			</vxe-toolbar>
+			<div style="height: calc(100% - 90px)">
+				<vxe-table border="inner" auto-resize resizable height="auto" :loading="loading" ref="dataTable"
+					show-header-overflow show-overflow highlight-hover-row :menu-config="{}" :data="dataList"
+					:checkbox-config="{}">
+					<vxe-column type="seq" width="60" title="序号"></vxe-column>
+					<vxe-column title="类型名称" field="name" min-width="200" align="center"></vxe-column>
+					<vxe-column title="排序号" field="sortOrder" width="100" align="center"></vxe-column>
+					<vxe-column title="状态" field="status" width="100" align="center">
+						<template #default="scope">
+							<el-tag :type="scope.row.status === 'ENABLED' ? 'success' : 'info'" effect="plain">
+								{{ scope.row.status === 'ENABLED' ? '启用' : '禁用' }}
+							</el-tag>
+						</template>
+					</vxe-column>
+					<vxe-column title="创建时间" field="createTime" min-width="170" align="center"></vxe-column>
+					<vxe-column title="操作" width="200" fixed="right" align="center">
+						<template #default="scope">
+							<el-button v-if="hasPermission('hotel:admin:roomType:edit')" text type="primary" size="small"
+								@click="edit(scope.row.id)">编辑</el-button>
+							<el-button v-if="hasPermission('hotel:admin:roomType:edit')" text type="primary" size="small"
+								@click="doToggleStatus(scope.row)">{{ scope.row.status === 'ENABLED' ? '禁用' : '启用' }}</el-button>
+							<el-button v-if="hasPermission('hotel:admin:roomType:delete')" text type="danger" size="small"
+								@click="doDelete(scope.row.id)">删除</el-button>
+						</template>
+					</vxe-column>
+				</vxe-table>
+				<vxe-pager background :current-page="tablePage.currentPage" :page-size="tablePage.pageSize"
+					:total="tablePage.total" :page-sizes="[10, 20, 100, 1000, { label: '全量数据', value: 1000000 }]"
+					:layouts="['PrevPage', 'JumpNumber', 'NextPage', 'FullJump', 'Sizes', 'Total']"
+					@page-change="currentChangeHandle">
+				</vxe-pager>
+			</div>
+		</div>
+		<RoomTypeForm ref="roomTypeForm" @refreshList="refreshList"></RoomTypeForm>
+	</div>
+</template>
+
+<script>
+import HotelRoomTypeService from '@/api/hotel/HotelRoomTypeService'
+import RoomTypeForm from './RoomTypeForm'
+export default {
+	data() {
+		return {
+			searchVisible: true,
+			searchForm: {
+				name: '',
+				status: ''
+			},
+			dataList: [],
+			tablePage: {
+				total: 0,
+				currentPage: 1,
+				pageSize: 10,
+				orders: []
+			},
+			loading: false
+		}
+	},
+	hotelRoomTypeService: null,
+	created() {
+		this.hotelRoomTypeService = new HotelRoomTypeService()
+	},
+	components: {
+		RoomTypeForm
+	},
+	mounted() {
+		this.refreshList()
+	},
+	activated() {
+		this.refreshList()
+	},
+	methods: {
+		add() {
+			this.$refs.roomTypeForm.init('add', '')
+		},
+		edit(id) {
+			this.$refs.roomTypeForm.init('edit', id)
+		},
+		refreshList() {
+			this.loading = true
+			this.hotelRoomTypeService.page({
+				'current': this.tablePage.currentPage,
+				'size': this.tablePage.pageSize,
+				'orders': this.tablePage.orders,
+				...this.searchForm
+			}).then((data) => {
+				this.dataList = data.records || []
+				this.tablePage.total = data.total || 0
+				this.tablePage.currentPage = data.current || 1
+				this.loading = false
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		currentChangeHandle({ currentPage, pageSize }) {
+			this.tablePage.currentPage = currentPage
+			this.tablePage.pageSize = pageSize
+			this.refreshList()
+		},
+		resetSearch() {
+			this.$refs.searchForm.resetFields()
+			this.tablePage.currentPage = 1
+			this.refreshList()
+		},
+		doToggleStatus(row) {
+			const action = row.status === 'ENABLED' ? '禁用' : '启用'
+			this.$confirm(`确定${action}该房间类型吗?`, '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.hotelRoomTypeService.toggleStatus(row.id).then(() => {
+					this.$message.success('操作成功')
+					this.refreshList()
+				})
+			})
+		},
+		doDelete(id) {
+			this.$confirm('确定删除该房间类型吗?', '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelRoomTypeService.delete(id).then(() => {
+					this.$message.success('删除成功')
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		}
+	}
+}
+</script>

+ 111 - 0
src/views/hotel/dish/CategoryForm.vue

@@ -0,0 +1,111 @@
+<template>
+	<div>
+		<el-dialog :title="title" :close-on-click-modal="false" draggable width="560px" @close="close"
+			@keyup.enter.native="doSubmit" v-model="visible">
+			<el-form :model="inputForm" ref="inputForm" v-loading="loading"
+				label-width="100px" @submit.native.prevent>
+				<el-row :gutter="15">
+					<el-col :span="22">
+						<el-form-item label="分类名称" prop="name"
+							:rules="[{ required: true, message: '分类名称不能为空', trigger: 'blur' }]">
+							<el-input v-model="inputForm.name" placeholder="请输入分类名称"></el-input>
+						</el-form-item>
+					</el-col>
+					<el-col :span="22">
+						<el-form-item label="图标" prop="icon">
+							<el-input v-model="inputForm.icon" placeholder="输入emoji图标,如 🍳"></el-input>
+						</el-form-item>
+					</el-col>
+					<el-col :span="22">
+						<el-form-item label="排序号" prop="sortOrder">
+							<el-input-number v-model="inputForm.sortOrder" :min="0" style="width: 100%"></el-input-number>
+						</el-form-item>
+					</el-col>
+				</el-row>
+			</el-form>
+			<template #footer>
+				<span class="dialog-footer">
+					<el-button @click="close()" icon="el-icon-circle-close">关闭</el-button>
+					<el-button type="primary" @click="doSubmit()" icon="el-icon-circle-check" v-noMoreClick>确定</el-button>
+				</span>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+import HotelDishService from '@/api/hotel/HotelDishService'
+export default {
+	data() {
+		return {
+			title: '',
+			method: '',
+			visible: false,
+			loading: false,
+			inputForm: {
+				id: '',
+				name: '',
+				icon: '',
+				sortOrder: 0
+			}
+		}
+	},
+	hotelDishService: null,
+	created() {
+		this.hotelDishService = new HotelDishService()
+	},
+	methods: {
+		init(method, id) {
+			this.method = method
+			this.inputForm = {
+				id: '',
+				name: '',
+				icon: '',
+				sortOrder: 0
+			}
+			if (method === 'add') {
+				this.title = '新建分类'
+			} else if (method === 'edit') {
+				this.inputForm.id = id
+				this.title = '编辑分类'
+			}
+			this.visible = true
+			this.loading = false
+			this.$nextTick(() => {
+				if (method === 'edit') {
+					this.loading = true
+					this.hotelDishService.categoryPage({ current: 1, size: 999 }).then((data) => {
+						const records = data.records || []
+						const found = records.find(item => item.id === id)
+						if (found) {
+							this.inputForm = { ...this.inputForm, ...found }
+						}
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		doSubmit() {
+			this.$refs['inputForm'].validate((valid) => {
+				if (valid) {
+					this.loading = true
+					this.hotelDishService.categorySave(this.inputForm).then((data) => {
+						this.close()
+						this.$message.success(data)
+						this.$emit('refreshDataList')
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		close() {
+			this.$refs.inputForm && this.$refs.inputForm.resetFields()
+			this.visible = false
+		}
+	}
+}
+</script>

+ 132 - 0
src/views/hotel/dish/CategoryManagement.vue

@@ -0,0 +1,132 @@
+<template>
+	<div class="page">
+		<div class="jp-table top">
+			<vxe-toolbar :refresh="{ query: refreshList }" custom>
+				<template #buttons>
+					<el-button v-if="hasPermission('hotel:admin:category:add')" type="primary" icon="el-icon-plus"
+						@click="add()">新建分类</el-button>
+				</template>
+			</vxe-toolbar>
+			<div style="height: calc(100% - 50px)">
+				<vxe-table border="inner" auto-resize resizable height="auto" :loading="loading" ref="dataTable"
+					show-header-overflow show-overflow highlight-hover-row :menu-config="{}"
+					:data="dataList" :checkbox-config="{}">
+					<vxe-column type="seq" width="60" title="序号"></vxe-column>
+					<vxe-column title="分类名称" field="name" align="left" min-width="150"></vxe-column>
+					<vxe-column title="图标" field="icon" width="80" align="center">
+						<template #default="scope">
+							<span style="font-size: 20px;">{{ scope.row.icon || '-' }}</span>
+						</template>
+					</vxe-column>
+					<vxe-column title="排序号" field="sortOrder" width="100" align="center"></vxe-column>
+					<vxe-column title="菜品数量" field="dishCount" width="100" align="center"></vxe-column>
+					<vxe-column title="状态" field="status" width="100" align="center">
+						<template #default="scope">
+							<el-tag :type="scope.row.status === 'ENABLED' ? 'success' : 'info'" size="small">
+								{{ scope.row.status === 'ENABLED' ? '启用' : '禁用' }}
+							</el-tag>
+						</template>
+					</vxe-column>
+					<vxe-column title="操作" width="200px" fixed="right" align="center">
+						<template #default="scope">
+							<el-button v-if="hasPermission('hotel:admin:category:edit')" text type="primary"
+								@click="edit(scope.row.id)">编辑</el-button>
+							<el-button v-if="hasPermission('hotel:admin:category:edit')" text type="primary"
+								@click="doToggleStatus(scope.row.id)">
+								{{ scope.row.status === 'ENABLED' ? '禁用' : '启用' }}
+							</el-button>
+							<el-button v-if="hasPermission('hotel:admin:category:delete')" text type="danger"
+								@click="doDelete(scope.row.id)">删除</el-button>
+						</template>
+					</vxe-column>
+				</vxe-table>
+			</div>
+		</div>
+		<CategoryForm ref="categoryForm" @refreshDataList="refreshList"></CategoryForm>
+	</div>
+</template>
+
+<script>
+import HotelDishService from '@/api/hotel/HotelDishService'
+import CategoryForm from './CategoryForm'
+export default {
+	data() {
+		return {
+			dataList: [],
+			tablePage: {
+				total: 0,
+				currentPage: 1,
+				pageSize: 10
+			},
+			loading: false
+		}
+	},
+	hotelDishService: null,
+	created() {
+		this.hotelDishService = new HotelDishService()
+	},
+	components: {
+		CategoryForm
+	},
+	mounted() {
+		this.refreshList()
+	},
+	activated() {
+		this.refreshList()
+	},
+	methods: {
+		add() {
+			this.$refs.categoryForm.init('add', '')
+		},
+		edit(id) {
+			this.$refs.categoryForm.init('edit', id)
+		},
+		refreshList() {
+			this.loading = true
+			const params = {
+				current: this.tablePage.currentPage,
+				size: this.tablePage.pageSize
+			}
+			this.hotelDishService.categoryPage(params).then((data) => {
+				this.dataList = data.records || []
+				this.tablePage.total = data.total || 0
+				this.tablePage.currentPage = data.current || 1
+				this.loading = false
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		handlePageChange({ currentPage, pageSize }) {
+			this.tablePage.currentPage = currentPage
+			this.tablePage.pageSize = pageSize
+			this.refreshList()
+		},
+		doToggleStatus(id) {
+			this.loading = true
+			this.hotelDishService.categoryToggleStatus(id).then((data) => {
+				this.$message.success(data)
+				this.refreshList()
+				this.loading = false
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		doDelete(id) {
+			this.$confirm('确定删除该分类吗?', '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelDishService.categoryDelete(id).then((data) => {
+					this.$message.success(data)
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		}
+	}
+}
+</script>

+ 200 - 0
src/views/hotel/dish/DishForm.vue

@@ -0,0 +1,200 @@
+<template>
+	<div>
+		<el-dialog :title="title" :close-on-click-modal="false" draggable width="900px" @close="close"
+			@keyup.enter.native="doSubmit" v-model="visible">
+			<el-form :model="inputForm" ref="inputForm" v-loading="loading"
+				:class="method === 'view' ? 'readonly' : ''"
+				:disabled="method === 'view'" label-width="110px" @submit.native.prevent>
+				<el-row :gutter="20">
+					<el-col :span="12">
+						<el-form-item label="菜品名称" prop="name"
+							:rules="[{ required: true, message: '菜品名称不能为空', trigger: 'blur' }]">
+							<el-input v-model="inputForm.name" placeholder="请填写菜品名称"></el-input>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="分类" prop="categoryId"
+							:rules="[{ required: true, message: '请选择分类', trigger: 'change' }]">
+							<el-select v-model="inputForm.categoryId" placeholder="请选择分类" style="width: 100%">
+								<el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id"></el-option>
+							</el-select>
+						</el-form-item>
+					</el-col>
+				</el-row>
+				<el-row :gutter="20">
+					<el-col :span="12">
+						<el-form-item label="价格(元)" prop="price"
+							:rules="[{ required: true, message: '价格不能为空', trigger: 'blur' }]">
+							<el-input-number v-model="inputForm.price" :min="0" :precision="2"
+								style="width: 100%"></el-input-number>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="制作时长(分钟)" prop="prepTime">
+							<el-input-number v-model="inputForm.prepTime" :min="0" style="width: 100%"></el-input-number>
+						</el-form-item>
+					</el-col>
+				</el-row>
+				<el-row :gutter="20">
+					<el-col :span="12">
+						<el-form-item label="标签" prop="tags">
+							<el-input v-model="inputForm.tags" placeholder="多个标签用逗号分隔,如:辣,推荐,新品"></el-input>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="排序号" prop="sortOrder">
+							<el-input-number v-model="inputForm.sortOrder" :min="0" style="width: 100%"></el-input-number>
+						</el-form-item>
+					</el-col>
+				</el-row>
+				<el-row :gutter="20">
+					<el-col :span="12">
+						<el-form-item label="主厨推荐" prop="isRecommended">
+							<el-switch v-model="inputForm.isRecommended" :active-value="1" :inactive-value="0"></el-switch>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="状态" prop="status">
+							<el-radio-group v-model="inputForm.status">
+								<el-radio label="ON_SALE">在售</el-radio>
+								<el-radio label="OFF_SHELF">下架</el-radio>
+							</el-radio-group>
+						</el-form-item>
+					</el-col>
+				</el-row>
+				<el-row :gutter="20">
+					<el-col :span="24">
+						<el-form-item label="描述" prop="description">
+							<el-input v-model="inputForm.description" type="textarea" :rows="3"
+								placeholder="请输入菜品描述"></el-input>
+						</el-form-item>
+					</el-col>
+				</el-row>
+				<el-row :gutter="20">
+					<el-col :span="12">
+						<el-form-item label="菜品图片" prop="image">
+							<DishImageUpload ref="dishImageUpload" v-model="inputForm.image" :auth="method"
+								directory="hotel-dish" :max-size="10" :limit="1">
+							</DishImageUpload>
+						</el-form-item>
+					</el-col>
+				</el-row>
+			</el-form>
+			<template #footer>
+				<span class="dialog-footer">
+					<el-button @click="close()" icon="el-icon-circle-close">关闭</el-button>
+					<el-button type="primary" v-if="method !== 'view'" @click="doSubmit()" icon="el-icon-circle-check"
+						v-noMoreClick>确定</el-button>
+				</span>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script>
+import HotelDishService from '@/api/hotel/HotelDishService'
+import DishImageUpload from './DishImageUpload'
+export default {
+	data() {
+		return {
+			title: '',
+			method: '',
+			visible: false,
+			loading: false,
+			categoryList: [],
+			inputForm: {
+				id: '',
+				name: '',
+				categoryId: '',
+				price: 0,
+				tags: '',
+				prepTime: null,
+				image: '',
+				description: '',
+				isRecommended: 0,
+				sortOrder: 0,
+				status: 'ON_SALE'
+			}
+		}
+	},
+	hotelDishService: null,
+	created() {
+		this.hotelDishService = new HotelDishService()
+	},
+	components: {
+		DishImageUpload
+	},
+	methods: {
+		init(method, id) {
+			this.method = method
+			this.inputForm = {
+				id: '',
+				name: '',
+				categoryId: '',
+				price: 0,
+				tags: '',
+				prepTime: null,
+				image: '',
+				description: '',
+				isRecommended: 0,
+				sortOrder: 0,
+				status: 'ON_SALE'
+			}
+			if (method === 'add') {
+				this.title = '新建菜品'
+			} else if (method === 'edit') {
+				this.title = '修改菜品'
+				this.inputForm.id = id
+			} else if (method === 'view') {
+				this.title = '查看菜品'
+				this.inputForm.id = id
+			}
+			this.visible = true
+			this.loading = false
+			this.loadCategories()
+			this.$nextTick(() => {
+				if (method === 'edit' || method === 'view') {
+					this.loading = true
+					this.$refs.inputForm.resetFields()
+					this.hotelDishService.queryById(this.inputForm.id).then((data) => {
+						const dish = data || {}
+						this.inputForm = { ...this.inputForm, ...dish }
+						this.inputForm = JSON.parse(JSON.stringify(this.inputForm))
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		loadCategories() {
+			this.hotelDishService.categoryAll().then((data) => {
+				this.categoryList = Array.isArray(data) ? data : (data.list || [])
+			})
+		},
+		doSubmit() {
+			this.$refs.inputForm.validate((valid) => {
+				if (valid) {
+					if (this.$refs.dishImageUpload && this.$refs.dishImageUpload.checkProgress()) {
+						return
+					}
+					this.loading = true
+					this.hotelDishService.save(this.inputForm).then((data) => {
+						this.$message.success(data)
+						this.close()
+						this.$emit('refreshList')
+						this.loading = false
+					}).catch(() => {
+						this.loading = false
+					})
+				}
+			})
+		},
+		close() {
+			this.$refs.inputForm && this.$refs.inputForm.resetFields()
+			this.$refs.dishImageUpload && this.$refs.dishImageUpload.clearUpload()
+			this.visible = false
+		}
+	}
+}
+</script>

+ 351 - 0
src/views/hotel/dish/DishImageUpload.vue

@@ -0,0 +1,351 @@
+<template>
+	<div :key="uploadKey" class="dish-image-upload">
+		<el-upload ref="upload" action="" :limit="limit" :http-request="httpRequest" :multiple="limit > 1"
+			:disabled="currentAuth === 'view'" :on-exceed="onExceed" :show-file-list="false" :on-change="changes"
+			:on-progress="uploadProcess" :file-list="fileList" accept=".jpg,.jpeg,.png,.gif,.bmp">
+			<template v-if="currentAuth === 'view'" #tip>
+				<el-button :loading="loading" type="primary" size="default" :disabled="true">点击上传</el-button>
+			</template>
+			<template v-else #trigger>
+				<el-button :loading="loading" type="primary" size="default">点击上传</el-button>
+			</template>
+			<template #tip>
+				<div class="el-upload__tip">
+					<template v-if="limit === 1">仅支持上传1张图片,重新上传将覆盖当前图片,格式限 JPG、JPEG、PNG、GIF、BMP,不超过 {{ maxValue }}MB</template>
+					<template v-else>支持上传多张图片(最多{{ limit }}张),格式限 JPG、JPEG、PNG、GIF、BMP,单张不超过 {{ maxValue }}MB</template>
+				</div>
+			</template>
+		</el-upload>
+
+		<el-progress v-if="progressFlag" :percentage="loadProgress" style="margin-top: 10px"></el-progress>
+
+		<div class="image-list">
+			<div class="image-item" v-for="(item, index) in dataListNew" :key="item.url || item.uid || index">
+				<el-image class="image-thumb" :src="item.lsUrl || item.url" :preview-src-list="previewList"
+					:initial-index="index" fit="cover" hide-on-click-modal :preview-teleported="true" :z-index="9999">
+					<template #error>
+						<div class="image-error">图片</div>
+					</template>
+				</el-image>
+				<div v-if="currentAuth !== 'view'" class="image-actions">
+					<el-tooltip effect="dark" content="删除" placement="top">
+						<el-icon style="cursor: pointer;">
+							<Delete @click="deleteById(item, index)" />
+						</el-icon>
+					</el-tooltip>
+				</div>
+				<el-tooltip effect="dark" :content="item.name" placement="top">
+					<div class="image-name">{{ item.name }}</div>
+				</el-tooltip>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import OSSSerivce, {
+	httpRequest,
+	fileName,
+	beforeAvatarUpload
+} from '@/api/sys/OSSService'
+import moment from 'moment'
+export default {
+	props: {
+		modelValue: {
+			type: String,
+			default: ''
+		},
+		auth: {
+			type: String,
+			default: ''
+		},
+		limit: {
+			type: Number,
+			default: 9
+		},
+		directory: {
+			type: String,
+			default: 'dish'
+		},
+		maxSize: {
+			type: Number,
+			default: 10
+		}
+	},
+	data() {
+		return {
+			uploadKey: '',
+			progressFlag: false,
+			loadProgress: 0,
+			fileList: [],
+			dataList: [],
+			dataListNew: [],
+			ossService: null,
+			localAuth: '',
+			uploadDirectory: 'dish',
+			maxValue: 10,
+			loading: false,
+			fileLoading: true,
+			innerValue: '',
+			setValueKey: 0
+		}
+	},
+	computed: {
+		previewList() {
+			return this.dataListNew.map(item => item.lsUrl || item.url).filter(item => item)
+		},
+		currentAuth() {
+			return this.localAuth || this.auth
+		}
+	},
+	watch: {
+		modelValue(val) {
+			if (val !== this.innerValue) {
+				this.setValue(val)
+			}
+		}
+	},
+	created() {
+		this.ossService = new OSSSerivce()
+	},
+	mounted() {
+		this.uploadDirectory = this.directory || 'dish'
+		this.maxValue = this.maxSize || 10
+		this.setValue(this.modelValue)
+	},
+	methods: {
+		async newUpload(auth, value, directory, maxValue) {
+			this.localAuth = auth
+			this.uploadDirectory = directory || 'dish'
+			this.maxValue = maxValue || 10
+			await this.setValue(value)
+		},
+		async setValue(value) {
+			const key = ++this.setValueKey
+			const urls = this.parseValue(value)
+			this.innerValue = urls.join(',')
+			this.dataList = []
+			this.dataListNew = []
+			this.fileList = []
+			this.fileLoading = false
+			for (const url of urls) {
+				const item = {
+					name: this.getFileNameByUrl(url),
+					url: url,
+					status: 'success'
+				}
+				await this.fillPreviewUrl(item)
+				if (key !== this.setValueKey) {
+					return
+				}
+				this.dataList.push(item)
+				this.dataListNew.push(item)
+				this.fileList.push(item)
+			}
+			this.fileLoading = true
+		},
+		parseValue(value) {
+			if (value === undefined || value === null || value === '') {
+				return []
+			}
+			if (Array.isArray(value)) {
+				return value.map(item => item.url || item).filter(item => item)
+			}
+			return value.split(',').map(item => item.trim()).filter(item => item)
+		},
+		async httpRequest(file) {
+			await httpRequest(file, fileName(file), this.uploadDirectory, this.maxValue)
+		},
+		uploadProcess(event) {
+			this.progressFlag = true
+			this.loadProgress = parseInt(event.percent)
+			if (this.loadProgress >= 100) {
+				this.loadProgress = 100
+				setTimeout(() => {
+					this.progressFlag = false
+				}, 1000)
+			}
+		},
+		async changes(file) {
+			if (file.status === 'ready') {
+				return
+			}
+			if (!this.isImage(file.name)) {
+				this.$message.warning('仅支持 JPG、JPEG、PNG、GIF、BMP 图片')
+				return
+			}
+			if (!beforeAvatarUpload(file.raw || file, [], this.maxValue)) {
+				this.$message.error('图片大小不能超过 ' + this.maxValue + ' MB')
+				return
+			}
+			const url = file.raw && file.raw.url ? file.raw.url : file.url
+			if (!url) {
+				return
+			}
+			// 单图模式:直接覆盖
+			if (this.limit === 1) {
+				this.dataList = []
+				this.dataListNew = []
+				this.fileList = []
+			} else {
+				if (this.dataListNew.length >= this.limit) {
+					this.$message.warning(`当前限制上传 ${this.limit} 张图片`)
+					return
+				}
+				const exists = this.dataListNew.some(item => item.url === url || item.name === file.name)
+				if (exists) {
+					this.$message.error(`${file.name}已存在,无法重复上传`)
+					return
+				}
+			}
+			const item = {
+				name: file.name,
+				url: url,
+				status: 'success',
+				createTime: moment(new Date()).format('YYYY-MM-DD HH:mm:ss'),
+				createBy: {
+					id: this.$store.state.user.id,
+					name: this.$store.state.user.name
+				}
+			}
+			await this.fillPreviewUrl(item)
+			this.fileList.push(item)
+			this.dataList.push(item)
+			this.dataListNew.push(item)
+			this.emitValue()
+		},
+		async fillPreviewUrl(item) {
+			if (!item.url) {
+				return
+			}
+			if (item.url.indexOf('http') === 0) {
+				item.lsUrl = item.url
+				return
+			}
+			try {
+				item.lsUrl = await this.ossService.getTemporaryUrl(item.url)
+			} catch (e) {
+				item.lsUrl = item.url
+			}
+		},
+		deleteById(row, index) {
+			this.dataListNew.splice(index, 1)
+			this.dataList = this.dataList.filter(item => item.url !== row.url)
+			this.fileList = this.fileList.filter(item => item.url !== row.url)
+			this.emitValue()
+		},
+		clearUpload() {
+			this.$refs.upload && this.$refs.upload.clearFiles()
+			this.dataList = []
+			this.dataListNew = []
+			this.fileList = []
+			this.innerValue = ''
+			this.$emit('update:modelValue', '')
+		},
+		getDataList() {
+			return this.dataListNew
+		},
+		checkProgress() {
+			if (this.progressFlag === true) {
+				this.$message.warning('请等待图片上传完成后再进行操作')
+				return true
+			}
+			if (this.fileLoading === false) {
+				this.$message.warning('请等待图片加载完成后再进行操作')
+				return true
+			}
+			const invalidFile = this.dataListNew.find(file => !file.url)
+			if (invalidFile) {
+				this.$message.warning(`${invalidFile.name}的URL为空,请检查后重新上传`)
+				return true
+			}
+			return false
+		},
+		emitValue() {
+			const value = this.dataListNew.map(item => item.url).filter(item => item).join(',')
+			this.innerValue = value
+			this.$emit('update:modelValue', value)
+		},
+		isImage(name) {
+			const suffix = (name || '').substring((name || '').lastIndexOf('.') + 1).toLowerCase()
+			return ['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(suffix)
+		},
+		getFileNameByUrl(url) {
+			if (!url) {
+				return ''
+			}
+			const arr = url.split('/')
+			return arr[arr.length - 1]
+		},
+		onExceed() {
+			this.$message.warning(`当前限制上传 ${this.limit} 张图片`)
+		}
+	}
+}
+</script>
+
+<style scoped>
+.dish-image-upload {
+	width: 100%;
+}
+
+.image-list {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+	margin-top: 10px;
+}
+
+.image-item {
+	width: 104px;
+	position: relative;
+	border-radius: 4px;
+	background-color: #f7f9fa;
+	padding: 6px;
+}
+
+.image-thumb {
+	width: 92px;
+	height: 92px;
+	border-radius: 4px;
+	background-color: #f0f2f5;
+}
+
+.image-actions {
+	position: absolute;
+	top: 6px;
+	right: 6px;
+	width: 24px;
+	height: 24px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	border-radius: 0 4px 0 4px;
+	background: rgba(245, 108, 108, 0.9);
+	color: #fff;
+}
+
+.image-name {
+	margin-top: 4px;
+	font-size: 12px;
+	line-height: 18px;
+	overflow: hidden;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+}
+
+.image-error {
+	width: 100%;
+	height: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	color: #909399;
+	font-size: 12px;
+}
+
+.el-upload__tip {
+	color: #909399;
+	line-height: 20px;
+}
+</style>

+ 262 - 0
src/views/hotel/dish/DishManagement.vue

@@ -0,0 +1,262 @@
+<template>
+	<div class="page">
+		<el-form :inline="true" v-if="searchVisible" class="query-form m-b-10" ref="searchForm" :model="searchForm"
+			@keyup.enter.native="refreshList()" @submit.native.prevent>
+			<el-form-item label="菜品名称" prop="name">
+				<el-input v-model="searchForm.name" placeholder="请输入菜品名称" clearable></el-input>
+			</el-form-item>
+			<el-form-item label="分类" prop="categoryId">
+				<el-select v-model="searchForm.categoryId" placeholder="请选择分类" clearable style="width: 140px">
+					<el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item label="状态" prop="status">
+				<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 120px">
+					<el-option label="在售" value="ON_SALE"></el-option>
+					<el-option label="下架" value="OFF_SHELF"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="refreshList()" icon="el-icon-search">查询</el-button>
+				<el-button @click="resetSearch()" icon="el-icon-refresh-right">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="jp-table top">
+			<vxe-toolbar :refresh="{ query: refreshList }" custom>
+				<template #buttons>
+					<el-button v-if="hasPermission('hotel:admin:dish:add')" type="primary" icon="el-icon-plus"
+						@click="add()">新建</el-button>
+					<el-button v-if="hasPermission('hotel:admin:dish:delete')" type="danger" icon="el-icon-delete"
+						@click="batchDelete()"
+						:disabled="$refs.dataTable && $refs.dataTable.getCheckboxRecords().length === 0"
+						plain>删除</el-button>
+				</template>
+				<template #tools>
+					<vxe-button text type="primary" :title="searchVisible ? '收起检索' : '展开检索'" icon="vxe-icon-search"
+						class="tool-btn" @click="searchVisible = !searchVisible"></vxe-button>
+				</template>
+			</vxe-toolbar>
+			<div style="height: calc(100% - 90px)">
+				<vxe-table border="inner" auto-resize resizable height="auto" :loading="loading" ref="dataTable"
+					show-header-overflow show-overflow highlight-hover-row :menu-config="{}" :data="dataList"
+					:checkbox-config="{}">
+					<vxe-column type="seq" width="60" title="序号"></vxe-column>
+					<vxe-column type="checkbox" width="60"></vxe-column>
+					<vxe-column width="80" align="center" title="菜品图片" field="image">
+						<template #default="scope">
+							<el-image v-if="scope.row.previewImageUrl" :src="scope.row.previewImageUrl"
+								:preview-src-list="scope.row.previewImageList" fit="cover"
+								style="width: 44px; height: 44px; border-radius: 4px;" hide-on-click-modal
+								append-to-body :z-index="9999"></el-image>
+							<span v-else>暂无图片</span>
+						</template>
+					</vxe-column>
+					<vxe-column min-width="150" align="left" title="菜品名称" field="name"></vxe-column>
+					<vxe-column width="100" align="center" title="分类" field="categoryName"></vxe-column>
+					<vxe-column width="90" align="right" title="价格" field="price">
+						<template #default="scope">¥{{ scope.row.price }}</template>
+					</vxe-column>
+					<vxe-column width="100" align="center" title="制作时长" field="prepTime">
+						<template #default="scope">{{ scope.row.prepTime ? scope.row.prepTime + '分钟' : '-' }}</template>
+					</vxe-column>
+					<vxe-column width="80" align="center" title="月销" field="sales"></vxe-column>
+					<vxe-column width="100" align="center" title="状态" field="status">
+						<template #default="scope">
+							<el-tag :type="scope.row.status === 'ON_SALE' ? 'success' : 'info'">
+								{{ scope.row.status === 'ON_SALE' ? '在售' : '下架' }}
+							</el-tag>
+						</template>
+					</vxe-column>
+					<vxe-column width="80" align="center" title="售罄" field="soldOut">
+						<template #default="scope">
+							<el-tag v-if="scope.row.soldOut === 1" type="danger">售罄</el-tag>
+							<span v-else>-</span>
+						</template>
+					</vxe-column>
+					<vxe-column width="80" align="center" title="推荐" field="isRecommended">
+						<template #default="scope">
+							<el-tag v-if="scope.row.isRecommended === 1" type="warning">推荐</el-tag>
+							<span v-else>-</span>
+						</template>
+					</vxe-column>
+					<vxe-column width="70" align="center" title="排序" field="sortOrder"></vxe-column>
+					<vxe-column title="操作" width="220px" fixed="right" align="center">
+						<template #default="scope">
+							<el-button v-if="hasPermission('hotel:admin:dish:edit')" text type="primary" size="small"
+								@click="edit(scope.row.id)">修改</el-button>
+							<el-button v-if="hasPermission('hotel:admin:dish:edit')" text type="primary" size="small"
+								@click="changeStatus(scope.row)">{{ scope.row.status === 'ON_SALE' ? '下架' : '上架' }}</el-button>
+							<el-button v-if="hasPermission('hotel:admin:dish:delete')" text type="primary" size="small"
+								@click="doDelete(scope.row.id)">删除</el-button>
+						</template>
+					</vxe-column>
+				</vxe-table>
+				<vxe-pager background :current-page="tablePage.currentPage" :page-size="tablePage.pageSize"
+					:total="tablePage.total" :page-sizes="[10, 20, 100, 1000, { label: '全量数据', value: 1000000 }]"
+					:layouts="['PrevPage', 'JumpNumber', 'NextPage', 'FullJump', 'Sizes', 'Total']"
+					@page-change="currentChangeHandle">
+				</vxe-pager>
+			</div>
+		</div>
+		<DishForm ref="dishForm" @refreshList="refreshList"></DishForm>
+	</div>
+</template>
+
+<script>
+import HotelDishService from '@/api/hotel/HotelDishService'
+import DishForm from './DishForm'
+import OSSSerivce from '@/api/sys/OSSService'
+export default {
+	data() {
+		return {
+			searchVisible: true,
+			searchForm: {
+				name: '',
+				categoryId: '',
+				status: ''
+			},
+			categoryList: [],
+			dataList: [],
+			tablePage: {
+				total: 0,
+				currentPage: 1,
+				pageSize: 10,
+				orders: []
+			},
+			loading: false
+		}
+	},
+	hotelDishService: null,
+	ossService: null,
+	created() {
+		this.hotelDishService = new HotelDishService()
+		this.ossService = new OSSSerivce()
+	},
+	components: {
+		DishForm
+	},
+	mounted() {
+		this.refreshList()
+		this.loadCategories()
+	},
+	activated() {
+		this.refreshList()
+	},
+	methods: {
+		add() {
+			this.$refs.dishForm.init('add', '')
+		},
+		edit(id) {
+			this.$refs.dishForm.init('edit', id)
+		},
+		loadCategories() {
+			this.hotelDishService.categoryAll().then((data) => {
+				this.categoryList = Array.isArray(data) ? data : (data.list || [])
+			})
+		},
+		refreshList() {
+			this.loading = true
+			this.hotelDishService.page({
+				'current': this.tablePage.currentPage,
+				'size': this.tablePage.pageSize,
+				'orders': this.tablePage.orders,
+				...this.searchForm
+			}).then((data) => {
+				this.dataList = data.records || []
+				this.tablePage.total = data.total || 0
+				this.loading = false
+				this.loadImagePreview()
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		loadImagePreview() {
+			this.dataList.forEach((row) => {
+				const urls = this.getImageUrls(row.image)
+				if (urls.length === 0) {
+					row.previewImageUrl = ''
+					row.previewImageList = []
+					return
+				}
+				row.previewImageList = urls
+				row.previewImageUrl = urls[0]
+				Promise.all(urls.map(url => this.getPreviewUrl(url))).then((list) => {
+					row.previewImageList = list
+					row.previewImageUrl = list[0]
+				})
+			})
+		},
+		getImageUrls(value) {
+			if (!value) {
+				return []
+			}
+			return value.split(',').map(item => item.trim()).filter(item => item)
+		},
+		getPreviewUrl(url) {
+			if (url.indexOf('http') === 0) {
+				return Promise.resolve(url)
+			}
+			return this.ossService.getTemporaryUrl(url).catch(() => url)
+		},
+		currentChangeHandle({ currentPage, pageSize }) {
+			this.tablePage.currentPage = currentPage
+			this.tablePage.pageSize = pageSize
+			this.refreshList()
+		},
+		changeStatus(row) {
+			const status = row.status === 'ON_SALE' ? 'OFF_SHELF' : 'ON_SALE'
+			const message = status === 'ON_SALE' ? '确定上架该菜品吗?' : '确定下架该菜品吗?'
+			this.$confirm(message, '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.hotelDishService.updateStatus(row.id, status).then((data) => {
+					this.$message.success(data)
+					this.refreshList()
+				})
+			})
+		},
+		doDelete(id) {
+			this.$confirm('确定删除该菜品吗?删除后不可恢复!', '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelDishService.delete(id).then((data) => {
+					this.$message.success(data)
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		},
+		batchDelete() {
+			const ids = this.$refs.dataTable.getCheckboxRecords().map(item => item.id).join(',')
+			this.$confirm(`确定删除所选的 ${ids.split(',').length} 个菜品吗?`, '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.hotelDishService.batch({ ids: ids.split(','), operation: 'DELETE' }).then((data) => {
+					this.$message.success(data)
+					this.refreshList()
+					this.loading = false
+				}).catch(() => {
+					this.loading = false
+				})
+			})
+		},
+		resetSearch() {
+			this.$refs.searchForm.resetFields()
+			this.searchForm.categoryId = ''
+			this.tablePage.currentPage = 1
+			this.refreshList()
+		}
+	}
+}
+</script>

+ 33 - 1
yarn.lock

@@ -2771,6 +2771,11 @@ image-size@~0.5.0:
   resolved "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz"
   integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==
 
+immediate@~3.0.5:
+  version "3.0.6"
+  resolved "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
+  integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
+
 immer@^9.0.6:
   version "9.0.18"
   resolved "https://registry.npmmirror.com/immer/-/immer-9.0.18.tgz"
@@ -3136,6 +3141,16 @@ jstoxml@^2.0.0:
   resolved "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz"
   integrity sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==
 
+jszip@^3.10.1:
+  version "3.10.1"
+  resolved "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2"
+  integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==
+  dependencies:
+    lie "~3.3.0"
+    pako "~1.0.2"
+    readable-stream "~2.3.6"
+    setimmediate "^1.0.5"
+
 keycloak-js@26.2.4:
   version "26.2.4"
   resolved "https://registry.npmmirror.com/keycloak-js/-/keycloak-js-26.2.4.tgz#b96ff33ed08ff6fd8d1e09d9e5055c7b3f2fa918"
@@ -3173,6 +3188,13 @@ levn@^0.3.0, levn@~0.3.0:
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+lie@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
+  integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
+  dependencies:
+    immediate "~3.0.5"
+
 loader-runner@^4.2.0:
   version "4.3.0"
   resolved "https://registry.npmmirror.com/loader-runner/-/loader-runner-4.3.0.tgz"
@@ -3634,6 +3656,11 @@ pac-resolver@^5.0.0:
     ip "^1.1.5"
     netmask "^2.0.2"
 
+pako@~1.0.2:
+  version "1.0.11"
+  resolved "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
+  integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
+
 parchment@^1.1.4:
   version "1.1.4"
   resolved "https://registry.npmmirror.com/parchment/-/parchment-1.1.4.tgz"
@@ -3896,7 +3923,7 @@ readable-stream@1.1.x:
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@^2.3.6:
+readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.8"
   resolved "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz"
   integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
@@ -4105,6 +4132,11 @@ serialize-javascript@^6.0.0:
   dependencies:
     randombytes "^2.1.0"
 
+setimmediate@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
+  integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
+
 setprototypeof@1.2.0:
   version "1.2.0"
   resolved "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz"