Procházet zdrojové kódy

酒店,二维码管理、房间管理、房型管理、菜品管理、菜品分类、菜品规格、营业时段功能提交

徐滕 před 1 týdnem
rodič
revize
0ce11f04cb
56 změnil soubory, kde provedl 5922 přidání a 38 odebrání
  1. 193 0
      forge-admin-ui/src/api/hotel.js
  2. 215 0
      forge-admin-ui/src/views/hotel/businessHours.vue
  3. 456 0
      forge-admin-ui/src/views/hotel/dish.vue
  4. 112 0
      forge-admin-ui/src/views/hotel/dishCategory.vue
  5. 378 0
      forge-admin-ui/src/views/hotel/dishSpec.vue
  6. 255 0
      forge-server/db/migration/V1.0.101__add_hotel_dish_module_tables.sql
  7. 44 0
      forge-server/db/migration/V1.0.102__add_hotel_dish_spec_menu.sql
  8. 104 0
      forge-server/db/migration/V1.0.103__add_hotel_business_hours.sql
  9. 109 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelBusinessHoursController.java
  10. 89 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishCategoryController.java
  11. 123 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishController.java
  12. 136 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishSpecController.java
  13. 6 8
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelQrCodeController.java
  14. 3 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelOpenController.java
  15. 52 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelBusinessHours.java
  16. 71 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDish.java
  17. 44 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishAddition.java
  18. 43 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishCategory.java
  19. 44 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishLog.java
  20. 55 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishSpecGroup.java
  21. 44 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishSpecOption.java
  22. 23 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/DishBatchOperationDTO.java
  23. 31 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/HotelDishCategoryDTO.java
  24. 117 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/HotelDishDTO.java
  25. 57 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelBusinessHoursMapper.java
  26. 12 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishAdditionMapper.java
  27. 32 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishCategoryMapper.java
  28. 12 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishLogMapper.java
  29. 52 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishMapper.java
  30. 40 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishSpecGroupMapper.java
  31. 30 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishSpecOptionMapper.java
  32. 1 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelRoomMapper.java
  33. 80 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelBusinessHoursService.java
  34. 63 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishCategoryService.java
  35. 94 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishService.java
  36. 61 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishSpecGroupService.java
  37. 49 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishSpecOptionService.java
  38. 2 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelQrCodeService.java
  39. 162 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelBusinessHoursServiceImpl.java
  40. 134 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishCategoryServiceImpl.java
  41. 415 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishServiceImpl.java
  42. 151 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishSpecGroupServiceImpl.java
  43. 114 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishSpecOptionServiceImpl.java
  44. 9 7
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java
  45. 45 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishCategoryVO.java
  46. 114 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishVO.java
  47. 54 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelBusinessHoursMapper.xml
  48. 33 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishCategoryMapper.xml
  49. 79 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishMapper.xml
  50. 34 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishSpecGroupMapper.xml
  51. 23 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishSpecOptionMapper.xml
  52. 6 6
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelRoomMapper.xml
  53. 323 0
      forge-server/forge-business/forge-hotel/编码习惯与规范.md
  54. 420 0
      forge-server/forge-business/forge-hotel/酒店AGENTS.md
  55. 18 10
      forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md
  56. 456 0
      forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md

+ 193 - 0
forge-admin-ui/src/api/hotel.js

@@ -110,3 +110,196 @@ export function updateRoomType(data) {
 export function deleteRoomType(id) {
   return request.post(`/hotel/room-type/remove/${id}`)
 }
+
+// ==================== 菜品分类管理 ====================
+
+/** 查询全部分类(带菜品数量) */
+export function getDishCategoryList() {
+  return request.get('/hotel/dish-category/list')
+}
+
+/** 查询启用分类(下拉用) */
+export function getDishCategoryEnabled() {
+  return request.get('/hotel/dish-category/enabled')
+}
+
+/** 查询分类详情 */
+export function getDishCategoryDetail(id) {
+  return request.get(`/hotel/dish-category/${id}`)
+}
+
+/** 新增分类 */
+export function createDishCategory(data) {
+  return request.post('/hotel/dish-category', data)
+}
+
+/** 修改分类 */
+export function updateDishCategory(data) {
+  return request.put('/hotel/dish-category', data)
+}
+
+/** 删除分类 */
+export function deleteDishCategory(id) {
+  return request.post(`/hotel/dish-category/remove/${id}`)
+}
+
+/** 切换分类状态 */
+export function toggleDishCategoryStatus(id) {
+  return request.post(`/hotel/dish-category/${id}/toggle-status`)
+}
+
+// ==================== 菜品管理 ====================
+
+/** 分页查询菜品 */
+export function getDishPage(params) {
+  return request.get('/hotel/dish/page', { params })
+}
+
+/** 查询菜品详情 */
+export function getDishDetail(id) {
+  return request.get(`/hotel/dish/${id}`)
+}
+
+/** 新增菜品 */
+export function createDish(data) {
+  return request.post('/hotel/dish', data)
+}
+
+/** 修改菜品 */
+export function updateDish(data) {
+  return request.put('/hotel/dish', data)
+}
+
+/** 删除菜品 */
+export function deleteDish(id) {
+  return request.post(`/hotel/dish/remove/${id}`)
+}
+
+/** 更新菜品状态 */
+export function updateDishStatus(id, status) {
+  return request.put(`/hotel/dish/${id}/status`, null, { params: { status } })
+}
+
+/** 标记售罄 */
+export function markDishSoldOut(id) {
+  return request.post(`/hotel/dish/${id}/sold-out`)
+}
+
+/** 恢复上架 */
+export function restoreDish(id) {
+  return request.post(`/hotel/dish/${id}/restore`)
+}
+
+/** 批量操作菜品 */
+export function batchOperateDish(data) {
+  return request.post('/hotel/dish/batch', data)
+}
+
+/** 销量排行 */
+export function getDishSalesRanking(limit = 10) {
+  return request.get('/hotel/dish/ranking', { params: { limit } })
+}
+
+// ==================== 菜品规格管理 ====================
+
+/** 分页查询规格组 */
+export function getDishSpecPage(params) {
+  return request.get('/hotel/dish-spec/page', { params })
+}
+
+/** 根据菜品ID查询规格组(含选项) */
+export function getDishSpecByDishId(dishId) {
+  return request.get(`/hotel/dish-spec/by-dish/${dishId}`)
+}
+
+/** 查询规格组详情 */
+export function getDishSpecDetail(id) {
+  return request.get(`/hotel/dish-spec/${id}`)
+}
+
+/** 新增规格组 */
+export function createDishSpec(data) {
+  return request.post('/hotel/dish-spec', data)
+}
+
+/** 修改规格组 */
+export function updateDishSpec(data) {
+  return request.put('/hotel/dish-spec', data)
+}
+
+/** 删除规格组(级联删除选项) */
+export function deleteDishSpec(id) {
+  return request.post(`/hotel/dish-spec/remove/${id}`)
+}
+
+/** 根据规格组ID查询选项列表 */
+export function getDishSpecOptionList(groupId) {
+  return request.get(`/hotel/dish-spec/option/list/${groupId}`)
+}
+
+/** 查询规格选项详情 */
+export function getDishSpecOptionDetail(id) {
+  return request.get(`/hotel/dish-spec/option/${id}`)
+}
+
+/** 新增规格选项 */
+export function createDishSpecOption(data) {
+  return request.post('/hotel/dish-spec/option', data)
+}
+
+/** 修改规格选项 */
+export function updateDishSpecOption(data) {
+  return request.put('/hotel/dish-spec/option', data)
+}
+
+/** 删除规格选项 */
+export function deleteDishSpecOption(id) {
+  return request.post(`/hotel/dish-spec/option/remove/${id}`)
+}
+
+// ==================== 营业时段管理 ====================
+
+/** 分页查询营业时段 */
+export function getBusinessHoursPage(params) {
+  return request.get('/hotel/business-hours/page', { params })
+}
+
+/** 查询全部营业时段(含禁用) */
+export function getBusinessHoursAll() {
+  return request.get('/hotel/business-hours/all')
+}
+
+/** 查询启用的营业时段(顾客端用) */
+export function getBusinessHoursEnabled() {
+  return request.get('/hotel/business-hours/enabled')
+}
+
+/** 检查当前营业状态(顾客端用) */
+export function checkCurrentBusinessHours() {
+  return request.get('/hotel/business-hours/check-current')
+}
+
+/** 查询营业时段详情 */
+export function getBusinessHoursDetail(id) {
+  return request.get(`/hotel/business-hours/${id}`)
+}
+
+/** 新增营业时段 */
+export function createBusinessHours(data) {
+  return request.post('/hotel/business-hours', data)
+}
+
+/** 修改营业时段 */
+export function updateBusinessHours(data) {
+  return request.put('/hotel/business-hours', data)
+}
+
+/** 切换营业时段状态 */
+export function toggleBusinessHours(id) {
+  return request.post(`/hotel/business-hours/${id}/toggle`)
+}
+
+/** 删除营业时段 */
+export function deleteBusinessHours(id) {
+  return request.post(`/hotel/business-hours/remove/${id}`)
+}

+ 215 - 0
forge-admin-ui/src/views/hotel/businessHours.vue

@@ -0,0 +1,215 @@
+<template>
+  <div class="hotel-business-hours-page">
+    <AiCrudPage
+      ref="crudRef"
+      api="/hotel/business-hours"
+      :api-config="{
+        list: 'get@/hotel/business-hours/page',
+        detail: 'get@/hotel/business-hours/:id',
+        add: 'post@/hotel/business-hours',
+        update: 'put@/hotel/business-hours',
+        delete: 'post@/hotel/business-hours/remove/:id',
+      }"
+      :load-detail-on-edit="true"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      :edit-schema="editSchema"
+      row-key="id"
+      add-button-text="新增时段"
+    />
+  </div>
+</template>
+
+<script setup>
+import { NTag } from 'naive-ui'
+import { computed, h, ref } from 'vue'
+import { toggleBusinessHours } from '@/api/hotel'
+import { AiCrudPage } from '@/components/ai-form'
+
+defineOptions({ name: 'HotelBusinessHours' })
+
+const crudRef = ref(null)
+
+// 星期映射
+const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
+
+// 餐段类型 → 默认名称映射
+const mealTypeNameMap = {
+  breakfast: '早餐',
+  lunch: '午餐',
+  dinner: '晚餐',
+}
+
+// 状态标签颜色映射
+const statusColorMap = {
+  ENABLED: 'success',
+  DISABLED: 'default',
+}
+const statusLabelMap = {
+  ENABLED: '启用',
+  DISABLED: '禁用',
+}
+
+// 生成时间段选项(09:00 ~ 23:30,每30分钟一档)
+const timeOptions = (() => {
+  const options = []
+  for (let h = 9; h <= 23; h++) {
+    for (let m = 0; m < 60; m += 30) {
+      const label = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+      options.push({ label, value: label })
+    }
+  }
+  return options
+})()
+
+// 搜索表单配置
+const searchSchema = [
+  {
+    field: 'status',
+    label: '状态',
+    type: 'select',
+    props: {
+      placeholder: '请选择状态',
+      options: [
+        { label: '启用', value: 'ENABLED' },
+        { label: '禁用', value: 'DISABLED' },
+      ],
+    },
+  },
+]
+
+// 表格列配置
+const tableColumns = computed(() => [
+  {
+    label: '序号',
+    width: 60,
+    render: (_, index) => h('span', {}, index + 1),
+  },
+  { prop: 'mealName', label: '餐段名称', minWidth: 120 },
+  {
+    prop: 'mealType',
+    label: '餐段类型',
+    width: 100,
+    render: row => h('span', {}, mealTypeNameMap[row.mealType] || row.mealType),
+  },
+  {
+    prop: 'dayOfWeek',
+    label: '适用星期',
+    width: 100,
+    render: row => h('span', {}, row.dayOfWeek != null ? weekDays[row.dayOfWeek] : '每天'),
+  },
+  { prop: 'startTime', label: '开始时间', width: 100 },
+  { prop: 'endTime', label: '结束时间', width: 100 },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 90,
+    render: row => h(NTag, { type: statusColorMap[row.status] || 'default', size: 'small' }, { default: () => statusLabelMap[row.status] || row.status }),
+  },
+  { prop: 'sortOrder', label: '排序', width: 80 },
+  {
+    label: '操作',
+    width: 200,
+    render: (row) => {
+      const toggleText = row.status === 'ENABLED' ? '禁用' : '启用'
+      const toggleType = row.status === 'ENABLED' ? 'text-warning' : 'text-success'
+      return h('div', { class: 'flex gap-3' }, [
+        h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          onClick: () => crudRef.value?.handleEdit(row),
+        }, '编辑'),
+        h('a', {
+          class: `${toggleType} cursor-pointer hover:opacity-80`,
+          onClick: async () => {
+            await toggleBusinessHours(row.id)
+            crudRef.value?.refresh()
+          },
+        }, toggleText),
+        h('a', {
+          class: 'text-error cursor-pointer hover:opacity-80',
+          onClick: () => crudRef.value?.handleDelete(row),
+        }, '删除'),
+      ])
+    },
+  },
+])
+
+// 编辑表单配置
+const editSchema = computed(() => [
+  {
+    field: 'mealType',
+    label: '餐段类型',
+    type: 'select',
+    rules: [{ required: true, message: '请选择餐段类型', trigger: 'change' }],
+    props: {
+      placeholder: '请选择餐段类型',
+      options: [
+        { label: '早餐', value: 'breakfast' },
+        { label: '午餐', value: 'lunch' },
+        { label: '晚餐', value: 'dinner' },
+      ],
+      onUpdateValue: (value) => {
+        // 选择餐段类型时自动填充名称
+        if (value && mealTypeNameMap[value]) {
+          const form = crudRef.value?.formData
+          if (form && !form.mealName) {
+            form.mealName = mealTypeNameMap[value]
+          }
+        }
+      },
+    },
+  },
+  {
+    field: 'mealName',
+    label: '餐段名称',
+    type: 'input',
+    rules: [{ required: true, message: '请输入餐段名称', trigger: 'blur' }],
+    props: { placeholder: '如:早餐、午餐、晚餐'},
+  },
+  {
+    field: 'dayOfWeek',
+    label: '适用星期',
+    type: 'select',
+    props: {
+      placeholder: '每天(不限制星期)',
+      clearable: true,
+      options: [
+        { label: '周日', value: 0 },
+        { label: '周一', value: 1 },
+        { label: '周二', value: 2 },
+        { label: '周三', value: 3 },
+        { label: '周四', value: 4 },
+        { label: '周五', value: 5 },
+        { label: '周六', value: 6 },
+      ],
+    },
+  },
+  {
+    field: 'startTime',
+    label: '开始时间',
+    type: 'select',
+    rules: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
+    props: { placeholder: '请选择开始时间', options: timeOptions },
+  },
+  {
+    field: 'endTime',
+    label: '结束时间',
+    type: 'select',
+    rules: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+    props: { placeholder: '请选择结束时间', options: timeOptions },
+  },
+  {
+    field: 'sortOrder',
+    label: '排序',
+    type: 'inputNumber',
+    defaultValue: 0,
+    props: { min: 0, step: 1, precision: 0, placeholder: '排序号' },
+  },
+])
+</script>
+
+<style scoped>
+.hotel-business-hours-page {
+  height: 100%;
+}
+</style>

+ 456 - 0
forge-admin-ui/src/views/hotel/dish.vue

@@ -0,0 +1,456 @@
+<template>
+  <div class="hotel-dish-page">
+    <AiCrudPage
+      ref="crudRef"
+      api="/hotel/dish"
+      :api-config="{
+        list: 'get@/hotel/dish/page',
+        detail: 'get@/hotel/dish/:id',
+        add: 'post@/hotel/dish',
+        update: 'put@/hotel/dish',
+        delete: 'post@/hotel/dish/remove/:id',
+      }"
+      :load-detail-on-edit="true"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      :edit-schema="editSchema"
+      row-key="id"
+      add-button-text="新增菜品"
+    />
+
+    <!-- 图片全屏预览 -->
+    <Teleport to="body">
+      <Transition name="fade">
+        <div v-if="previewVisible" class="image-preview-overlay" @click.self="closePreview" @keydown.esc="closePreview">
+          <div class="preview-close" @click="closePreview">&times;</div>
+          <div class="preview-nav preview-nav-left" @click.stop="prevImage">&#8249;</div>
+          <div class="preview-main">
+            <AuthImage v-if="previewUrls.length > 0" :src="previewUrls[previewIndex]" style="max-width: 90vw; max-height: 85vh; object-fit: contain;" :lazy="false" />
+          </div>
+          <div class="preview-nav preview-nav-right" @click.stop="nextImage">&#8250;</div>
+          <div v-if="previewUrls.length > 1" class="preview-dots">
+            <span v-for="(_, i) in previewUrls" :key="i" class="preview-dot" :class="{ active: i === previewIndex }" @click.stop="previewIndex = i" />
+          </div>
+          <div class="preview-counter">
+            {{ previewIndex + 1 }} / {{ previewUrls.length }}
+          </div>
+        </div>
+      </Transition>
+    </Teleport>
+  </div>
+</template>
+
+<script setup>
+import { NTag } from 'naive-ui'
+import { computed, h, ref } from 'vue'
+import { getDishCategoryEnabled, markDishSoldOut, restoreDish, updateDishStatus } from '@/api/hotel'
+import { AiCrudPage } from '@/components/ai-form'
+import AuthImage from '@/components/common/AuthImage.vue'
+
+defineOptions({ name: 'HotelDish' })
+
+const crudRef = ref(null)
+
+// 图片预览
+const previewVisible = ref(false)
+const previewUrls = ref([])
+const previewIndex = ref(0)
+
+function openPreview(row) {
+  const urls = []
+  if (row.image)
+    urls.push(row.image)
+  if (row.images)
+    urls.push(...String(row.images).split(',').filter(Boolean))
+  if (urls.length === 0)
+    return
+  previewUrls.value = urls
+  previewIndex.value = 0
+  previewVisible.value = true
+}
+
+function prevImage() {
+  if (previewIndex.value > 0)
+    previewIndex.value--
+  else
+    previewIndex.value = previewUrls.value.length - 1
+}
+
+function nextImage() {
+  if (previewIndex.value < previewUrls.value.length - 1)
+    previewIndex.value++
+  else
+    previewIndex.value = 0
+}
+
+function closePreview() {
+  previewVisible.value = false
+  previewUrls.value = []
+  previewIndex.value = 0
+}
+
+// 分类选项(异步加载)
+const categoryOptions = ref([])
+async function loadCategories() {
+  try {
+    const res = await getDishCategoryEnabled()
+    if (res.code === 200) {
+      categoryOptions.value = (res.data || []).map(c => ({
+        label: c.name,
+        value: c.id,
+      }))
+    }
+  }
+  catch {
+    // ignore
+  }
+}
+loadCategories()
+
+// 状态选项
+const dishStatusOptions = [
+  { label: '在售', value: 'ON_SALE' },
+  { label: '下架', value: 'OFF_SHELF' },
+]
+
+// 状态颜色映射
+const statusColorMap = {
+  ON_SALE: 'success',
+  OFF_SHELF: 'default',
+}
+const statusLabelMap = {
+  ON_SALE: '在售',
+  OFF_SHELF: '下架',
+}
+
+// 搜索表单配置
+const searchSchema = [
+  {
+    field: 'name',
+    label: '菜品名称',
+    type: 'input',
+    props: { placeholder: '请输入菜品名称' },
+  },
+  {
+    field: 'categoryId',
+    label: '分类',
+    type: 'select',
+    props: { placeholder: '请选择分类', options: categoryOptions },
+  },
+  {
+    field: 'status',
+    label: '状态',
+    type: 'select',
+    props: { placeholder: '请选择状态', options: dishStatusOptions },
+  },
+]
+
+// 表格列配置
+const tableColumns = computed(() => [
+  {
+    label: '序号',
+    width: 60,
+    render: (row, index) => h('span', {}, (index + 1)),
+  },
+  { prop: 'name', label: '菜品名称', minWidth: 160 },
+  {
+    prop: 'image',
+    label: '主图',
+    width: 100,
+    render: (row) => {
+      if (!row.image)
+        return '-'
+      return h('div', { style: 'overflow: hidden; display: flex; align-items: center;' }, [h(AuthImage, { src: row.image, style: 'width: 40px; height: 40px; border-radius: 4px; object-fit: cover; cursor: pointer;', onClick: () => openPreview(row) })])
+    },
+  },
+  { prop: 'categoryName', label: '分类', width: 120 },
+  { prop: 'price', label: '价格(元)', width: 100 },
+  { prop: 'sales', label: '月销', width: 80 },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 90,
+    render: row => h(NTag, { type: statusColorMap[row.status] || 'default', size: 'small' }, { default: () => statusLabelMap[row.status] || row.status }),
+  },
+  {
+    prop: 'soldOut',
+    label: '售罄',
+    width: 80,
+    render: row => h(NTag, { type: row.soldOut === 1 ? 'error' : 'success', size: 'small' }, { default: () => row.soldOut === 1 ? '售罄' : '正常' }),
+  },
+  {
+    prop: 'isRecommended',
+    label: '推荐',
+    width: 80,
+    render: row => row.isRecommended === 1 ? h(NTag, { type: 'warning', size: 'small' }, { default: () => '推荐' }) : '-',
+  },
+  { prop: 'sortOrder', label: '排序', width: 70 },
+  { prop: 'createTime', label: '创建时间', width: 172 },
+  {
+    label: '操作',
+    width: 320,
+    render: (row) => {
+      const actions = []
+      // 编辑(仅下架状态可编辑)
+      if (row.status === 'OFF_SHELF') {
+        actions.push(h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          onClick: () => crudRef.value?.handleEdit(row),
+        }, '编辑'))
+      }
+      // 上架/下架
+      if (row.status === 'ON_SALE') {
+        actions.push(h('a', {
+          class: 'text-warning cursor-pointer hover:opacity-80',
+          onClick: async () => {
+            await updateDishStatus(row.id, 'OFF_SHELF')
+            crudRef.value?.refresh()
+          },
+        }, '下架'))
+      }
+      else {
+        actions.push(h('a', {
+          class: 'text-success cursor-pointer hover:opacity-80',
+          onClick: async () => {
+            await updateDishStatus(row.id, 'ON_SALE')
+            crudRef.value?.refresh()
+          },
+        }, '上架'))
+      }
+      // 售罄/恢复
+      if (row.soldOut === 1) {
+        actions.push(h('a', {
+          class: 'text-success cursor-pointer hover:opacity-80',
+          onClick: async () => {
+            await restoreDish(row.id)
+            crudRef.value?.refresh()
+          },
+        }, '恢复'))
+      }
+      else {
+        actions.push(h('a', {
+          class: 'text-error cursor-pointer hover:opacity-80',
+          onClick: async () => {
+            await markDishSoldOut(row.id)
+            crudRef.value?.refresh()
+          },
+        }, '售罄'))
+      }
+      // 删除
+      actions.push(h('a', {
+        class: 'text-error cursor-pointer hover:opacity-80',
+        onClick: () => crudRef.value?.handleDelete(row),
+      }, '删除'))
+      return h('div', { class: 'flex gap-3' }, actions)
+    },
+  },
+])
+
+// 编辑表单配置
+const editSchema = computed(() => [
+  {
+    field: 'name',
+    label: '菜品名称',
+    type: 'input',
+    rules: [{ required: true, message: '请输入菜品名称', trigger: 'blur' }],
+    props: { placeholder: '请输入菜品名称' },
+  },
+  {
+    field: 'categoryId',
+    label: '分类',
+    type: 'select',
+    props: { placeholder: '请选择分类', options: categoryOptions.value },
+  },
+  {
+    field: 'price',
+    label: '价格(元)',
+    type: 'inputNumber',
+    rules: [{ required: true, message: '请输入价格', trigger: 'blur' }],
+    props: { placeholder: '请输入价格', min: 0, step: 1, precision: 2 },
+  },
+  {
+    field: 'image',
+    label: '主图',
+    type: 'imageUpload',
+    businessType: 'hotel-dish-image',
+    limit: 1,
+    fileSize: 5,
+    fileType: ['png', 'jpg', 'jpeg', 'webp'],
+    valueType: 'string',
+    props: { showTip: true },
+  },
+  {
+    field: 'images',
+    label: '附加图片',
+    type: 'imageUpload',
+    businessType: 'hotel-dish-images',
+    limit: 9,
+    fileSize: 5,
+    fileType: ['png', 'jpg', 'jpeg', 'webp'],
+    valueType: 'string',
+    span: 2,
+    props: { showTip: true },
+  },
+  {
+    field: 'description',
+    label: '描述',
+    type: 'textarea',
+    span: 2,
+    props: { placeholder: '请输入菜品描述', rows: 3 },
+  },
+  {
+    field: 'tags',
+    label: '标签',
+    type: 'input',
+    props: { placeholder: '标签,逗号分隔,如:辣,推荐' },
+  },
+  {
+    field: 'prepTime',
+    label: '制作时长',
+    type: 'inputNumber',
+    props: { placeholder: '预计制作时长(分钟)', min: 0, step: 1, precision: 0 },
+  },
+  {
+    field: 'status',
+    label: '状态',
+    type: 'radio',
+    defaultValue: 'ON_SALE',
+    props: { options: dishStatusOptions },
+  },
+  {
+    field: 'isRecommended',
+    label: '主厨推荐',
+    type: 'switch',
+    defaultValue: 0,
+    props: {
+      checkedValue: 1,
+      uncheckedValue: 0,
+    },
+  },
+  {
+    field: 'sortOrder',
+    label: '排序',
+    type: 'inputNumber',
+    defaultValue: 0,
+    props: { min: 0, step: 1, precision: 0 },
+  },
+])
+</script>
+
+<style scoped>
+.hotel-dish-page {
+  height: 100%;
+}
+
+.image-preview-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  background: rgba(0, 0, 0, 0.85);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.preview-close {
+  position: absolute;
+  top: 16px;
+  right: 20px;
+  font-size: 32px;
+  color: #fff;
+  cursor: pointer;
+  z-index: 10;
+  line-height: 1;
+  opacity: 0.7;
+  transition: opacity 0.2s;
+}
+
+.preview-close:hover {
+  opacity: 1;
+}
+
+.preview-main {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-width: 85vw;
+  max-height: 80vh;
+  overflow: hidden;
+}
+
+.preview-main :deep(img) {
+  max-width: 85vw;
+  max-height: 80vh;
+  object-fit: contain;
+}
+
+.preview-nav {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 48px;
+  color: #fff;
+  cursor: pointer;
+  user-select: none;
+  opacity: 0.6;
+  transition: opacity 0.2s;
+  z-index: 10;
+  line-height: 1;
+}
+
+.preview-nav:hover {
+  opacity: 1;
+}
+
+.preview-nav-left {
+  left: 24px;
+}
+
+.preview-nav-right {
+  right: 24px;
+}
+
+.preview-dots {
+  position: absolute;
+  bottom: 40px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: 8px;
+}
+
+.preview-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.4);
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.preview-dot.active {
+  background: #fff;
+}
+
+.preview-counter {
+  position: absolute;
+  bottom: 16px;
+  left: 50%;
+  transform: translateX(-50%);
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.6);
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.25s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 112 - 0
forge-admin-ui/src/views/hotel/dishCategory.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="hotel-dish-category-page">
+    <AiCrudPage
+      ref="crudRef"
+      api="/hotel/dish-category"
+      :api-config="{
+        list: 'get@/hotel/dish-category/list',
+        detail: 'get@/hotel/dish-category/:id',
+        add: 'post@/hotel/dish-category',
+        update: 'put@/hotel/dish-category',
+        delete: 'post@/hotel/dish-category/remove/:id',
+      }"
+      :load-detail-on-edit="true"
+      :columns="tableColumns"
+      :edit-schema="editSchema"
+      row-key="id"
+      add-button-text="新增分类"
+    />
+  </div>
+</template>
+
+<script setup>
+import { NTag } from 'naive-ui'
+import { computed, h, ref } from 'vue'
+import { toggleDishCategoryStatus } from '@/api/hotel'
+import { AiCrudPage } from '@/components/ai-form'
+
+defineOptions({ name: 'HotelDishCategory' })
+
+const crudRef = ref(null)
+
+// 状态标签颜色映射
+const statusColorMap = {
+  ENABLED: 'success',
+  DISABLED: 'default',
+}
+const statusLabelMap = {
+  ENABLED: '启用',
+  DISABLED: '禁用',
+}
+
+// 表格列配置
+const tableColumns = computed(() => [
+  { prop: 'name', label: '分类名称', minWidth: 150 },
+  { prop: 'sortOrder', label: '排序', width: 80 },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 100,
+    render: row => h(NTag, { type: statusColorMap[row.status] || 'default', size: 'small' }, { default: () => statusLabelMap[row.status] || row.status }),
+  },
+  { prop: 'dishCount', label: '菜品数', width: 100 },
+  { prop: 'createTime', label: '创建时间', width: 172 },
+  {
+    label: '操作',
+    width: 200,
+    render: (row) => {
+      const toggleText = row.status === 'ENABLED' ? '禁用' : '启用'
+      const toggleType = row.status === 'ENABLED' ? 'text-warning' : 'text-success'
+      return h('div', { class: 'flex gap-3' }, [
+        h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          onClick: () => crudRef.value?.handleEdit(row),
+        }, '编辑'),
+        h('a', {
+          class: `${toggleType} cursor-pointer hover:opacity-80`,
+          onClick: async () => {
+            await toggleDishCategoryStatus(row.id)
+            crudRef.value?.refresh()
+          },
+        }, toggleText),
+      ])
+    },
+  },
+])
+
+// 编辑表单配置
+const editSchema = computed(() => [
+  {
+    field: 'name',
+    label: '分类名称',
+    type: 'input',
+    rules: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
+    props: { placeholder: '请输入分类名称' },
+  },
+  {
+    field: 'sortOrder',
+    label: '排序',
+    type: 'input',
+    props: { placeholder: '排序号', type: 'number' },
+  },
+  {
+    field: 'status',
+    label: '状态',
+    type: 'select',
+    defaultValue: 'ENABLED',
+    props: {
+      placeholder: '请选择状态',
+      options: [
+        { label: '启用', value: 'ENABLED' },
+        { label: '禁用', value: 'DISABLED' },
+      ],
+    },
+  },
+])
+</script>
+
+<style scoped>
+.hotel-dish-category-page {
+  height: 100%;
+}
+</style>

+ 378 - 0
forge-admin-ui/src/views/hotel/dishSpec.vue

@@ -0,0 +1,378 @@
+<template>
+  <div class="hotel-dish-spec-page">
+    <AiCrudPage
+      ref="crudRef"
+      api="/hotel/dish-spec"
+      :api-config="{
+        list: 'get@/hotel/dish-spec/page',
+        detail: 'get@/hotel/dish-spec/:id',
+        add: 'post@/hotel/dish-spec',
+        update: 'put@/hotel/dish-spec',
+        delete: 'post@/hotel/dish-spec/remove/:id',
+      }"
+      :load-detail-on-edit="true"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      :edit-schema="editSchema"
+      row-key="id"
+      add-button-text="新增规格组"
+    />
+
+    <!-- 规格选项管理弹窗 -->
+    <NModal
+      v-model:show="optionDialogVisible"
+      title="规格选项管理"
+      preset="dialog"
+      style="width: 650px;"
+      :show-icon="false"
+      :mask-closable="false"
+    >
+      <div v-if="currentGroup">
+        <div class="mb-3 flex items-center gap-2">
+          <span>规格组:<strong>{{ currentGroup.groupName }}</strong></span>
+          <NTag :type="currentGroup.required === 1 ? 'error' : 'info'" size="small">
+            {{ currentGroup.required === 1 ? '必选' : '可选' }}
+          </NTag>
+        </div>
+        <div class="mb-3">
+          <NButton type="primary" size="small" @click="openOptionForm('add')">
+            新增选项
+          </NButton>
+        </div>
+        <NDataTable
+          :columns="optionColumns"
+          :data="optionList"
+          :loading="optionLoading"
+          :row-key="(row) => row.id"
+          bordered
+          size="small"
+        />
+      </div>
+    </NModal>
+
+    <!-- 规格选项表单弹窗 -->
+    <NModal
+      v-model:show="optionFormVisible"
+      :title="optionFormTitle"
+      preset="dialog"
+      style="width: 460px;"
+      :show-icon="false"
+      :mask-closable="false"
+    >
+      <NForm
+        ref="optionFormRef"
+        :model="optionForm"
+        :rules="optionRules"
+        label-placement="left"
+        label-width="80"
+        class="mt-4"
+      >
+        <NFormItem label="选项名称" path="optionName">
+          <NInput v-model:value="optionForm.optionName" placeholder="如:三分熟、黑椒汁" />
+        </NFormItem>
+        <NFormItem label="加价金额" path="priceExtra">
+          <NInputNumber v-model:value="optionForm.priceExtra" :min="0" :max="9999" :precision="2" style="width: 100%;" placeholder="0表示不加价" />
+        </NFormItem>
+        <NFormItem label="排序号" path="sortOrder">
+          <NInputNumber v-model:value="optionForm.sortOrder" :min="0" :max="999" style="width: 100%;" />
+        </NFormItem>
+      </NForm>
+      <template #action>
+        <NButton @click="optionFormVisible = false">
+          取消
+        </NButton>
+        <NButton type="primary" :loading="optionFormLoading" @click="submitOptionForm">
+          确定
+        </NButton>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<script setup>
+import { NButton, NDataTable, NForm, NFormItem, NInput, NInputNumber, NModal, NTag, useDialog, useMessage } from 'naive-ui'
+import { computed, h, onMounted, reactive, ref } from 'vue'
+import {
+  createDishSpecOption,
+  deleteDishSpecOption,
+  getDishPage,
+  getDishSpecOptionList,
+  updateDishSpecOption,
+} from '@/api/hotel'
+import { AiCrudPage } from '@/components/ai-form'
+
+defineOptions({ name: 'HotelDishSpec' })
+
+const message = useMessage()
+const dialog = useDialog()
+const crudRef = ref(null)
+
+// ==================== 菜品下拉(仅加载在售菜品) ====================
+const dishOptions = ref([])
+
+async function loadDishOptions() {
+  try {
+    const res = await getDishPage({ pageNum: 1, pageSize: 10000, status: 'ON_SALE' })
+    if (res.code === 200) {
+      dishOptions.value = (res.data?.records || []).map(d => ({
+        label: d.name,
+        value: d.id,
+      }))
+    }
+  }
+  catch {
+    // ignore
+  }
+}
+
+// ==================== 搜索表单配置 ====================
+const searchSchema = computed(() => [
+  {
+    field: 'dishName',
+    label: '菜品名称',
+    type: 'input',
+    props: { placeholder: '请输入菜品名称' },
+  },
+  {
+    field: 'groupName',
+    label: '规格组名称',
+    type: 'input',
+    props: { placeholder: '请输入规格组名称' },
+  },
+])
+
+// ==================== 表格列配置 ====================
+const tableColumns = computed(() => [
+  {
+    label: '序号',
+    width: 60,
+    render: (_, index) => h('span', {}, index + 1),
+  },
+  { prop: 'dishName', label: '菜品名称', minWidth: 150 },
+  { prop: 'groupName', label: '规格组名称', minWidth: 150 },
+  {
+    prop: 'required',
+    label: '是否必选',
+    width: 100,
+    render: row => h(NTag, { type: row.required === 1 ? 'error' : 'info', size: 'small' }, { default: () => row.required === 1 ? '必选' : '可选' }),
+  },
+  {
+    label: '选项数量',
+    width: 120,
+    render: row => h('a', {
+      class: 'text-primary cursor-pointer hover:opacity-80',
+      onClick: () => openOptionDialog(row),
+    }, `${(row.options || []).length} 个选项`),
+  },
+  { prop: 'sortOrder', label: '排序', width: 80 },
+  {
+    label: '操作',
+    width: 220,
+    render: (row) => {
+      return h('div', { class: 'flex gap-3' }, [
+        h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          onClick: () => crudRef.value?.handleEdit(row),
+        }, '编辑'),
+        h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          onClick: () => openOptionDialog(row),
+        }, '管理选项'),
+        h('a', {
+          class: 'text-error cursor-pointer hover:opacity-80',
+          onClick: () => crudRef.value?.handleDelete(row),
+        }, '删除'),
+      ])
+    },
+  },
+])
+
+// ==================== 编辑表单配置 ====================
+const editSchema = computed(() => [
+  {
+    field: 'dishId',
+    label: '关联菜品',
+    type: 'select',
+    rules: [{ required: true, message: '请选择关联菜品', trigger: 'change', type: 'number' }],
+    props: { placeholder: '请选择菜品', filterable: true, clearable: true, options: dishOptions.value },
+  },
+  {
+    field: 'groupName',
+    label: '规格组名称',
+    type: 'input',
+    rules: [{ required: true, message: '请输入规格组名称', trigger: 'blur' }],
+    props: { placeholder: '如:熟度、酱汁、份量' },
+  },
+  {
+    field: 'required',
+    label: '是否必选',
+    type: 'radio',
+    defaultValue: 0,
+    props: {
+      options: [
+        { label: '必选', value: 1 },
+        { label: '可选', value: 0 },
+      ],
+    },
+  },
+  {
+    field: 'sortOrder',
+    label: '排序',
+    type: 'inputNumber',
+    defaultValue: 0,
+    props: { min: 0, step: 1, precision: 0, placeholder: '排序号' },
+  },
+])
+
+// ==================== 规格选项管理 ====================
+const optionDialogVisible = ref(false)
+const currentGroup = ref(null)
+const optionList = ref([])
+const optionLoading = ref(false)
+
+async function openOptionDialog(group) {
+  currentGroup.value = group
+  optionDialogVisible.value = true
+  await loadOptions()
+}
+
+async function loadOptions() {
+  if (!currentGroup.value)
+    return
+  optionLoading.value = true
+  try {
+    const res = await getDishSpecOptionList(currentGroup.value.id)
+    if (res.code === 200) {
+      optionList.value = res.data || []
+    }
+  }
+  finally {
+    optionLoading.value = false
+  }
+}
+
+// ==================== 规格选项表格列 ====================
+const optionColumns = computed(() => [
+  {
+    title: '序号',
+    width: 60,
+    render: (_, index) => h('span', {}, index + 1),
+  },
+  { title: '选项名称', key: 'optionName', minWidth: 150, align: 'center' },
+  {
+    title: '加价金额',
+    key: 'priceExtra',
+    width: 120,
+    align: 'center',
+    render: row => h('span', {}, row.priceExtra > 0 ? `+¥${row.priceExtra}` : '-'),
+  },
+  { title: '排序号', key: 'sortOrder', width: 80, align: 'center' },
+  {
+    title: '操作',
+    width: 130,
+    align: 'center',
+    render: row => h('div', { class: 'flex gap-3 justify-center' }, [
+      h('a', { class: 'text-primary cursor-pointer hover:opacity-80', onClick: () => openOptionForm('edit', row) }, '编辑'),
+      h('a', { class: 'text-error cursor-pointer hover:opacity-80', onClick: () => handleDeleteOption(row) }, '删除'),
+    ]),
+  },
+])
+
+// ==================== 规格选项表单 ====================
+const optionFormVisible = ref(false)
+const optionFormTitle = ref('')
+const optionFormLoading = ref(false)
+const optionFormRef = ref(null)
+const optionFormMode = ref('add')
+const optionForm = reactive({ id: null, groupId: null, optionName: '', priceExtra: 0, sortOrder: 0 })
+
+const optionRules = {
+  optionName: [{ required: true, message: '请输入选项名称', trigger: 'blur' }],
+}
+
+function openOptionForm(mode, row) {
+  optionFormMode.value = mode
+  if (mode === 'add') {
+    optionFormTitle.value = '新增规格选项'
+    optionForm.id = null
+    optionForm.groupId = currentGroup.value?.id
+    optionForm.optionName = ''
+    optionForm.priceExtra = 0
+    optionForm.sortOrder = 0
+  }
+  else {
+    optionFormTitle.value = '编辑规格选项'
+    optionForm.id = row.id
+    optionForm.groupId = row.groupId
+    optionForm.optionName = row.optionName
+    optionForm.priceExtra = row.priceExtra ?? 0
+    optionForm.sortOrder = row.sortOrder ?? 0
+  }
+  optionFormVisible.value = true
+}
+
+async function submitOptionForm() {
+  try {
+    await optionFormRef.value?.validate()
+  }
+  catch {
+    return
+  }
+  optionFormLoading.value = true
+  try {
+    const data = { ...optionForm }
+    let res
+    if (optionFormMode.value === 'add') {
+      res = await createDishSpecOption(data)
+    }
+    else {
+      res = await updateDishSpecOption(data)
+    }
+    if (res.code === 200) {
+      message.success(optionFormMode.value === 'add' ? '新增成功' : '修改成功')
+      optionFormVisible.value = false
+      loadOptions()
+      // 刷新规格组列表以更新选项数量
+      crudRef.value?.refresh()
+    }
+    else {
+      message.error(res.msg || '操作失败')
+    }
+  }
+  finally {
+    optionFormLoading.value = false
+  }
+}
+
+function handleDeleteOption(row) {
+  dialog.warning({
+    title: '确认删除',
+    content: `确定删除规格选项「${row.optionName}」吗?`,
+    positiveText: '确定',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      const res = await deleteDishSpecOption(row.id)
+      if (res.code === 200) {
+        message.success('删除成功')
+        loadOptions()
+        // 刷新规格组列表以更新选项数量
+        crudRef.value?.refresh()
+      }
+      else {
+        message.error(res.msg || '删除失败')
+      }
+    },
+  })
+}
+
+// ==================== 初始化 ====================
+onMounted(() => {
+  loadDishOptions()
+})
+</script>
+
+<style scoped>
+.hotel-dish-spec-page {
+  height: 100%;
+}
+</style>

+ 255 - 0
forge-server/db/migration/V1.0.101__add_hotel_dish_module_tables.sql

@@ -0,0 +1,255 @@
+-- ============================================================
+-- 酒店菜品管理模块:分类、菜品、规格组、规格选项、加料、操作日志
+-- ============================================================
+
+-- 菜品分类表
+CREATE TABLE IF NOT EXISTS `hotel_dish_category` (
+    `id`          bigint       NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`   bigint       NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `name`        varchar(64)  NOT NULL COMMENT '分类名称',
+    `icon`        varchar(32)  DEFAULT NULL COMMENT '图标(emoji)',
+    `sort_order`  int          DEFAULT 0 COMMENT '排序号',
+    `status`      varchar(16)  NOT NULL DEFAULT 'ENABLED' COMMENT '状态(ENABLED=启用, DISABLED=禁用)',
+    `del_flag`    bigint       NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`   bigint       DEFAULT NULL COMMENT '创建者',
+    `create_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept` bigint       DEFAULT NULL COMMENT '创建部门',
+    `update_by`   bigint       DEFAULT NULL COMMENT '更新者',
+    `update_time` datetime     DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_tenant_status` (`tenant_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店菜品分类表';
+
+-- 菜品表
+CREATE TABLE IF NOT EXISTS `hotel_dish` (
+    `id`             bigint        NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`      bigint        NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `name`           varchar(128)  NOT NULL COMMENT '菜品名称',
+    `category_id`    bigint        DEFAULT NULL COMMENT '分类ID',
+    `price`          decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '基础价格(元)',
+    `image`          varchar(512)  DEFAULT NULL COMMENT '主图(文件ID)',
+    `images`         text          DEFAULT NULL COMMENT '附加图片(逗号分隔fileId)',
+    `description`    varchar(512)  DEFAULT NULL COMMENT '菜品描述',
+    `tags`           varchar(255)  DEFAULT NULL COMMENT '标签(逗号分隔)',
+    `prep_time`      int           DEFAULT NULL COMMENT '预计制作时长(分钟)',
+    `sales`          int           DEFAULT 0 COMMENT '月销量',
+    `status`         varchar(16)   NOT NULL DEFAULT 'ON_SALE' COMMENT '状态(ON_SALE=在售, OFF_SHELF=下架)',
+    `sold_out`       tinyint       NOT NULL DEFAULT 0 COMMENT '是否售罄(0=正常, 1=售罄)',
+    `is_recommended` tinyint       NOT NULL DEFAULT 0 COMMENT '是否主厨推荐(0=否, 1=是)',
+    `sort_order`     int           DEFAULT 0 COMMENT '排序权重',
+    `del_flag`       bigint        NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`      bigint        DEFAULT NULL COMMENT '创建者',
+    `create_time`    datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept`    bigint        DEFAULT NULL COMMENT '创建部门',
+    `update_by`      bigint        DEFAULT NULL COMMENT '更新者',
+    `update_time`    datetime      DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_tenant_category` (`tenant_id`, `category_id`),
+    KEY `idx_tenant_status` (`tenant_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店菜品表';
+
+-- 菜品规格组表
+CREATE TABLE IF NOT EXISTS `hotel_dish_spec_group` (
+    `id`          bigint       NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`   bigint       NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `dish_id`     bigint       NOT NULL COMMENT '关联菜品ID',
+    `group_name`  varchar(64)  NOT NULL COMMENT '规格组名称(如:熟度、酱汁)',
+    `required`    tinyint      NOT NULL DEFAULT 0 COMMENT '是否必选(0=可选, 1=必选)',
+    `sort_order`  int          DEFAULT 0 COMMENT '排序号',
+    `del_flag`    bigint       NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`   bigint       DEFAULT NULL COMMENT '创建者',
+    `create_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept` bigint       DEFAULT NULL COMMENT '创建部门',
+    `update_by`   bigint       DEFAULT NULL COMMENT '更新者',
+    `update_time` datetime     DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_dish_id` (`dish_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店菜品规格组表';
+
+-- 菜品规格选项表
+CREATE TABLE IF NOT EXISTS `hotel_dish_spec_option` (
+    `id`           bigint        NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`    bigint        NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `group_id`     bigint        NOT NULL COMMENT '关联规格组ID',
+    `option_name`  varchar(64)   NOT NULL COMMENT '选项名称(如:三分熟、黑椒汁)',
+    `price_extra`  decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '加价金额(元)',
+    `sort_order`   int           DEFAULT 0 COMMENT '排序号',
+    `del_flag`     bigint        NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`    bigint        DEFAULT NULL COMMENT '创建者',
+    `create_time`  datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept`  bigint        DEFAULT NULL COMMENT '创建部门',
+    `update_by`    bigint        DEFAULT NULL COMMENT '更新者',
+    `update_time`  datetime      DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_group_id` (`group_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店菜品规格选项表';
+
+-- 菜品加料表
+CREATE TABLE IF NOT EXISTS `hotel_dish_addition` (
+    `id`          bigint        NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`   bigint        NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `dish_id`     bigint        NOT NULL COMMENT '菜品ID',
+    `name`        varchar(64)   NOT NULL COMMENT '加料名称',
+    `extra_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '加料价格(元)',
+    `sort_order`  int           DEFAULT 0 COMMENT '排序权重',
+    `del_flag`    bigint        NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`   bigint        DEFAULT NULL COMMENT '创建者',
+    `create_time` datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept` bigint        DEFAULT NULL COMMENT '创建部门',
+    `update_by`   bigint        DEFAULT NULL COMMENT '更新者',
+    `update_time` datetime      DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_dish_id` (`dish_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店菜品加料表';
+
+-- 菜品操作日志表
+CREATE TABLE IF NOT EXISTS `hotel_dish_log` (
+    `id`             bigint       NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`      bigint       NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `dish_id`        bigint       DEFAULT NULL COMMENT '菜品ID',
+    `dish_name`      varchar(128) DEFAULT NULL COMMENT '菜品名称(冗余)',
+    `operation_type` varchar(32)  NOT NULL COMMENT '操作类型(CREATE/EDIT/DELETE/ON_SALE/OFF_SHELF/SOLD_OUT/RESTORE)',
+    `old_value`      text         DEFAULT NULL COMMENT '变更前值(JSON)',
+    `new_value`      text         DEFAULT NULL COMMENT '变更后值(JSON)',
+    `remark`         varchar(255) DEFAULT NULL COMMENT '备注',
+    `create_by`      bigint       DEFAULT NULL COMMENT '创建者',
+    `create_time`    datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept`    bigint       DEFAULT NULL COMMENT '创建部门',
+    `update_by`      bigint       DEFAULT NULL COMMENT '更新者',
+    `update_time`    datetime     DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_dish_id` (`dish_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店菜品操作日志表';
+
+-- ============================================================
+-- 字典数据
+-- ============================================================
+
+-- 菜品状态字典类型
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '菜品状态', 'hotel_dish_status', 1, '酒店菜品状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_dish_status' AND tenant_id = 1);
+
+-- 菜品状态字典数据
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '在售', 'ON_SALE', 'hotel_dish_status', NULL, 'success', 'N', 1, '在售状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_status' AND dict_value = 'ON_SALE' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '下架', 'OFF_SHELF', 'hotel_dish_status', NULL, 'default', 'N', 1, '下架状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_status' AND dict_value = 'OFF_SHELF' AND tenant_id = 1);
+
+-- 分类状态字典类型
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '分类状态', 'hotel_category_status', 1, '酒店菜品分类状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_category_status' AND tenant_id = 1);
+
+-- 分类状态字典数据
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '启用', 'ENABLED', 'hotel_category_status', NULL, 'success', 'N', 1, '启用状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_category_status' AND dict_value = 'ENABLED' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '禁用', 'DISABLED', 'hotel_category_status', NULL, 'default', 'N', 1, '禁用状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_category_status' AND dict_value = 'DISABLED' AND tenant_id = 1);
+
+-- 菜品操作日志类型字典
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '菜品操作类型', 'hotel_dish_operation_type', 1, '酒店菜品操作日志类型', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_dish_operation_type' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '新增', 'CREATE', 'hotel_dish_operation_type', NULL, 'success', 'N', 1, '新增菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'CREATE' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '编辑', 'EDIT', 'hotel_dish_operation_type', NULL, 'primary', 'N', 1, '编辑菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'EDIT' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '删除', 'DELETE', 'hotel_dish_operation_type', NULL, 'error', 'N', 1, '删除菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'DELETE' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 4, '上架', 'ON_SALE', 'hotel_dish_operation_type', NULL, 'success', 'N', 1, '上架菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'ON_SALE' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 5, '下架', 'OFF_SHELF', 'hotel_dish_operation_type', NULL, 'warning', 'N', 1, '下架菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'OFF_SHELF' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 6, '售罄', 'SOLD_OUT', 'hotel_dish_operation_type', NULL, 'error', 'N', 1, '标记售罄', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'SOLD_OUT' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 7, '恢复', 'RESTORE', 'hotel_dish_operation_type', NULL, 'success', 'N', 1, '恢复上架', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_dish_operation_type' AND dict_value = 'RESTORE' AND tenant_id = 1);
+
+-- ============================================================
+-- 菜单资源(sys_resource)
+-- ============================================================
+
+-- 菜品管理菜单(挂在酒店管理下,如果酒店管理不存在则创建为一级目录)
+-- 先查找是否已有酒店管理目录
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 如果没有酒店管理目录,创建一个
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '酒店管理', 0, 1, 70, '/hotel', NULL, 0, 0, 1, 1, 'mdi:hotel', 0, 1, '酒店管理目录', NOW(), NOW()
+WHERE @hotel_parent_id IS NULL;
+
+SET @hotel_parent_id = IFNULL(
+    (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1),
+    LAST_INSERT_ID()
+);
+
+-- 菜品管理菜单
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '菜品管理', @hotel_parent_id, 2, 20, '/hotel/dish', 'hotel/dish', 0, 0, 1, 1, 'mdi:food', 0, 0, '菜品管理菜单', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '菜品管理' AND parent_id = @hotel_parent_id);
+
+SET @dish_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '菜品管理' AND parent_id = @hotel_parent_id LIMIT 1);
+
+-- 菜品管理按钮权限
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '菜品查询', @dish_menu_id, 3, 1, 'hotel:dish:query', 0, 0, 1, 1, '查询菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dish:query' AND parent_id = @dish_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '菜品新增', @dish_menu_id, 3, 2, 'hotel:dish:add', 0, 0, 1, 1, '新增菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dish:add' AND parent_id = @dish_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '菜品编辑', @dish_menu_id, 3, 3, 'hotel:dish:edit', 0, 0, 1, 1, '编辑菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dish:edit' AND parent_id = @dish_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '菜品删除', @dish_menu_id, 3, 4, 'hotel:dish:delete', 0, 0, 1, 1, '删除菜品', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dish:delete' AND parent_id = @dish_menu_id);
+
+-- 菜品分类菜单
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '菜品分类', @hotel_parent_id, 2, 21, '/hotel/dishCategory', 'hotel/dishCategory', 0, 0, 1, 1, 'mdi:food-apple', 0, 0, '菜品分类菜单', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '菜品分类' AND parent_id = @hotel_parent_id);
+
+SET @category_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '菜品分类' AND parent_id = @hotel_parent_id LIMIT 1);
+
+-- 菜品分类按钮权限
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '分类查询', @category_menu_id, 3, 1, 'hotel:dishCategory:query', 0, 0, 1, 1, '查询分类', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishCategory:query' AND parent_id = @category_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '分类新增', @category_menu_id, 3, 2, 'hotel:dishCategory:add', 0, 0, 1, 1, '新增分类', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishCategory:add' AND parent_id = @category_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '分类编辑', @category_menu_id, 3, 3, 'hotel:dishCategory:edit', 0, 0, 1, 1, '编辑分类', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishCategory:edit' AND parent_id = @category_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '分类删除', @category_menu_id, 3, 4, 'hotel:dishCategory:delete', 0, 0, 1, 1, '删除分类', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishCategory:delete' AND parent_id = @category_menu_id);
+
+

+ 44 - 0
forge-server/db/migration/V1.0.102__add_hotel_dish_spec_menu.sql

@@ -0,0 +1,44 @@
+-- ============================================================
+-- 酒店菜品规格管理菜单资源
+-- ============================================================
+
+-- 查找酒店管理目录
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 如果酒店管理目录不存在,创建
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '酒店管理', 0, 1, 70, '/hotel', NULL, 0, 0, 1, 1, 'mdi:hotel', 0, 1, '酒店管理目录', NOW(), NOW()
+WHERE @hotel_parent_id IS NULL;
+
+SET @hotel_parent_id = IFNULL(
+    (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1),
+    LAST_INSERT_ID()
+);
+
+-- ============================================================
+-- 菜品规格管理菜单
+-- ============================================================
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '菜品规格', @hotel_parent_id, 2, 22, '/hotel/dishSpec', 'hotel/dishSpec', 0, 0, 1, 1, 'mdi:format-list-bulleted', 0, 0, '菜品规格管理菜单', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '菜品规格' AND parent_id = @hotel_parent_id);
+
+SET @spec_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '菜品规格' AND parent_id = @hotel_parent_id LIMIT 1);
+
+-- 菜品规格按钮权限
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '规格查询', @spec_menu_id, 3, 1, 'hotel:dishSpec:query', 0, 0, 1, 1, '查询规格', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishSpec:query' AND parent_id = @spec_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '规格新增', @spec_menu_id, 3, 2, 'hotel:dishSpec:add', 0, 0, 1, 1, '新增规格', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishSpec:add' AND parent_id = @spec_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '规格编辑', @spec_menu_id, 3, 3, 'hotel:dishSpec:edit', 0, 0, 1, 1, '编辑规格', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishSpec:edit' AND parent_id = @spec_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '规格删除', @spec_menu_id, 3, 4, 'hotel:dishSpec:delete', 0, 0, 1, 1, '删除规格', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dishSpec:delete' AND parent_id = @spec_menu_id);

+ 104 - 0
forge-server/db/migration/V1.0.103__add_hotel_business_hours.sql

@@ -0,0 +1,104 @@
+-- ============================================================
+-- 酒店营业时段管理:建表 + 菜单资源
+-- ============================================================
+
+-- 营业时段表
+CREATE TABLE IF NOT EXISTS `hotel_business_hours` (
+    `id`          bigint       NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`   bigint       NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `day_of_week` int          DEFAULT NULL COMMENT '星期几(0=周日,1=周一,...,6=周六,NULL=每天)',
+    `meal_type`   varchar(16)  NOT NULL COMMENT '餐段标识(breakfast/lunch/dinner/night)',
+    `meal_name`   varchar(32)  NOT NULL COMMENT '餐段名称(如:早餐、午餐、晚餐、夜宵)',
+    `start_time`  varchar(8)   NOT NULL COMMENT '开始时间(如:08:00)',
+    `end_time`    varchar(8)   NOT NULL COMMENT '结束时间(如:10:00)',
+    `status`      varchar(16)  NOT NULL DEFAULT 'ENABLED' COMMENT '状态(ENABLED=启用, DISABLED=禁用)',
+    `sort_order`  int          DEFAULT 0 COMMENT '排序号',
+    `del_flag`    bigint       NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`   bigint       DEFAULT NULL COMMENT '创建者',
+    `create_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept` bigint       DEFAULT NULL COMMENT '创建部门',
+    `update_by`   bigint       DEFAULT NULL COMMENT '更新者',
+    `update_time` datetime     DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_tenant_status` (`tenant_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店营业时段表';
+
+-- ============================================================
+-- 字典数据
+-- ============================================================
+
+-- 餐段类型字典类型
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '餐段类型', 'hotel_meal_type', 1, '酒店餐段类型', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_meal_type' AND tenant_id = 1);
+
+-- 餐段类型字典数据
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '早餐', 'breakfast', 'hotel_meal_type', NULL, 'success', 'N', 1, '早餐时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_meal_type' AND dict_value = 'breakfast' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '午餐', 'lunch', 'hotel_meal_type', NULL, 'primary', 'N', 1, '午餐时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_meal_type' AND dict_value = 'lunch' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '晚餐', 'dinner', 'hotel_meal_type', NULL, 'warning', 'N', 1, '晚餐时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_meal_type' AND dict_value = 'dinner' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 4, '夜宵', 'night', 'hotel_meal_type', NULL, 'info', 'N', 1, '夜宵时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_meal_type' AND dict_value = 'night' AND tenant_id = 1);
+
+-- 营业时段状态字典类型
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '营业时段状态', 'hotel_business_hours_status', 1, '酒店营业时段状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_business_hours_status' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '启用', 'ENABLED', 'hotel_business_hours_status', NULL, 'success', 'N', 1, '启用状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_business_hours_status' AND dict_value = 'ENABLED' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '禁用', 'DISABLED', 'hotel_business_hours_status', NULL, 'default', 'N', 1, '禁用状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_business_hours_status' AND dict_value = 'DISABLED' AND tenant_id = 1);
+
+-- ============================================================
+-- 菜单资源(sys_resource)
+-- ============================================================
+
+-- 查找酒店管理目录
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 如果酒店管理目录不存在,创建
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '酒店管理', 0, 1, 70, '/hotel', NULL, 0, 0, 1, 1, 'mdi:hotel', 0, 1, '酒店管理目录', NOW(), NOW()
+WHERE @hotel_parent_id IS NULL;
+
+SET @hotel_parent_id = IFNULL(
+    (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1),
+    LAST_INSERT_ID()
+);
+
+-- 营业时段管理菜单
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '营业时段', @hotel_parent_id, 2, 23, '/hotel/businessHours', 'hotel/businessHours', 0, 0, 1, 1, 'mdi:clock-outline', 0, 0, '营业时段管理菜单', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '营业时段' AND parent_id = @hotel_parent_id);
+
+SET @hours_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '营业时段' AND parent_id = @hotel_parent_id LIMIT 1);
+
+-- 营业时段按钮权限
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '时段查询', @hours_menu_id, 3, 1, 'hotel:businessHours:query', 0, 0, 1, 1, '查询营业时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:businessHours:query' AND parent_id = @hours_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '时段新增', @hours_menu_id, 3, 2, 'hotel:businessHours:add', 0, 0, 1, 1, '新增营业时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:businessHours:add' AND parent_id = @hours_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '时段编辑', @hours_menu_id, 3, 3, 'hotel:businessHours:edit', 0, 0, 1, 1, '编辑营业时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:businessHours:edit' AND parent_id = @hours_menu_id);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '时段删除', @hours_menu_id, 3, 4, 'hotel:businessHours:delete', 0, 0, 1, 1, '删除营业时段', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:businessHours:delete' AND parent_id = @hours_menu_id);

+ 109 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelBusinessHoursController.java

@@ -0,0 +1,109 @@
+package com.mdframe.forge.business.core.hotel.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
+import com.mdframe.forge.business.core.hotel.service.HotelBusinessHoursService;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 酒店营业时段管理接口。
+ */
+@RestController
+@RequestMapping("/hotel/business-hours")
+public class HotelBusinessHoursController {
+
+    @Autowired
+    private HotelBusinessHoursService businessHoursService;
+
+    /**
+     * 分页查询营业时段。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "营业时段", type = OperationType.QUERY, desc = "分页查询营业时段")
+    public RespInfo<IPage<HotelBusinessHours>> businessHoursPage(PageQuery pageQuery,
+                                                                  HotelBusinessHours query) {
+        return RespInfo.success(businessHoursService.businessHoursPage(pageQuery, query));
+    }
+
+    /**
+     * 查询所有营业时段(含禁用)。
+     */
+    @GetMapping("/all")
+    @OperationLog(module = "营业时段", type = OperationType.QUERY, desc = "查询全部营业时段")
+    public RespInfo<List<HotelBusinessHours>> businessHoursAll() {
+        return RespInfo.success(businessHoursService.businessHoursAll());
+    }
+
+    /**
+     * 查询所有启用的营业时段(顾客端用)。
+     */
+    @GetMapping("/enabled")
+    @OperationLog(module = "营业时段", type = OperationType.QUERY, desc = "查询启用的营业时段")
+    public RespInfo<List<HotelBusinessHours>> businessHoursEnabled() {
+        return RespInfo.success(businessHoursService.businessHoursEnabled());
+    }
+
+    /**
+     * 检查当前是否在营业时段内(顾客端用)。
+     */
+    @GetMapping("/check-current")
+    @OperationLog(module = "营业时段", type = OperationType.QUERY, desc = "检查当前营业状态")
+    public RespInfo<HotelBusinessHours> checkCurrentBusinessHours() {
+        return RespInfo.success(businessHoursService.checkCurrentBusinessHours());
+    }
+
+    /**
+     * 查询营业时段详情。
+     */
+    @GetMapping("/{id}")
+    @OperationLog(module = "营业时段", type = OperationType.QUERY, desc = "查询营业时段详情")
+    public RespInfo<HotelBusinessHours> businessHoursDetail(@PathVariable Long id) {
+        return RespInfo.success(businessHoursService.businessHoursDetail(id));
+    }
+
+    /**
+     * 新增营业时段。
+     */
+    @PostMapping
+    @OperationLog(module = "营业时段", type = OperationType.ADD, desc = "新增营业时段")
+    public RespInfo<Long> businessHoursCreate(@RequestBody HotelBusinessHours businessHours) {
+        return RespInfo.success(businessHoursService.businessHoursCreate(businessHours));
+    }
+
+    /**
+     * 修改营业时段。
+     */
+    @PutMapping
+    @OperationLog(module = "营业时段", type = OperationType.UPDATE, desc = "修改营业时段")
+    public RespInfo<Void> businessHoursUpdate(@RequestBody HotelBusinessHours businessHours) {
+        businessHoursService.businessHoursUpdate(businessHours);
+        return RespInfo.success();
+    }
+
+    /**
+     * 切换营业时段状态(启用/禁用)。
+     */
+    @PostMapping("/{id}/toggle")
+    @OperationLog(module = "营业时段", type = OperationType.UPDATE, desc = "切换营业时段状态")
+    public RespInfo<Void> businessHoursToggle(@PathVariable Long id) {
+        businessHoursService.businessHoursToggle(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 删除营业时段。
+     */
+    @PostMapping("/remove/{id}")
+    @OperationLog(module = "营业时段", type = OperationType.DELETE, desc = "删除营业时段")
+    public RespInfo<Void> businessHoursDelete(@PathVariable Long id) {
+        businessHoursService.businessHoursDelete(id);
+        return RespInfo.success();
+    }
+}

+ 89 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishCategoryController.java

@@ -0,0 +1,89 @@
+package com.mdframe.forge.business.core.hotel.controller;
+
+import com.mdframe.forge.business.core.hotel.dto.HotelDishCategoryDTO;
+import com.mdframe.forge.business.core.hotel.service.HotelDishCategoryService;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 酒店菜品分类管理接口。
+ */
+@RestController
+@RequestMapping("/hotel/dish-category")
+public class HotelDishCategoryController {
+
+    @Autowired
+    private HotelDishCategoryService hotelDishCategoryService;
+
+    /**
+     * 查询全部分类(带菜品数量)。
+     */
+    @GetMapping("/list")
+    @OperationLog(module = "菜品分类", type = OperationType.QUERY, desc = "查询分类列表")
+    public RespInfo<List<HotelDishCategoryVO>> categoryList() {
+        return RespInfo.success(hotelDishCategoryService.categoryList());
+    }
+
+    /**
+     * 查询所有启用的分类(下拉用)。
+     */
+    @GetMapping("/enabled")
+    @OperationLog(module = "菜品分类", type = OperationType.QUERY, desc = "查询启用分类")
+    public RespInfo<List<HotelDishCategoryVO>> categoryEnabledList() {
+        return RespInfo.success(hotelDishCategoryService.categoryEnabledList());
+    }
+
+    /**
+     * 查询分类详情。
+     */
+    @GetMapping("/{id}")
+    @OperationLog(module = "菜品分类", type = OperationType.QUERY, desc = "查询分类详情")
+    public RespInfo<HotelDishCategoryVO> categoryDetail(@PathVariable Long id) {
+        return RespInfo.success(hotelDishCategoryService.categoryDetail(id));
+    }
+
+    /**
+     * 新增分类。
+     */
+    @PostMapping
+    @OperationLog(module = "菜品分类", type = OperationType.ADD, desc = "新增分类")
+    public RespInfo<Long> categoryCreate(@RequestBody HotelDishCategoryDTO dto) {
+        return RespInfo.success(hotelDishCategoryService.categoryCreate(dto));
+    }
+
+    /**
+     * 修改分类。
+     */
+    @PutMapping
+    @OperationLog(module = "菜品分类", type = OperationType.UPDATE, desc = "修改分类")
+    public RespInfo<Void> categoryUpdate(@RequestBody HotelDishCategoryDTO dto) {
+        hotelDishCategoryService.categoryUpdate(dto);
+        return RespInfo.success();
+    }
+
+    /**
+     * 删除分类。
+     */
+    @PostMapping("/remove/{id}")
+    @OperationLog(module = "菜品分类", type = OperationType.DELETE, desc = "删除分类")
+    public RespInfo<Void> categoryDelete(@PathVariable Long id) {
+        hotelDishCategoryService.categoryDelete(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 切换分类状态(启用/禁用)。
+     */
+    @PostMapping("/{id}/toggle-status")
+    @OperationLog(module = "菜品分类", type = OperationType.UPDATE, desc = "切换分类状态")
+    public RespInfo<Void> categoryToggleStatus(@PathVariable Long id) {
+        hotelDishCategoryService.categoryToggleStatus(id);
+        return RespInfo.success();
+    }
+}

+ 123 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishController.java

@@ -0,0 +1,123 @@
+package com.mdframe.forge.business.core.hotel.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelDish;
+import com.mdframe.forge.business.core.hotel.dto.DishBatchOperationDTO;
+import com.mdframe.forge.business.core.hotel.dto.HotelDishDTO;
+import com.mdframe.forge.business.core.hotel.service.HotelDishService;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 酒店菜品管理接口。
+ */
+@RestController
+@RequestMapping("/hotel/dish")
+public class HotelDishController {
+
+    @Autowired
+    private HotelDishService hotelDishService;
+
+    /**
+     * 管理后台分页查询菜品。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "酒店菜品", type = OperationType.QUERY, desc = "分页查询菜品")
+    public RespInfo<IPage<HotelDishVO>> dishPage(PageQuery pageQuery, HotelDish query) {
+        return RespInfo.success(hotelDishService.dishPage(pageQuery, query));
+    }
+
+    /**
+     * 查询菜品详情(含规格和加料)。
+     */
+    @GetMapping("/{id}")
+    @OperationLog(module = "酒店菜品", type = OperationType.QUERY, desc = "查询菜品详情")
+    public RespInfo<HotelDishVO> dishDetail(@PathVariable Long id) {
+        return RespInfo.success(hotelDishService.dishDetail(id));
+    }
+
+    /**
+     * 新增菜品。
+     */
+    @PostMapping
+    @OperationLog(module = "酒店菜品", type = OperationType.ADD, desc = "新增菜品")
+    public RespInfo<Long> dishCreate(@RequestBody HotelDishDTO dto) {
+        return RespInfo.success(hotelDishService.dishCreate(dto));
+    }
+
+    /**
+     * 修改菜品。
+     */
+    @PutMapping
+    @OperationLog(module = "酒店菜品", type = OperationType.UPDATE, desc = "修改菜品")
+    public RespInfo<Void> dishUpdate(@RequestBody HotelDishDTO dto) {
+        hotelDishService.dishUpdate(dto);
+        return RespInfo.success();
+    }
+
+    /**
+     * 删除菜品(含级联删除规格和加料)。
+     */
+    @PostMapping("/remove/{id}")
+    @OperationLog(module = "酒店菜品", type = OperationType.DELETE, desc = "删除菜品")
+    public RespInfo<Void> dishDelete(@PathVariable Long id) {
+        hotelDishService.dishDelete(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 更新菜品状态(上架/下架)。
+     */
+    @PutMapping("/{id}/status")
+    @OperationLog(module = "酒店菜品", type = OperationType.UPDATE, desc = "更新菜品状态")
+    public RespInfo<Void> dishUpdateStatus(@PathVariable Long id, @RequestParam String status) {
+        hotelDishService.dishUpdateStatus(id, status);
+        return RespInfo.success();
+    }
+
+    /**
+     * 标记售罄。
+     */
+    @PostMapping("/{id}/sold-out")
+    @OperationLog(module = "酒店菜品", type = OperationType.UPDATE, desc = "标记售罄")
+    public RespInfo<Void> dishMarkSoldOut(@PathVariable Long id) {
+        hotelDishService.dishMarkSoldOut(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 恢复上架(取消售罄)。
+     */
+    @PostMapping("/{id}/restore")
+    @OperationLog(module = "酒店菜品", type = OperationType.UPDATE, desc = "恢复上架")
+    public RespInfo<Void> dishRestore(@PathVariable Long id) {
+        hotelDishService.dishRestore(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 批量操作(上架/下架/售罄/恢复/删除)。
+     */
+    @PostMapping("/batch")
+    @OperationLog(module = "酒店菜品", type = OperationType.UPDATE, desc = "批量操作菜品")
+    public RespInfo<Void> dishBatchOperate(@RequestBody DishBatchOperationDTO dto) {
+        hotelDishService.dishBatchOperate(dto);
+        return RespInfo.success();
+    }
+
+    /**
+     * 销量排行。
+     */
+    @GetMapping("/ranking")
+    @OperationLog(module = "酒店菜品", type = OperationType.QUERY, desc = "销量排行")
+    public RespInfo<List<HotelDishVO>> dishSalesRanking(@RequestParam(defaultValue = "10") int limit) {
+        return RespInfo.success(hotelDishService.dishSalesRanking(limit));
+    }
+}

+ 136 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishSpecController.java

@@ -0,0 +1,136 @@
+package com.mdframe.forge.business.core.hotel.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecGroup;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
+import com.mdframe.forge.business.core.hotel.service.HotelDishSpecGroupService;
+import com.mdframe.forge.business.core.hotel.service.HotelDishSpecOptionService;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 酒店菜品规格管理接口(规格组 + 规格选项)。
+ */
+@RestController
+@RequestMapping("/hotel/dish-spec")
+public class HotelDishSpecController {
+
+    @Autowired
+    private HotelDishSpecGroupService specGroupService;
+    @Autowired
+    private HotelDishSpecOptionService specOptionService;
+
+    // ==================== 规格组接口 ====================
+
+    /**
+     * 分页查询规格组(含菜品名称和选项列表)。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "菜品规格", type = OperationType.QUERY, desc = "分页查询规格组")
+    public RespInfo<IPage<HotelDishSpecGroup>> specGroupPage(PageQuery pageQuery,
+                                                              HotelDishSpecGroup query) {
+        return RespInfo.success(specGroupService.specGroupPage(pageQuery, query));
+    }
+
+    /**
+     * 根据菜品ID查询所有规格组(含选项列表,顾客端点餐使用)。
+     */
+    @GetMapping("/by-dish/{dishId}")
+    @OperationLog(module = "菜品规格", type = OperationType.QUERY, desc = "按菜品查询规格组")
+    public RespInfo<List<HotelDishSpecGroup>> specGroupByDishId(@PathVariable Long dishId) {
+        return RespInfo.success(specGroupService.findByDishId(dishId));
+    }
+
+    /**
+     * 查询规格组详情。
+     */
+    @GetMapping("/{id}")
+    @OperationLog(module = "菜品规格", type = OperationType.QUERY, desc = "查询规格组详情")
+    public RespInfo<HotelDishSpecGroup> specGroupDetail(@PathVariable Long id) {
+        return RespInfo.success(specGroupService.specGroupDetail(id));
+    }
+
+    /**
+     * 新增规格组。
+     */
+    @PostMapping
+    @OperationLog(module = "菜品规格", type = OperationType.ADD, desc = "新增规格组")
+    public RespInfo<Long> specGroupCreate(@RequestBody HotelDishSpecGroup specGroup) {
+        return RespInfo.success(specGroupService.specGroupCreate(specGroup));
+    }
+
+    /**
+     * 修改规格组。
+     */
+    @PutMapping
+    @OperationLog(module = "菜品规格", type = OperationType.UPDATE, desc = "修改规格组")
+    public RespInfo<Void> specGroupUpdate(@RequestBody HotelDishSpecGroup specGroup) {
+        specGroupService.specGroupUpdate(specGroup);
+        return RespInfo.success();
+    }
+
+    /**
+     * 删除规格组(级联删除其下所有选项)。
+     */
+    @PostMapping("/remove/{id}")
+    @OperationLog(module = "菜品规格", type = OperationType.DELETE, desc = "删除规格组")
+    public RespInfo<Void> specGroupDelete(@PathVariable Long id) {
+        specGroupService.specGroupDelete(id);
+        return RespInfo.success();
+    }
+
+    // ==================== 规格选项接口 ====================
+
+    /**
+     * 根据规格组ID查询所有选项。
+     */
+    @GetMapping("/option/list/{groupId}")
+    @OperationLog(module = "菜品规格", type = OperationType.QUERY, desc = "查询规格选项列表")
+    public RespInfo<List<HotelDishSpecOption>> optionListByGroupId(@PathVariable Long groupId) {
+        return RespInfo.success(specOptionService.findByGroupId(groupId));
+    }
+
+    /**
+     * 查询规格选项详情。
+     */
+    @GetMapping("/option/{id}")
+    @OperationLog(module = "菜品规格", type = OperationType.QUERY, desc = "查询规格选项详情")
+    public RespInfo<HotelDishSpecOption> optionDetail(@PathVariable Long id) {
+        return RespInfo.success(specOptionService.specOptionDetail(id));
+    }
+
+    /**
+     * 新增规格选项。
+     */
+    @PostMapping("/option")
+    @OperationLog(module = "菜品规格", type = OperationType.ADD, desc = "新增规格选项")
+    public RespInfo<Long> optionCreate(@RequestBody HotelDishSpecOption specOption) {
+        return RespInfo.success(specOptionService.specOptionCreate(specOption));
+    }
+
+    /**
+     * 修改规格选项。
+     */
+    @PutMapping("/option")
+    @OperationLog(module = "菜品规格", type = OperationType.UPDATE, desc = "修改规格选项")
+    public RespInfo<Void> optionUpdate(@RequestBody HotelDishSpecOption specOption) {
+        specOptionService.specOptionUpdate(specOption);
+        return RespInfo.success();
+    }
+
+    /**
+     * 删除规格选项。
+     */
+    @PostMapping("/option/remove/{id}")
+    @OperationLog(module = "菜品规格", type = OperationType.DELETE, desc = "删除规格选项")
+    public RespInfo<Void> optionDelete(@PathVariable Long id) {
+        specOptionService.specOptionDelete(id);
+        return RespInfo.success();
+    }
+}

+ 6 - 8
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelQrCodeController.java

@@ -1,6 +1,7 @@
 package com.mdframe.forge.business.core.hotel.controller;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoom;
 import com.mdframe.forge.business.core.hotel.dto.HotelRoomDTO;
 import com.mdframe.forge.business.core.hotel.dto.HotelRoomTypeDTO;
 import com.mdframe.forge.business.core.hotel.dto.QrCodeBatchGenerateDTO;
@@ -16,7 +17,7 @@ import com.mdframe.forge.starter.core.domain.OperationType;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
 import jakarta.servlet.http.HttpServletResponse;
-import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -33,10 +34,10 @@ import java.util.List;
  */
 @RestController
 @RequestMapping("/hotel")
-@RequiredArgsConstructor
 public class HotelQrCodeController {
 
-    private final HotelQrCodeService hotelQrCodeService;
+    @Autowired
+    private HotelQrCodeService hotelQrCodeService;
 
     // ==================== 二维码管理 ====================
 
@@ -201,11 +202,8 @@ public class HotelQrCodeController {
      */
     @GetMapping("/room/page")
     @OperationLog(module = "酒店房间", type = OperationType.QUERY, desc = "分页查询房间")
-    public RespInfo<IPage<HotelRoomVO>> roomPage(PageQuery pageQuery,
-                                                  @RequestParam(required = false) String roomNo,
-                                                  @RequestParam(required = false) String status,
-                                                  @RequestParam(required = false) Long roomTypeId) {
-        return RespInfo.success(hotelQrCodeService.roomPage(pageQuery, roomNo, status, roomTypeId));
+    public RespInfo<IPage<HotelRoomVO>> roomPage(PageQuery pageQuery, HotelRoom query) {
+        return RespInfo.success(hotelQrCodeService.roomPage(pageQuery, query));
     }
 
     /**

+ 3 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelOpenController.java

@@ -3,7 +3,7 @@ package com.mdframe.forge.business.core.hotel.controller.open;
 import com.mdframe.forge.business.core.hotel.service.HotelQrCodeService;
 import com.mdframe.forge.business.core.hotel.vo.QrCodeScanVO;
 import com.mdframe.forge.starter.core.domain.RespInfo;
-import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -17,10 +17,10 @@ import org.springframework.web.bind.annotation.RestController;
  */
 @RestController
 @RequestMapping("/hotel/open")
-@RequiredArgsConstructor
 public class HotelOpenController {
 
-    private final HotelQrCodeService hotelQrCodeService;
+    @Autowired
+    private HotelQrCodeService hotelQrCodeService;
 
     /**
      * 扫码查询二维码信息。

+ 52 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelBusinessHours.java

@@ -0,0 +1,52 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+
+/**
+ * 酒店营业时段实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_business_hours")
+public class HotelBusinessHours extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 星期几(0=周日,1=周一,...,6=周六,NULL=每天) */
+    private Integer dayOfWeek;
+
+    /** 餐段标识(breakfast/lunch/dinner/night) */
+    private String mealType;
+
+    /** 餐段名称(如:早餐、午餐、晚餐、夜宵) */
+    private String mealName;
+
+    /** 开始时间(如:08:00) */
+    private String startTime;
+
+    /** 结束时间(如:10:00) */
+    private String endTime;
+
+    /** 状态: ENABLED-启用, DISABLED-禁用 */
+    private String status;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 71 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDish.java

@@ -0,0 +1,71 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+
+/**
+ * 酒店菜品实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_dish")
+public class HotelDish extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 菜品名称 */
+    private String name;
+
+    /** 分类ID */
+    private Long categoryId;
+
+    /** 基础价格(元) */
+    private BigDecimal price;
+
+    /** 主图(文件ID) */
+    private String image;
+
+    /** 附加图片(逗号分隔fileId) */
+    private String images;
+
+    /** 菜品描述 */
+    private String description;
+
+    /** 标签(逗号分隔) */
+    private String tags;
+
+    /** 预计制作时长(分钟) */
+    private Integer prepTime;
+
+    /** 月销量 */
+    private Integer sales;
+
+    /** 状态:ON_SALE-在售, OFF_SHELF-下架 */
+    private String status;
+
+    /** 是否售罄(0=正常, 1=售罄) */
+    private Integer soldOut;
+
+    /** 是否主厨推荐(0=否, 1=是) */
+    private Integer isRecommended;
+
+    /** 排序权重 */
+    private Integer sortOrder;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 44 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishAddition.java

@@ -0,0 +1,44 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+
+/**
+ * 酒店菜品加料实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_dish_addition")
+public class HotelDishAddition extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 菜品ID */
+    private Long dishId;
+
+    /** 加料名称 */
+    private String name;
+
+    /** 加料价格(元) */
+    private BigDecimal extraPrice;
+
+    /** 排序权重 */
+    private Integer sortOrder;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 43 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishCategory.java

@@ -0,0 +1,43 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+
+/**
+ * 酒店菜品分类实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_dish_category")
+public class HotelDishCategory extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 分类名称 */
+    private String name;
+
+    /** 图标(emoji) */
+    private String icon;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 状态:ENABLED-启用, DISABLED-禁用 */
+    private String status;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 44 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishLog.java

@@ -0,0 +1,44 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+
+/**
+ * 酒店菜品操作日志实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_dish_log")
+public class HotelDishLog extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 菜品ID */
+    private Long dishId;
+
+    /** 菜品名称(冗余) */
+    private String dishName;
+
+    /** 操作类型: CREATE/EDIT/DELETE/ON_SALE/OFF_SHELF/SOLD_OUT/RESTORE */
+    private String operationType;
+
+    /** 变更前值(JSON) */
+    private String oldValue;
+
+    /** 变更后值(JSON) */
+    private String newValue;
+
+    /** 备注 */
+    private String remark;
+}

+ 55 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishSpecGroup.java

@@ -0,0 +1,55 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.util.List;
+
+/**
+ * 酒店菜品规格组实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_dish_spec_group")
+public class HotelDishSpecGroup extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 关联菜品ID */
+    private Long dishId;
+
+    /** 规格组名称(如:熟度、酱汁) */
+    private String groupName;
+
+    /** 是否必选(0=可选, 1=必选) */
+    private Integer required;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+
+    // ==================== 非DB字段 ====================
+
+    /** 关联菜品名称(查询时填充) */
+    @TableField(exist = false)
+    private String dishName;
+
+    /** 规格选项列表(查询时填充) */
+    @TableField(exist = false)
+    private List<HotelDishSpecOption> options;
+}

+ 44 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishSpecOption.java

@@ -0,0 +1,44 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+
+/**
+ * 酒店菜品规格选项实体。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_dish_spec_option")
+public class HotelDishSpecOption extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 关联规格组ID */
+    private Long groupId;
+
+    /** 选项名称(如:三分熟、黑椒汁) */
+    private String optionName;
+
+    /** 加价金额(元) */
+    private BigDecimal priceExtra;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 23 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/DishBatchOperationDTO.java

@@ -0,0 +1,23 @@
+package com.mdframe.forge.business.core.hotel.dto;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 菜品批量操作DTO。
+ */
+@Data
+public class DishBatchOperationDTO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 菜品ID列表 */
+    private List<Long> ids;
+
+    /** 操作类型: ON_SALE-上架, OFF_SHELF-下架, SOLD_OUT-售罄, RESTORE-恢复上架, DELETE-删除 */
+    private String operation;
+}

+ 31 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/HotelDishCategoryDTO.java

@@ -0,0 +1,31 @@
+package com.mdframe.forge.business.core.hotel.dto;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 菜品分类请求DTO。
+ */
+@Data
+public class HotelDishCategoryDTO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 分类ID(修改时必填,新增时留空) */
+    private Long id;
+
+    /** 分类名称 */
+    private String name;
+
+    /** 图标(emoji) */
+    private String icon;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 状态:ENABLED-启用, DISABLED-禁用 */
+    private String status;
+}

+ 117 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/HotelDishDTO.java

@@ -0,0 +1,117 @@
+package com.mdframe.forge.business.core.hotel.dto;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 菜品请求DTO。
+ */
+@Data
+public class HotelDishDTO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 菜品ID(修改时必填,新增时留空) */
+    private Long id;
+
+    /** 菜品名称 */
+    private String name;
+
+    /** 分类ID */
+    private Long categoryId;
+
+    /** 基础价格(元) */
+    private BigDecimal price;
+
+    /** 主图(文件ID) */
+    private String image;
+
+    /** 附加图片(逗号分隔fileId) */
+    private String images;
+
+    /** 菜品描述 */
+    private String description;
+
+    /** 标签(逗号分隔) */
+    private String tags;
+
+    /** 预计制作时长(分钟) */
+    private Integer prepTime;
+
+    /** 状态:ON_SALE-在售, OFF_SHELF-下架 */
+    private String status;
+
+    /** 是否售罄(0=正常, 1=售罄) */
+    private Integer soldOut;
+
+    /** 是否主厨推荐(0=否, 1=是) */
+    private Integer isRecommended;
+
+    /** 排序权重 */
+    private Integer sortOrder;
+
+    /** 规格组列表(新增/编辑时一起提交) */
+    private List<SpecGroupDTO> specGroups;
+
+    /** 加料列表(新增/编辑时一起提交) */
+    private List<AdditionDTO> additions;
+
+    // ====== 查询条件字段 ======
+
+    /** 搜索关键词(名称模糊匹配) */
+    private String keyword;
+
+    /** 分类ID查询条件 */
+    private Long categoryIdQuery;
+
+    /** 状态查询条件 */
+    private String statusQuery;
+
+    /**
+     * 规格组DTO。
+     */
+    @Data
+    public static class SpecGroupDTO implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        private Long id;
+        private String groupName;
+        private Integer required;
+        private Integer sortOrder;
+        private List<SpecOptionDTO> options;
+    }
+
+    /**
+     * 规格选项DTO。
+     */
+    @Data
+    public static class SpecOptionDTO implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        private Long id;
+        private String optionName;
+        private BigDecimal priceExtra;
+        private Integer sortOrder;
+    }
+
+    /**
+     * 加料DTO。
+     */
+    @Data
+    public static class AdditionDTO implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        private Long id;
+        private String name;
+        private BigDecimal extraPrice;
+        private Integer sortOrder;
+    }
+}

+ 57 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelBusinessHoursMapper.java

@@ -0,0 +1,57 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酒店营业时段Mapper。
+ */
+@Mapper
+public interface HotelBusinessHoursMapper extends BaseMapper<HotelBusinessHours> {
+
+    /**
+     * 分页查询营业时段(按状态筛选)。
+     *
+     * @param page     分页参数
+     * @param tenantId 租户ID
+     * @param status   状态筛选(可选)
+     * @return 营业时段分页数据
+     */
+    IPage<HotelBusinessHours> selectBusinessHoursPage(Page<HotelBusinessHours> page,
+                                                       @Param("tenantId") Long tenantId,
+                                                       @Param("query") HotelBusinessHours query);
+
+    /**
+     * 查询所有营业时段(含禁用,按排序号升序)。
+     *
+     * @param tenantId 租户ID
+     * @return 营业时段列表
+     */
+    List<HotelBusinessHours> selectAll(@Param("tenantId") Long tenantId);
+
+    /**
+     * 查询所有启用的营业时段(按排序号升序)。
+     *
+     * @param tenantId 租户ID
+     * @return 营业时段列表
+     */
+    List<HotelBusinessHours> selectEnabled(@Param("tenantId") Long tenantId);
+
+    /**
+     * 查询当前营业时段(按星期匹配 + 时间区间判断)。
+     *
+     * @param tenantId    租户ID
+     * @param dayOfWeek   当前星期(0=周日)
+     * @param currentTime 当前时间(HH:mm)
+     * @return 匹配的营业时段(可能为null)
+     */
+    HotelBusinessHours selectCurrentBusinessHours(@Param("tenantId") Long tenantId,
+                                                   @Param("dayOfWeek") int dayOfWeek,
+                                                   @Param("currentTime") String currentTime);
+}

+ 12 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishAdditionMapper.java

@@ -0,0 +1,12 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishAddition;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 酒店菜品加料Mapper。
+ */
+@Mapper
+public interface HotelDishAdditionMapper extends BaseMapper<HotelDishAddition> {
+}

+ 32 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishCategoryMapper.java

@@ -0,0 +1,32 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishCategory;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酒店菜品分类Mapper。
+ */
+@Mapper
+public interface HotelDishCategoryMapper extends BaseMapper<HotelDishCategory> {
+
+    /**
+     * 分页查询分类(带菜品数量统计)。
+     *
+     * @param tenantId 租户ID
+     * @return 分类列表(含菜品数量)
+     */
+    List<HotelDishCategoryVO> selectCategoryWithCount(@Param("tenantId") Long tenantId);
+
+    /**
+     * 查询所有启用的分类。
+     *
+     * @param tenantId 租户ID
+     * @return 启用分类列表
+     */
+    List<HotelDishCategoryVO> selectEnabledList(@Param("tenantId") Long tenantId);
+}

+ 12 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishLogMapper.java

@@ -0,0 +1,12 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishLog;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 酒店菜品操作日志Mapper。
+ */
+@Mapper
+public interface HotelDishLogMapper extends BaseMapper<HotelDishLog> {
+}

+ 52 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishMapper.java

@@ -0,0 +1,52 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.domain.HotelDish;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酒店菜品Mapper。
+ */
+@Mapper
+public interface HotelDishMapper extends BaseMapper<HotelDish> {
+
+    /**
+     * 管理后台分页查询菜品(关联分类名称)。
+     *
+     * @param page       分页对象
+     * @param tenantId   租户ID
+     * @param categoryId 分类ID(可选)
+     * @param status     状态(可选)
+     * @param name       菜品名称模糊搜索(可选)
+     * @return 菜品分页数据
+     */
+    IPage<HotelDishVO> selectAdminPage(Page<HotelDishVO> page,
+                                       @Param("tenantId") Long tenantId,
+                                       @Param("query") HotelDish query);
+
+    /**
+     * 查询菜品详情(关联分类名称)。
+     *
+     * @param tenantId 租户ID
+     * @param id       菜品ID
+     * @return 菜品详情
+     */
+    HotelDishVO selectDetail(@Param("tenantId") Long tenantId,
+                             @Param("id") Long id);
+
+    /**
+     * 销量排行。
+     *
+     * @param tenantId 租户ID
+     * @param limit    数量限制
+     * @return 菜品列表
+     */
+    List<HotelDishVO> selectSalesRanking(@Param("tenantId") Long tenantId,
+                                         @Param("limit") int limit);
+}

+ 40 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishSpecGroupMapper.java

@@ -0,0 +1,40 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecGroup;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酒店菜品规格组Mapper。
+ */
+@Mapper
+public interface HotelDishSpecGroupMapper extends BaseMapper<HotelDishSpecGroup> {
+
+    /**
+     * 分页查询规格组(关联菜品名称,按菜品名称/规格组名称筛选)。
+     *
+     * @param page     分页参数
+     * @param tenantId 租户ID
+     * @param dishName 菜品名称模糊搜索(可选)
+     * @param groupName 规格组名称模糊搜索(可选)
+     * @return 规格组分页数据
+     */
+    IPage<HotelDishSpecGroup> selectSpecGroupPage(Page<HotelDishSpecGroup> page,
+                                                   @Param("tenantId") Long tenantId,
+                                                   @Param("query") HotelDishSpecGroup query);
+
+    /**
+     * 根据菜品ID查询所有规格组。
+     *
+     * @param dishId   菜品ID
+     * @param tenantId 租户ID
+     * @return 规格组列表
+     */
+    List<HotelDishSpecGroup> selectByDishId(@Param("dishId") Long dishId,
+                                             @Param("tenantId") Long tenantId);
+}

+ 30 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishSpecOptionMapper.java

@@ -0,0 +1,30 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酒店菜品规格选项Mapper。
+ */
+@Mapper
+public interface HotelDishSpecOptionMapper extends BaseMapper<HotelDishSpecOption> {
+
+    /**
+     * 根据规格组ID查询所有选项。
+     *
+     * @param groupId 规格组ID
+     * @return 选项列表
+     */
+    List<HotelDishSpecOption> selectByGroupId(@Param("groupId") Long groupId);
+
+    /**
+     * 根据规格组ID删除所有选项。
+     *
+     * @param groupId 规格组ID
+     */
+    void deleteByGroupId(@Param("groupId") Long groupId);
+}

+ 1 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelRoomMapper.java

@@ -28,9 +28,7 @@ public interface HotelRoomMapper extends BaseMapper<HotelRoom> {
      */
     IPage<HotelRoomVO> selectPage(Page<HotelRoomVO> page,
                                   @Param("tenantId") Long tenantId,
-                                  @Param("roomNo") String roomNo,
-                                  @Param("status") String status,
-                                  @Param("roomTypeId") Long roomTypeId);
+                                  @Param("query") HotelRoom query);
 
     /**
      * 查询房间详情(关联房型名称)。

+ 80 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelBusinessHoursService.java

@@ -0,0 +1,80 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+import java.util.List;
+
+/**
+ * 酒店营业时段服务接口。
+ */
+public interface HotelBusinessHoursService {
+
+    /**
+     * 分页查询营业时段。
+     *
+     * @param pageQuery 分页参数
+     * @param status    状态筛选(可选)
+     * @return 营业时段分页数据
+     */
+    IPage<HotelBusinessHours> businessHoursPage(PageQuery pageQuery, HotelBusinessHours query);
+
+    /**
+     * 查询所有营业时段(含禁用)。
+     *
+     * @return 营业时段列表
+     */
+    List<HotelBusinessHours> businessHoursAll();
+
+    /**
+     * 查询所有启用的营业时段(顾客端用)。
+     *
+     * @return 营业时段列表
+     */
+    List<HotelBusinessHours> businessHoursEnabled();
+
+    /**
+     * 检查当前是否在营业时段内(顾客端用)。
+     *
+     * @return 当前营业时段信息,不在营业时段则返回null
+     */
+    HotelBusinessHours checkCurrentBusinessHours();
+
+    /**
+     * 查询营业时段详情。
+     *
+     * @param id 营业时段ID
+     * @return 营业时段信息
+     */
+    HotelBusinessHours businessHoursDetail(Long id);
+
+    /**
+     * 新增营业时段。
+     *
+     * @param businessHours 营业时段信息
+     * @return 新建营业时段ID
+     */
+    Long businessHoursCreate(HotelBusinessHours businessHours);
+
+    /**
+     * 修改营业时段。
+     *
+     * @param businessHours 营业时段信息
+     */
+    void businessHoursUpdate(HotelBusinessHours businessHours);
+
+    /**
+     * 切换营业时段状态(启用/禁用)。
+     *
+     * @param id 营业时段ID
+     */
+    void businessHoursToggle(Long id);
+
+    /**
+     * 删除营业时段。
+     *
+     * @param id 营业时段ID
+     */
+    void businessHoursDelete(Long id);
+}

+ 63 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishCategoryService.java

@@ -0,0 +1,63 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+import com.mdframe.forge.business.core.hotel.dto.HotelDishCategoryDTO;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
+
+import java.util.List;
+
+/**
+ * 酒店菜品分类服务接口。
+ */
+public interface HotelDishCategoryService {
+
+    /**
+     * 查询全部分类(带菜品数量)。
+     *
+     * @return 分类列表
+     */
+    List<HotelDishCategoryVO> categoryList();
+
+    /**
+     * 查询所有启用的分类(下拉用)。
+     *
+     * @return 启用分类列表
+     */
+    List<HotelDishCategoryVO> categoryEnabledList();
+
+    /**
+     * 查询分类详情。
+     *
+     * @param id 分类ID
+     * @return 分类详情
+     */
+    HotelDishCategoryVO categoryDetail(Long id);
+
+    /**
+     * 新增分类。
+     *
+     * @param dto 分类信息
+     * @return 新建分类ID
+     */
+    Long categoryCreate(HotelDishCategoryDTO dto);
+
+    /**
+     * 修改分类。
+     *
+     * @param dto 分类信息
+     */
+    void categoryUpdate(HotelDishCategoryDTO dto);
+
+    /**
+     * 删除分类(逻辑删除)。
+     *
+     * @param id 分类ID
+     */
+    void categoryDelete(Long id);
+
+    /**
+     * 切换分类状态(启用/禁用)。
+     *
+     * @param id 分类ID
+     */
+    void categoryToggleStatus(Long id);
+}

+ 94 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishService.java

@@ -0,0 +1,94 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelDish;
+import com.mdframe.forge.business.core.hotel.dto.DishBatchOperationDTO;
+import com.mdframe.forge.business.core.hotel.dto.HotelDishDTO;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+import java.util.List;
+
+/**
+ * 酒店菜品服务接口。
+ */
+public interface HotelDishService {
+
+    /**
+     * 管理后台分页查询菜品。
+     *
+     * @param pageQuery  分页参数
+     * @param categoryId 分类ID(可选)
+     * @param status     状态(可选)
+     * @param name       菜品名称模糊搜索(可选)
+     * @return 菜品分页数据
+     */
+    IPage<HotelDishVO> dishPage(PageQuery pageQuery, HotelDish query);
+
+    /**
+     * 查询菜品详情(含规格组和加料)。
+     *
+     * @param id 菜品ID
+     * @return 菜品详情
+     */
+    HotelDishVO dishDetail(Long id);
+
+    /**
+     * 新增菜品(含规格组和加料)。
+     *
+     * @param dto 菜品信息
+     * @return 新建菜品ID
+     */
+    Long dishCreate(HotelDishDTO dto);
+
+    /**
+     * 修改菜品(含规格组和加料)。
+     *
+     * @param dto 菜品信息
+     */
+    void dishUpdate(HotelDishDTO dto);
+
+    /**
+     * 删除菜品(逻辑删除,级联删除规格组和加料)。
+     *
+     * @param id 菜品ID
+     */
+    void dishDelete(Long id);
+
+    /**
+     * 更新菜品状态(上架/下架)。
+     *
+     * @param id     菜品ID
+     * @param status 目标状态
+     */
+    void dishUpdateStatus(Long id, String status);
+
+    /**
+     * 标记售罄。
+     *
+     * @param id 菜品ID
+     */
+    void dishMarkSoldOut(Long id);
+
+    /**
+     * 恢复上架(取消售罄)。
+     *
+     * @param id 菜品ID
+     */
+    void dishRestore(Long id);
+
+    /**
+     * 批量操作(上架/下架/售罄/恢复/删除)。
+     *
+     * @param dto 批量操作参数
+     */
+    void dishBatchOperate(DishBatchOperationDTO dto);
+
+    /**
+     * 销量排行。
+     *
+     * @param limit 数量限制
+     * @return 菜品列表
+     */
+    List<HotelDishVO> dishSalesRanking(int limit);
+}

+ 61 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishSpecGroupService.java

@@ -0,0 +1,61 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecGroup;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+import java.util.List;
+
+/**
+ * 酒店菜品规格组服务接口。
+ */
+public interface HotelDishSpecGroupService {
+
+    /**
+     * 分页查询规格组(含菜品名称和选项数量)。
+     *
+     * @param pageQuery 分页参数
+     * @param dishName  菜品名称模糊搜索(可选)
+     * @param groupName 规格组名称模糊搜索(可选)
+     * @return 规格组分页数据
+     */
+    IPage<HotelDishSpecGroup> specGroupPage(PageQuery pageQuery, HotelDishSpecGroup query);
+
+    /**
+     * 根据菜品ID查询所有规格组(含选项列表)。
+     *
+     * @param dishId 菜品ID
+     * @return 规格组列表(含选项)
+     */
+    List<HotelDishSpecGroup> findByDishId(Long dishId);
+
+    /**
+     * 查询规格组详情。
+     *
+     * @param id 规格组ID
+     * @return 规格组信息
+     */
+    HotelDishSpecGroup specGroupDetail(Long id);
+
+    /**
+     * 新增规格组。
+     *
+     * @param specGroup 规格组信息
+     * @return 新建规格组ID
+     */
+    Long specGroupCreate(HotelDishSpecGroup specGroup);
+
+    /**
+     * 修改规格组。
+     *
+     * @param specGroup 规格组信息
+     */
+    void specGroupUpdate(HotelDishSpecGroup specGroup);
+
+    /**
+     * 删除规格组(级联删除选项)。
+     *
+     * @param id 规格组ID
+     */
+    void specGroupDelete(Long id);
+}

+ 49 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishSpecOptionService.java

@@ -0,0 +1,49 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
+
+import java.util.List;
+
+/**
+ * 酒店菜品规格选项服务接口。
+ */
+public interface HotelDishSpecOptionService {
+
+    /**
+     * 根据规格组ID查询所有选项。
+     *
+     * @param groupId 规格组ID
+     * @return 选项列表
+     */
+    List<HotelDishSpecOption> findByGroupId(Long groupId);
+
+    /**
+     * 查询规格选项详情。
+     *
+     * @param id 选项ID
+     * @return 选项信息
+     */
+    HotelDishSpecOption specOptionDetail(Long id);
+
+    /**
+     * 新增规格选项。
+     *
+     * @param specOption 选项信息
+     * @return 新建选项ID
+     */
+    Long specOptionCreate(HotelDishSpecOption specOption);
+
+    /**
+     * 修改规格选项。
+     *
+     * @param specOption 选项信息
+     */
+    void specOptionUpdate(HotelDishSpecOption specOption);
+
+    /**
+     * 删除规格选项。
+     *
+     * @param id 选项ID
+     */
+    void specOptionDelete(Long id);
+}

+ 2 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelQrCodeService.java

@@ -1,6 +1,7 @@
 package com.mdframe.forge.business.core.hotel.service;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoom;
 import com.mdframe.forge.business.core.hotel.dto.HotelRoomDTO;
 import com.mdframe.forge.business.core.hotel.dto.HotelRoomTypeDTO;
 import com.mdframe.forge.business.core.hotel.dto.QrCodeBatchGenerateDTO;
@@ -149,7 +150,7 @@ public interface HotelQrCodeService {
      * @param roomTypeId 房型ID(精确匹配,可选)
      * @return 房间分页数据(含房型名称)
      */
-    IPage<HotelRoomVO> roomPage(PageQuery pageQuery, String roomNo, String status, Long roomTypeId);
+    IPage<HotelRoomVO> roomPage(PageQuery pageQuery, HotelRoom query);
 
     /**
      * 查询房间详情。

+ 162 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelBusinessHoursServiceImpl.java

@@ -0,0 +1,162 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
+import com.mdframe.forge.business.core.hotel.mapper.HotelBusinessHoursMapper;
+import com.mdframe.forge.business.core.hotel.service.HotelBusinessHoursService;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 酒店营业时段服务实现。
+ */
+@Slf4j
+@Service
+public class HotelBusinessHoursServiceImpl implements HotelBusinessHoursService {
+
+    @Autowired
+    private HotelBusinessHoursMapper businessHoursMapper;
+
+    @Override
+    public IPage<HotelBusinessHours> businessHoursPage(PageQuery pageQuery, HotelBusinessHours query) {
+        Page<HotelBusinessHours> page = pageQuery.toPage();
+        Long tenantId = resolveTenantId();
+        return businessHoursMapper.selectBusinessHoursPage(page, tenantId, query);
+    }
+
+    @Override
+    public List<HotelBusinessHours> businessHoursAll() {
+        return businessHoursMapper.selectAll(resolveTenantId());
+    }
+
+    @Override
+    public List<HotelBusinessHours> businessHoursEnabled() {
+        return businessHoursMapper.selectEnabled(resolveTenantId());
+    }
+
+    @Override
+    public HotelBusinessHours checkCurrentBusinessHours() {
+        Long tenantId = resolveTenantId();
+        // 获取当前星期(0=周日, 与Calendar.DAY_OF_WEEK-1一致)
+        int dayOfWeek = java.time.LocalDate.now().getDayOfWeek().getValue() % 7;
+        // 获取当前时间(HH:mm)
+        String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));
+        return businessHoursMapper.selectCurrentBusinessHours(tenantId, dayOfWeek, currentTime);
+    }
+
+    @Override
+    public HotelBusinessHours businessHoursDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("营业时段ID不能为空");
+        }
+        HotelBusinessHours entity = businessHoursMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("营业时段不存在");
+        }
+        return entity;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long businessHoursCreate(HotelBusinessHours businessHours) {
+        validateBusinessHours(businessHours);
+        if (!StringUtils.hasText(businessHours.getStatus())) {
+            businessHours.setStatus("ENABLED");
+        }
+        if (businessHours.getSortOrder() == null) {
+            businessHours.setSortOrder(0);
+        }
+        businessHoursMapper.insert(businessHours);
+        log.info("新增营业时段: {}", businessHours.getMealName());
+        return businessHours.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void businessHoursUpdate(HotelBusinessHours businessHours) {
+        if (businessHours == null || businessHours.getId() == null) {
+            throw new BusinessException("营业时段ID不能为空");
+        }
+        HotelBusinessHours entity = businessHoursMapper.selectById(businessHours.getId());
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("营业时段不存在");
+        }
+        validateBusinessHours(businessHours);
+        entity.setDayOfWeek(businessHours.getDayOfWeek());
+        entity.setMealType(businessHours.getMealType());
+        entity.setMealName(businessHours.getMealName());
+        entity.setStartTime(businessHours.getStartTime());
+        entity.setEndTime(businessHours.getEndTime());
+        entity.setSortOrder(businessHours.getSortOrder());
+        businessHoursMapper.updateById(entity);
+        log.info("编辑营业时段: {}", entity.getMealName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void businessHoursToggle(Long id) {
+        if (id == null) {
+            throw new BusinessException("营业时段ID不能为空");
+        }
+        HotelBusinessHours entity = businessHoursMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("营业时段不存在");
+        }
+        String newStatus = "ENABLED".equals(entity.getStatus()) ? "DISABLED" : "ENABLED";
+        entity.setStatus(newStatus);
+        businessHoursMapper.updateById(entity);
+        log.info("营业时段状态变更: {} -> {}", entity.getMealName(), newStatus);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void businessHoursDelete(Long id) {
+        if (id == null) {
+            throw new BusinessException("营业时段ID不能为空");
+        }
+        HotelBusinessHours entity = businessHoursMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("营业时段不存在");
+        }
+        businessHoursMapper.deleteById(id);
+        log.info("删除营业时段: {}", entity.getMealName());
+    }
+
+    /**
+     * 校验营业时段参数。
+     */
+    private void validateBusinessHours(HotelBusinessHours businessHours) {
+        if (businessHours == null) {
+            throw new BusinessException("营业时段参数不能为空");
+        }
+        if (!StringUtils.hasText(businessHours.getMealType())) {
+            throw new BusinessException("餐段类型不能为空");
+        }
+        if (!StringUtils.hasText(businessHours.getMealName())) {
+            throw new BusinessException("餐段名称不能为空");
+        }
+        if (!StringUtils.hasText(businessHours.getStartTime())) {
+            throw new BusinessException("开始时间不能为空");
+        }
+        if (!StringUtils.hasText(businessHours.getEndTime())) {
+            throw new BusinessException("结束时间不能为空");
+        }
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 134 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishCategoryServiceImpl.java

@@ -0,0 +1,134 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+import com.mdframe.forge.business.core.hotel.domain.HotelDishCategory;
+import com.mdframe.forge.business.core.hotel.dto.HotelDishCategoryDTO;
+import com.mdframe.forge.business.core.hotel.mapper.HotelDishCategoryMapper;
+import com.mdframe.forge.business.core.hotel.service.HotelDishCategoryService;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.core.session.SessionHelper;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 酒店菜品分类服务实现。
+ */
+@Slf4j
+@Service
+public class HotelDishCategoryServiceImpl implements HotelDishCategoryService {
+
+    @Autowired
+    private HotelDishCategoryMapper categoryMapper;
+
+    @Override
+    public List<HotelDishCategoryVO> categoryList() {
+        return categoryMapper.selectCategoryWithCount(resolveTenantId());
+    }
+
+    @Override
+    public List<HotelDishCategoryVO> categoryEnabledList() {
+        return categoryMapper.selectEnabledList(resolveTenantId());
+    }
+
+    @Override
+    public HotelDishCategoryVO categoryDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("分类ID不能为空");
+        }
+        HotelDishCategory entity = categoryMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("分类不存在");
+        }
+        HotelDishCategoryVO vo = new HotelDishCategoryVO();
+        BeanUtils.copyProperties(entity, vo);
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long categoryCreate(HotelDishCategoryDTO dto) {
+        validateDTO(dto);
+        HotelDishCategory entity = new HotelDishCategory();
+        BeanUtils.copyProperties(dto, entity, "id");
+        if (!StringUtils.hasText(entity.getStatus())) {
+            entity.setStatus("ENABLED");
+        }
+        if (entity.getSortOrder() == null) {
+            entity.setSortOrder(0);
+        }
+        categoryMapper.insert(entity);
+        log.info("新增菜品分类: {}", entity.getName());
+        return entity.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void categoryUpdate(HotelDishCategoryDTO dto) {
+        if (dto == null || dto.getId() == null) {
+            throw new BusinessException("分类ID不能为空");
+        }
+        HotelDishCategory entity = categoryMapper.selectById(dto.getId());
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("分类不存在");
+        }
+        validateDTO(dto);
+        BeanUtils.copyProperties(dto, entity, "id");
+        categoryMapper.updateById(entity);
+        log.info("编辑菜品分类: {}", entity.getName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void categoryDelete(Long id) {
+        if (id == null) {
+            throw new BusinessException("分类ID不能为空");
+        }
+        HotelDishCategory entity = categoryMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("分类不存在");
+        }
+        categoryMapper.deleteById(id);
+        log.info("删除菜品分类: {}", entity.getName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void categoryToggleStatus(Long id) {
+        if (id == null) {
+            throw new BusinessException("分类ID不能为空");
+        }
+        HotelDishCategory entity = categoryMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("分类不存在");
+        }
+        entity.setStatus("ENABLED".equals(entity.getStatus()) ? "DISABLED" : "ENABLED");
+        categoryMapper.updateById(entity);
+        log.info("切换分类状态: {} -> {}", entity.getName(), entity.getStatus());
+    }
+
+    private void validateDTO(HotelDishCategoryDTO dto) {
+        if (dto == null) {
+            throw new BusinessException("分类参数不能为空");
+        }
+        if (!StringUtils.hasText(dto.getName())) {
+            throw new BusinessException("分类名称不能为空");
+        }
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SessionHelper.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 415 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishServiceImpl.java

@@ -0,0 +1,415 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.mdframe.forge.business.core.hotel.domain.*;
+import com.mdframe.forge.business.core.hotel.dto.DishBatchOperationDTO;
+import com.mdframe.forge.business.core.hotel.dto.HotelDishDTO;
+import com.mdframe.forge.business.core.hotel.mapper.*;
+import com.mdframe.forge.business.core.hotel.service.HotelDishService;
+import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.core.session.SessionHelper;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 酒店菜品服务实现。
+ */
+@Slf4j
+@Service
+public class HotelDishServiceImpl implements HotelDishService {
+
+    @Autowired
+    private HotelDishMapper dishMapper;
+    @Autowired
+    private HotelDishSpecGroupMapper specGroupMapper;
+    @Autowired
+    private HotelDishSpecOptionMapper specOptionMapper;
+    @Autowired
+    private HotelDishAdditionMapper additionMapper;
+    @Autowired
+    private HotelDishLogMapper dishLogMapper;
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    // ==================== 菜品查询 ====================
+
+    @Override
+    public IPage<HotelDishVO> dishPage(PageQuery pageQuery, HotelDish query) {
+        Page<HotelDishVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return dishMapper.selectAdminPage(page, resolveTenantId(), query);
+    }
+
+    @Override
+    public HotelDishVO dishDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("菜品ID不能为空");
+        }
+        HotelDishVO vo = dishMapper.selectDetail(resolveTenantId(), id);
+        if (vo == null) {
+            throw new BusinessException("菜品不存在");
+        }
+        // 填充规格组和加料
+        fillDishSpecGroupsAndAdditions(vo);
+        return vo;
+    }
+
+    @Override
+    public List<HotelDishVO> dishSalesRanking(int limit) {
+        return dishMapper.selectSalesRanking(resolveTenantId(), limit);
+    }
+
+    // ==================== 菜品新增/编辑 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long dishCreate(HotelDishDTO dto) {
+        validateDishDTO(dto);
+
+        HotelDish entity = new HotelDish();
+        BeanUtils.copyProperties(dto, entity, "id", "specGroups", "additions", "keyword", "categoryIdQuery", "statusQuery");
+        if (!StringUtils.hasText(entity.getStatus())) {
+            entity.setStatus("ON_SALE");
+        }
+        if (entity.getSales() == null) {
+            entity.setSales(0);
+        }
+        if (entity.getSoldOut() == null) {
+            entity.setSoldOut(0);
+        }
+        if (entity.getIsRecommended() == null) {
+            entity.setIsRecommended(0);
+        }
+        dishMapper.insert(entity);
+
+        // 保存规格组和加料
+        saveSpecGroups(entity.getId(), dto.getSpecGroups());
+        saveAdditions(entity.getId(), dto.getAdditions());
+
+        // 记录日志
+        saveLog(entity.getId(), entity.getName(), "CREATE", null, toJson(entity), "新增菜品");
+        log.info("新增菜品: {}", entity.getName());
+        return entity.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void dishUpdate(HotelDishDTO dto) {
+        if (dto == null || dto.getId() == null) {
+            throw new BusinessException("菜品ID不能为空");
+        }
+        HotelDish oldEntity = dishMapper.selectById(dto.getId());
+        if (oldEntity == null || !Objects.equals(oldEntity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("菜品不存在");
+        }
+        String oldJson = toJson(oldEntity);
+
+        validateDishDTO(dto);
+        BeanUtils.copyProperties(dto, oldEntity, "id", "specGroups", "additions", "keyword", "categoryIdQuery", "statusQuery");
+        dishMapper.updateById(oldEntity);
+
+        // 更新规格组:先删后增
+        deleteSpecGroupsByDishId(oldEntity.getId());
+        saveSpecGroups(oldEntity.getId(), dto.getSpecGroups());
+
+        // 更新加料:先删后增
+        deleteAdditionsByDishId(oldEntity.getId());
+        saveAdditions(oldEntity.getId(), dto.getAdditions());
+
+        saveLog(oldEntity.getId(), oldEntity.getName(), "EDIT", oldJson, toJson(oldEntity), "编辑菜品");
+        log.info("编辑菜品: {}", oldEntity.getName());
+    }
+
+    // ==================== 菜品删除 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void dishDelete(Long id) {
+        if (id == null) {
+            throw new BusinessException("菜品ID不能为空");
+        }
+        HotelDish entity = dishMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("菜品不存在");
+        }
+        String oldJson = toJson(entity);
+
+        // 逻辑删除菜品
+        dishMapper.deleteById(id);
+
+        // 级联逻辑删除规格组、规格选项、加料
+        deleteSpecGroupsByDishId(id);
+        deleteAdditionsByDishId(id);
+
+        saveLog(id, entity.getName(), "DELETE", oldJson, null, "删除菜品");
+        log.info("删除菜品: {}", entity.getName());
+    }
+
+    // ==================== 菜品状态操作 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void dishUpdateStatus(Long id, String status) {
+        HotelDish entity = requireDish(id);
+        entity.setStatus(status);
+        dishMapper.updateById(entity);
+        String opType = "ON_SALE".equals(status) ? "ON_SALE" : "OFF_SHELF";
+        String desc = "ON_SALE".equals(status) ? "上架" : "下架";
+        saveLog(id, entity.getName(), opType, null, null, desc);
+        log.info("菜品状态变更: {} -> {}", entity.getName(), status);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void dishMarkSoldOut(Long id) {
+        HotelDish entity = requireDish(id);
+        entity.setSoldOut(1);
+        dishMapper.updateById(entity);
+        saveLog(id, entity.getName(), "SOLD_OUT", null, null, "标记售罄");
+        log.info("标记售罄: {}", entity.getName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void dishRestore(Long id) {
+        HotelDish entity = requireDish(id);
+        entity.setSoldOut(0);
+        entity.setStatus("ON_SALE");
+        dishMapper.updateById(entity);
+        saveLog(id, entity.getName(), "RESTORE", null, null, "恢复上架");
+        log.info("恢复上架: {}", entity.getName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void dishBatchOperate(DishBatchOperationDTO dto) {
+        if (dto == null || dto.getIds() == null || dto.getIds().isEmpty()) {
+            throw new BusinessException("请选择要操作的菜品");
+        }
+        if (!StringUtils.hasText(dto.getOperation())) {
+            throw new BusinessException("操作类型不能为空");
+        }
+        for (Long id : dto.getIds()) {
+            switch (dto.getOperation()) {
+                case "ON_SALE" -> dishUpdateStatus(id, "ON_SALE");
+                case "OFF_SHELF" -> dishUpdateStatus(id, "OFF_SHELF");
+                case "SOLD_OUT" -> dishMarkSoldOut(id);
+                case "RESTORE" -> dishRestore(id);
+                case "DELETE" -> dishDelete(id);
+                default -> throw new BusinessException("不支持的操作类型: " + dto.getOperation());
+            }
+        }
+    }
+
+    // ==================== 私有方法 ====================
+
+    /**
+     * 填充菜品的规格组和加料信息。
+     */
+    private void fillDishSpecGroupsAndAdditions(HotelDishVO vo) {
+        Long tenantId = resolveTenantId();
+
+        // 查询规格组
+        LambdaQueryWrapper<HotelDishSpecGroup> groupWrapper = new LambdaQueryWrapper<>();
+        groupWrapper.eq(HotelDishSpecGroup::getDishId, vo.getId())
+                .eq(HotelDishSpecGroup::getTenantId, tenantId)
+                .orderByAsc(HotelDishSpecGroup::getSortOrder);
+        List<HotelDishSpecGroup> groups = specGroupMapper.selectList(groupWrapper);
+
+        List<HotelDishVO.SpecGroupVO> groupVOs = new ArrayList<>();
+        for (HotelDishSpecGroup group : groups) {
+            HotelDishVO.SpecGroupVO groupVO = new HotelDishVO.SpecGroupVO();
+            groupVO.setId(group.getId());
+            groupVO.setGroupName(group.getGroupName());
+            groupVO.setRequired(group.getRequired());
+            groupVO.setSortOrder(group.getSortOrder());
+
+            // 查询规格选项
+            LambdaQueryWrapper<HotelDishSpecOption> optWrapper = new LambdaQueryWrapper<>();
+            optWrapper.eq(HotelDishSpecOption::getGroupId, group.getId())
+                    .eq(HotelDishSpecOption::getTenantId, tenantId)
+                    .orderByAsc(HotelDishSpecOption::getSortOrder);
+            List<HotelDishSpecOption> options = specOptionMapper.selectList(optWrapper);
+
+            List<HotelDishVO.SpecOptionVO> optionVOs = new ArrayList<>();
+            for (HotelDishSpecOption opt : options) {
+                HotelDishVO.SpecOptionVO optVO = new HotelDishVO.SpecOptionVO();
+                optVO.setId(opt.getId());
+                optVO.setOptionName(opt.getOptionName());
+                optVO.setPriceExtra(opt.getPriceExtra());
+                optVO.setSortOrder(opt.getSortOrder());
+                optionVOs.add(optVO);
+            }
+            groupVO.setOptions(optionVOs);
+            groupVOs.add(groupVO);
+        }
+        vo.setSpecGroups(groupVOs);
+
+        // 查询加料
+        LambdaQueryWrapper<HotelDishAddition> addWrapper = new LambdaQueryWrapper<>();
+        addWrapper.eq(HotelDishAddition::getDishId, vo.getId())
+                .eq(HotelDishAddition::getTenantId, tenantId)
+                .orderByAsc(HotelDishAddition::getSortOrder);
+        List<HotelDishAddition> additions = additionMapper.selectList(addWrapper);
+
+        List<HotelDishVO.AdditionVO> additionVOs = new ArrayList<>();
+        for (HotelDishAddition add : additions) {
+            HotelDishVO.AdditionVO addVO = new HotelDishVO.AdditionVO();
+            addVO.setId(add.getId());
+            addVO.setName(add.getName());
+            addVO.setExtraPrice(add.getExtraPrice());
+            addVO.setSortOrder(add.getSortOrder());
+            additionVOs.add(addVO);
+        }
+        vo.setAdditions(additionVOs);
+    }
+
+    /**
+     * 保存菜品规格组及选项。
+     */
+    private void saveSpecGroups(Long dishId, List<HotelDishDTO.SpecGroupDTO> specGroupDTOs) {
+        if (specGroupDTOs == null || specGroupDTOs.isEmpty()) {
+            return;
+        }
+        Long tenantId = resolveTenantId();
+        for (HotelDishDTO.SpecGroupDTO groupDTO : specGroupDTOs) {
+            HotelDishSpecGroup group = new HotelDishSpecGroup();
+            group.setDishId(dishId);
+            group.setGroupName(groupDTO.getGroupName());
+            group.setRequired(groupDTO.getRequired() != null ? groupDTO.getRequired() : 0);
+            group.setSortOrder(groupDTO.getSortOrder() != null ? groupDTO.getSortOrder() : 0);
+            group.setTenantId(tenantId);
+            specGroupMapper.insert(group);
+
+            // 保存规格选项
+            if (groupDTO.getOptions() != null) {
+                for (HotelDishDTO.SpecOptionDTO optDTO : groupDTO.getOptions()) {
+                    HotelDishSpecOption opt = new HotelDishSpecOption();
+                    opt.setGroupId(group.getId());
+                    opt.setOptionName(optDTO.getOptionName());
+                    opt.setPriceExtra(optDTO.getPriceExtra());
+                    opt.setSortOrder(optDTO.getSortOrder() != null ? optDTO.getSortOrder() : 0);
+                    opt.setTenantId(tenantId);
+                    specOptionMapper.insert(opt);
+                }
+            }
+        }
+    }
+
+    /**
+     * 保存菜品加料。
+     */
+    private void saveAdditions(Long dishId, List<HotelDishDTO.AdditionDTO> additionDTOs) {
+        if (additionDTOs == null || additionDTOs.isEmpty()) {
+            return;
+        }
+        Long tenantId = resolveTenantId();
+        for (HotelDishDTO.AdditionDTO addDTO : additionDTOs) {
+            HotelDishAddition addition = new HotelDishAddition();
+            addition.setDishId(dishId);
+            addition.setName(addDTO.getName());
+            addition.setExtraPrice(addDTO.getExtraPrice());
+            addition.setSortOrder(addDTO.getSortOrder() != null ? addDTO.getSortOrder() : 0);
+            addition.setTenantId(tenantId);
+            additionMapper.insert(addition);
+        }
+    }
+
+    /**
+     * 级联删除菜品的规格组和规格选项。
+     */
+    private void deleteSpecGroupsByDishId(Long dishId) {
+        LambdaQueryWrapper<HotelDishSpecGroup> groupWrapper = new LambdaQueryWrapper<>();
+        groupWrapper.eq(HotelDishSpecGroup::getDishId, dishId);
+        List<HotelDishSpecGroup> groups = specGroupMapper.selectList(groupWrapper);
+        for (HotelDishSpecGroup group : groups) {
+            // 删除规格选项
+            LambdaQueryWrapper<HotelDishSpecOption> optWrapper = new LambdaQueryWrapper<>();
+            optWrapper.eq(HotelDishSpecOption::getGroupId, group.getId());
+            specOptionMapper.delete(optWrapper);
+            // 删除规格组
+            specGroupMapper.deleteById(group.getId());
+        }
+    }
+
+    /**
+     * 级联删除菜品的加料。
+     */
+    private void deleteAdditionsByDishId(Long dishId) {
+        LambdaQueryWrapper<HotelDishAddition> addWrapper = new LambdaQueryWrapper<>();
+        addWrapper.eq(HotelDishAddition::getDishId, dishId);
+        additionMapper.delete(addWrapper);
+    }
+
+    /**
+     * 校验菜品存在且属于当前租户。
+     */
+    private HotelDish requireDish(Long id) {
+        if (id == null) {
+            throw new BusinessException("菜品ID不能为空");
+        }
+        HotelDish entity = dishMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("菜品不存在");
+        }
+        return entity;
+    }
+
+    /**
+     * 校验菜品DTO必填字段。
+     */
+    private void validateDishDTO(HotelDishDTO dto) {
+        if (dto == null) {
+            throw new BusinessException("菜品参数不能为空");
+        }
+        if (!StringUtils.hasText(dto.getName())) {
+            throw new BusinessException("菜品名称不能为空");
+        }
+    }
+
+    /**
+     * 保存操作日志。
+     */
+    private void saveLog(Long dishId, String dishName, String operationType, String oldValue, String newValue, String remark) {
+        HotelDishLog logEntity = new HotelDishLog();
+        logEntity.setDishId(dishId);
+        logEntity.setDishName(dishName);
+        logEntity.setOperationType(operationType);
+        logEntity.setOldValue(oldValue);
+        logEntity.setNewValue(newValue);
+        logEntity.setRemark(remark);
+        logEntity.setTenantId(resolveTenantId());
+        dishLogMapper.insert(logEntity);
+    }
+
+    private String toJson(Object obj) {
+        if (obj == null) return null;
+        try {
+            return OBJECT_MAPPER.writeValueAsString(obj);
+        } catch (Exception e) {
+            return obj.toString();
+        }
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SessionHelper.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 151 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishSpecGroupServiceImpl.java

@@ -0,0 +1,151 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecGroup;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
+import com.mdframe.forge.business.core.hotel.mapper.HotelDishSpecGroupMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelDishSpecOptionMapper;
+import com.mdframe.forge.business.core.hotel.service.HotelDishSpecGroupService;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 酒店菜品规格组服务实现。
+ */
+@Slf4j
+@Service
+public class HotelDishSpecGroupServiceImpl implements HotelDishSpecGroupService {
+
+    @Autowired
+    private HotelDishSpecGroupMapper specGroupMapper;
+    @Autowired
+    private HotelDishSpecOptionMapper specOptionMapper;
+
+    @Override
+    public IPage<HotelDishSpecGroup> specGroupPage(PageQuery pageQuery, HotelDishSpecGroup query) {
+        Page<HotelDishSpecGroup> page = pageQuery.toPage();
+        Long tenantId = resolveTenantId();
+        IPage<HotelDishSpecGroup> result = specGroupMapper.selectSpecGroupPage(page, tenantId, query);
+        // 填充每个规格组的选项列表
+        fillOptions(result.getRecords());
+        return result;
+    }
+
+    @Override
+    public List<HotelDishSpecGroup> findByDishId(Long dishId) {
+        if (dishId == null) {
+            throw new BusinessException("菜品ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        List<HotelDishSpecGroup> groups = specGroupMapper.selectByDishId(dishId, tenantId);
+        // 填充每个规格组的选项列表
+        fillOptions(groups);
+        return groups;
+    }
+
+    @Override
+    public HotelDishSpecGroup specGroupDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("规格组ID不能为空");
+        }
+        HotelDishSpecGroup entity = specGroupMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("规格组不存在");
+        }
+        return entity;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long specGroupCreate(HotelDishSpecGroup specGroup) {
+        validateSpecGroup(specGroup);
+        if (specGroup.getRequired() == null) {
+            specGroup.setRequired(0);
+        }
+        if (specGroup.getSortOrder() == null) {
+            specGroup.setSortOrder(0);
+        }
+        specGroupMapper.insert(specGroup);
+        log.info("新增菜品规格组: {}", specGroup.getGroupName());
+        return specGroup.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void specGroupUpdate(HotelDishSpecGroup specGroup) {
+        if (specGroup == null || specGroup.getId() == null) {
+            throw new BusinessException("规格组ID不能为空");
+        }
+        HotelDishSpecGroup entity = specGroupMapper.selectById(specGroup.getId());
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("规格组不存在");
+        }
+        validateSpecGroup(specGroup);
+        entity.setDishId(specGroup.getDishId());
+        entity.setGroupName(specGroup.getGroupName());
+        entity.setRequired(specGroup.getRequired());
+        entity.setSortOrder(specGroup.getSortOrder());
+        specGroupMapper.updateById(entity);
+        log.info("编辑菜品规格组: {}", entity.getGroupName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void specGroupDelete(Long id) {
+        if (id == null) {
+            throw new BusinessException("规格组ID不能为空");
+        }
+        HotelDishSpecGroup entity = specGroupMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("规格组不存在");
+        }
+        // 级联删除规格选项
+        specOptionMapper.deleteByGroupId(id);
+        // 逻辑删除规格组
+        specGroupMapper.deleteById(id);
+        log.info("删除菜品规格组及其选项: {}", entity.getGroupName());
+    }
+
+    /**
+     * 校验规格组参数。
+     */
+    private void validateSpecGroup(HotelDishSpecGroup specGroup) {
+        if (specGroup == null) {
+            throw new BusinessException("规格组参数不能为空");
+        }
+        if (specGroup.getDishId() == null) {
+            throw new BusinessException("关联菜品不能为空");
+        }
+        if (!StringUtils.hasText(specGroup.getGroupName())) {
+            throw new BusinessException("规格组名称不能为空");
+        }
+    }
+
+    /**
+     * 批量填充选项列表。
+     */
+    private void fillOptions(List<HotelDishSpecGroup> groups) {
+        if (groups == null || groups.isEmpty()) {
+            return;
+        }
+        for (HotelDishSpecGroup group : groups) {
+            List<HotelDishSpecOption> options = specOptionMapper.selectByGroupId(group.getId());
+            group.setOptions(options);
+        }
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 114 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishSpecOptionServiceImpl.java

@@ -0,0 +1,114 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
+import com.mdframe.forge.business.core.hotel.mapper.HotelDishSpecOptionMapper;
+import com.mdframe.forge.business.core.hotel.service.HotelDishSpecOptionService;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 酒店菜品规格选项服务实现。
+ */
+@Slf4j
+@Service
+public class HotelDishSpecOptionServiceImpl implements HotelDishSpecOptionService {
+
+    @Autowired
+    private HotelDishSpecOptionMapper specOptionMapper;
+
+    @Override
+    public List<HotelDishSpecOption> findByGroupId(Long groupId) {
+        if (groupId == null) {
+            throw new BusinessException("规格组ID不能为空");
+        }
+        return specOptionMapper.selectByGroupId(groupId);
+    }
+
+    @Override
+    public HotelDishSpecOption specOptionDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("规格选项ID不能为空");
+        }
+        HotelDishSpecOption entity = specOptionMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("规格选项不存在");
+        }
+        return entity;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long specOptionCreate(HotelDishSpecOption specOption) {
+        validateSpecOption(specOption);
+        if (specOption.getPriceExtra() == null) {
+            specOption.setPriceExtra(BigDecimal.ZERO);
+        }
+        if (specOption.getSortOrder() == null) {
+            specOption.setSortOrder(0);
+        }
+        specOptionMapper.insert(specOption);
+        log.info("新增规格选项: {}", specOption.getOptionName());
+        return specOption.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void specOptionUpdate(HotelDishSpecOption specOption) {
+        if (specOption == null || specOption.getId() == null) {
+            throw new BusinessException("规格选项ID不能为空");
+        }
+        HotelDishSpecOption entity = specOptionMapper.selectById(specOption.getId());
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("规格选项不存在");
+        }
+        validateSpecOption(specOption);
+        entity.setOptionName(specOption.getOptionName());
+        entity.setPriceExtra(specOption.getPriceExtra());
+        entity.setSortOrder(specOption.getSortOrder());
+        specOptionMapper.updateById(entity);
+        log.info("编辑规格选项: {}", entity.getOptionName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void specOptionDelete(Long id) {
+        if (id == null) {
+            throw new BusinessException("规格选项ID不能为空");
+        }
+        HotelDishSpecOption entity = specOptionMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("规格选项不存在");
+        }
+        specOptionMapper.deleteById(id);
+        log.info("删除规格选项: {}", entity.getOptionName());
+    }
+
+    /**
+     * 校验规格选项参数。
+     */
+    private void validateSpecOption(HotelDishSpecOption specOption) {
+        if (specOption == null) {
+            throw new BusinessException("规格选项参数不能为空");
+        }
+        if (specOption.getGroupId() == null) {
+            throw new BusinessException("关联规格组不能为空");
+        }
+        if (!StringUtils.hasText(specOption.getOptionName())) {
+            throw new BusinessException("选项名称不能为空");
+        }
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 9 - 7
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java

@@ -29,9 +29,9 @@ import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.core.session.SessionHelper;
 import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
@@ -50,15 +50,17 @@ import java.util.zip.ZipOutputStream;
  */
 @Slf4j
 @Service
-@RequiredArgsConstructor
 public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, HotelQrCode>
         implements HotelQrCodeService {
 
     private static final DateTimeFormatter BATCH_NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
 
-    private final HotelQrBindLogMapper bindLogMapper;
-    private final HotelRoomMapper roomMapper;
-    private final HotelRoomTypeMapper roomTypeMapper;
+    @Autowired
+    private HotelQrBindLogMapper bindLogMapper;
+    @Autowired
+    private HotelRoomMapper roomMapper;
+    @Autowired
+    private HotelRoomTypeMapper roomTypeMapper;
 
     @org.springframework.beans.factory.annotation.Value("${hotel.qr.base-url}")
     private String qrBaseUrl;
@@ -341,9 +343,9 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
     // ==================== 房间管理 ====================
 
     @Override
-    public IPage<HotelRoomVO> roomPage(PageQuery pageQuery, String roomNo, String status, Long roomTypeId) {
+    public IPage<HotelRoomVO> roomPage(PageQuery pageQuery, HotelRoom query) {
         Page<HotelRoomVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
-        return roomMapper.selectPage(page, resolveTenantId(), roomNo, status, roomTypeId);
+        return roomMapper.selectPage(page, resolveTenantId(), query);
     }
 
     @Override

+ 45 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishCategoryVO.java

@@ -0,0 +1,45 @@
+package com.mdframe.forge.business.core.hotel.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 菜品分类展示对象。
+ */
+@Data
+public class HotelDishCategoryVO {
+
+    /** 分类ID */
+    private Long id;
+
+    /** 租户ID */
+    private Long tenantId;
+
+    /** 分类名称 */
+    private String name;
+
+    /** 图标(emoji) */
+    private String icon;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 状态:ENABLED-启用, DISABLED-禁用 */
+    private String status;
+
+    /** 菜品数量(关联查询) */
+    private Integer dishCount;
+
+    /** 创建人ID */
+    private Long createBy;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    /** 更新人ID */
+    private Long updateBy;
+
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+}

+ 114 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishVO.java

@@ -0,0 +1,114 @@
+package com.mdframe.forge.business.core.hotel.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 菜品展示对象。
+ */
+@Data
+public class HotelDishVO {
+
+    /** 菜品ID */
+    private Long id;
+
+    /** 租户ID */
+    private Long tenantId;
+
+    /** 菜品名称 */
+    private String name;
+
+    /** 分类ID */
+    private Long categoryId;
+
+    /** 分类名称(关联查询) */
+    private String categoryName;
+
+    /** 基础价格(元) */
+    private BigDecimal price;
+
+    /** 主图(文件ID) */
+    private String image;
+
+    /** 附加图片(逗号分隔fileId) */
+    private String images;
+
+    /** 菜品描述 */
+    private String description;
+
+    /** 标签(逗号分隔) */
+    private String tags;
+
+    /** 预计制作时长(分钟) */
+    private Integer prepTime;
+
+    /** 月销量 */
+    private Integer sales;
+
+    /** 状态:ON_SALE-在售, OFF_SHELF-下架 */
+    private String status;
+
+    /** 是否售罄(0=正常, 1=售罄) */
+    private Integer soldOut;
+
+    /** 是否主厨推荐(0=否, 1=是) */
+    private Integer isRecommended;
+
+    /** 排序权重 */
+    private Integer sortOrder;
+
+    /** 规格组列表(详情查询时填充) */
+    private List<SpecGroupVO> specGroups;
+
+    /** 加料列表(详情查询时填充) */
+    private List<AdditionVO> additions;
+
+    /** 创建人ID */
+    private Long createBy;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    /** 更新人ID */
+    private Long updateBy;
+
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+
+    /**
+     * 规格组VO。
+     */
+    @Data
+    public static class SpecGroupVO {
+        private Long id;
+        private String groupName;
+        private Integer required;
+        private Integer sortOrder;
+        private List<SpecOptionVO> options;
+    }
+
+    /**
+     * 规格选项VO。
+     */
+    @Data
+    public static class SpecOptionVO {
+        private Long id;
+        private String optionName;
+        private BigDecimal priceExtra;
+        private Integer sortOrder;
+    }
+
+    /**
+     * 加料VO。
+     */
+    @Data
+    public static class AdditionVO {
+        private Long id;
+        private String name;
+        private BigDecimal extraPrice;
+        private Integer sortOrder;
+    }
+}

+ 54 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelBusinessHoursMapper.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.mapper.HotelBusinessHoursMapper">
+
+    <!-- 分页查询营业时段(按状态筛选) -->
+    <select id="selectBusinessHoursPage" resultType="com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours">
+        SELECT id, tenant_id, day_of_week, meal_type, meal_name, start_time, end_time,
+               status, sort_order, create_by, create_time, update_by, update_time
+        FROM hotel_business_hours
+        WHERE tenant_id = #{tenantId}
+          AND del_flag = 0
+        <if test="query.status != null and query.status != ''">
+            AND status = #{query.status}
+        </if>
+        ORDER BY sort_order ASC
+    </select>
+
+    <!-- 查询所有营业时段(含禁用) -->
+    <select id="selectAll" resultType="com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours">
+        SELECT id, tenant_id, day_of_week, meal_type, meal_name, start_time, end_time,
+               status, sort_order, create_by, create_time, update_by, update_time
+        FROM hotel_business_hours
+        WHERE tenant_id = #{tenantId}
+          AND del_flag = 0
+        ORDER BY sort_order ASC
+    </select>
+
+    <!-- 查询所有启用的营业时段 -->
+    <select id="selectEnabled" resultType="com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours">
+        SELECT id, tenant_id, day_of_week, meal_type, meal_name, start_time, end_time,
+               status, sort_order, create_by, create_time, update_by, update_time
+        FROM hotel_business_hours
+        WHERE tenant_id = #{tenantId}
+          AND status = 'ENABLED'
+          AND del_flag = 0
+        ORDER BY sort_order ASC
+    </select>
+
+    <!-- 查询当前营业时段(星期匹配 + 时间区间判断) -->
+    <select id="selectCurrentBusinessHours" resultType="com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours">
+        SELECT id, tenant_id, day_of_week, meal_type, meal_name, start_time, end_time,
+               status, sort_order, create_by, create_time, update_by, update_time
+        FROM hotel_business_hours
+        WHERE tenant_id = #{tenantId}
+          AND status = 'ENABLED'
+          AND del_flag = 0
+          AND (day_of_week = #{dayOfWeek} OR day_of_week IS NULL)
+          AND #{currentTime} >= start_time
+          AND #{currentTime} &lt; end_time
+        ORDER BY sort_order ASC
+        LIMIT 1
+    </select>
+
+</mapper>

+ 33 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishCategoryMapper.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.mapper.HotelDishCategoryMapper">
+
+    <!-- 分页查询分类(带菜品数量统计) -->
+    <select id="selectCategoryWithCount" resultType="com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO">
+        SELECT c.id, c.tenant_id, c.name, c.icon, c.sort_order, c.status,
+               c.create_by, c.create_time, c.update_by, c.update_time,
+               IFNULL(d.dish_count, 0) AS dish_count
+        FROM hotel_dish_category c
+        LEFT JOIN (
+            SELECT category_id, COUNT(*) AS dish_count
+            FROM hotel_dish
+            WHERE del_flag = 0
+            GROUP BY category_id
+        ) d ON c.id = d.category_id
+        WHERE c.tenant_id = #{tenantId}
+          AND c.del_flag = 0
+        ORDER BY c.sort_order ASC
+    </select>
+
+    <!-- 查询所有启用的分类 -->
+    <select id="selectEnabledList" resultType="com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO">
+        SELECT id, tenant_id, name, icon, sort_order, status,
+               create_by, create_time, update_by, update_time
+        FROM hotel_dish_category
+        WHERE tenant_id = #{tenantId}
+          AND del_flag = 0
+          AND status = 'ENABLED'
+        ORDER BY sort_order ASC
+    </select>
+
+</mapper>

+ 79 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishMapper.xml

@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.mapper.HotelDishMapper">
+
+    <resultMap id="DishVOMap" type="com.mdframe.forge.business.core.hotel.vo.HotelDishVO">
+        <id column="id" property="id" />
+        <result column="tenant_id" property="tenantId" />
+        <result column="name" property="name" />
+        <result column="category_id" property="categoryId" />
+        <result column="category_name" property="categoryName" />
+        <result column="price" property="price" />
+        <result column="image" property="image" />
+        <result column="images" property="images" />
+        <result column="description" property="description" />
+        <result column="tags" property="tags" />
+        <result column="prep_time" property="prepTime" />
+        <result column="sales" property="sales" />
+        <result column="status" property="status" />
+        <result column="sold_out" property="soldOut" />
+        <result column="is_recommended" property="isRecommended" />
+        <result column="sort_order" property="sortOrder" />
+        <result column="create_by" property="createBy" />
+        <result column="create_time" property="createTime" />
+        <result column="update_by" property="updateBy" />
+        <result column="update_time" property="updateTime" />
+    </resultMap>
+
+    <sql id="DishColumns">
+        d.id, d.tenant_id, d.name, d.category_id,
+        c.name AS category_name,
+        d.price, d.image, d.images, d.description, d.tags,
+        d.prep_time, d.sales, d.status, d.sold_out,
+        d.is_recommended, d.sort_order,
+        d.create_by, d.create_time, d.update_by, d.update_time
+    </sql>
+
+    <!-- 管理后台菜品分页查询 -->
+    <select id="selectAdminPage" resultMap="DishVOMap">
+        SELECT <include refid="DishColumns" />
+        FROM hotel_dish d
+        LEFT JOIN hotel_dish_category c ON c.id = d.category_id AND c.del_flag = 0
+        WHERE d.tenant_id = #{tenantId}
+          AND d.del_flag = 0
+        <if test="query.categoryId != null">
+            AND d.category_id = #{query.categoryId}
+        </if>
+        <if test="query.status != null and query.status != ''">
+            AND d.status = #{query.status}
+        </if>
+        <if test="query.name != null and query.name != ''">
+            AND d.name LIKE CONCAT('%', #{query.name}, '%')
+        </if>
+        ORDER BY d.sort_order ASC, d.sales DESC
+    </select>
+
+    <!-- 菜品详情 -->
+    <select id="selectDetail" resultMap="DishVOMap">
+        SELECT <include refid="DishColumns" />
+        FROM hotel_dish d
+        LEFT JOIN hotel_dish_category c ON c.id = d.category_id AND c.del_flag = 0
+        WHERE d.tenant_id = #{tenantId}
+          AND d.del_flag = 0
+          AND d.id = #{id}
+        LIMIT 1
+    </select>
+
+    <!-- 销量排行 -->
+    <select id="selectSalesRanking" resultMap="DishVOMap">
+        SELECT <include refid="DishColumns" />
+        FROM hotel_dish d
+        LEFT JOIN hotel_dish_category c ON c.id = d.category_id AND c.del_flag = 0
+        WHERE d.tenant_id = #{tenantId}
+          AND d.del_flag = 0
+          AND d.status = 'ON_SALE'
+        ORDER BY d.sales DESC
+        LIMIT #{limit}
+    </select>
+
+</mapper>

+ 34 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishSpecGroupMapper.xml

@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.mapper.HotelDishSpecGroupMapper">
+
+    <!-- 分页查询规格组(关联菜品名称) -->
+    <select id="selectSpecGroupPage" resultType="com.mdframe.forge.business.core.hotel.domain.HotelDishSpecGroup">
+        SELECT g.id, g.tenant_id, g.dish_id, g.group_name, g.required, g.sort_order,
+               g.create_by, g.create_time, g.update_by, g.update_time,
+               d.name AS dishName
+        FROM hotel_dish_spec_group g
+        LEFT JOIN hotel_dish d ON g.dish_id = d.id AND d.del_flag = 0
+        WHERE g.tenant_id = #{tenantId}
+          AND g.del_flag = 0
+        <if test="query.dishName != null and query.dishName != ''">
+            AND d.name LIKE CONCAT('%', #{query.dishName}, '%')
+        </if>
+        <if test="query.groupName != null and query.groupName != ''">
+            AND g.group_name LIKE CONCAT('%', #{query.groupName}, '%')
+        </if>
+        ORDER BY g.sort_order ASC
+    </select>
+
+    <!-- 根据菜品ID查询所有规格组 -->
+    <select id="selectByDishId" resultType="com.mdframe.forge.business.core.hotel.domain.HotelDishSpecGroup">
+        SELECT id, tenant_id, dish_id, group_name, required, sort_order,
+               create_by, create_time, update_by, update_time
+        FROM hotel_dish_spec_group
+        WHERE dish_id = #{dishId}
+          AND tenant_id = #{tenantId}
+          AND del_flag = 0
+        ORDER BY sort_order ASC
+    </select>
+
+</mapper>

+ 23 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishSpecOptionMapper.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.mapper.HotelDishSpecOptionMapper">
+
+    <!-- 根据规格组ID查询所有选项 -->
+    <select id="selectByGroupId" resultType="com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption">
+        SELECT id, tenant_id, group_id, option_name, price_extra, sort_order,
+               create_by, create_time, update_by, update_time
+        FROM hotel_dish_spec_option
+        WHERE group_id = #{groupId}
+          AND del_flag = 0
+        ORDER BY sort_order ASC
+    </select>
+
+    <!-- 根据规格组ID逻辑删除所有选项(写入主键ID作为删除标记) -->
+    <update id="deleteByGroupId">
+        UPDATE hotel_dish_spec_option
+        SET del_flag = id
+        WHERE group_id = #{groupId}
+          AND del_flag = 0
+    </update>
+
+</mapper>

+ 6 - 6
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelRoomMapper.xml

@@ -31,14 +31,14 @@
         LEFT JOIN hotel_room_type rt ON rt.id = r.room_type_id AND rt.del_flag = 0
         WHERE r.tenant_id = #{tenantId}
           AND r.del_flag = 0
-        <if test="roomNo != null and roomNo != ''">
-            AND r.room_no LIKE CONCAT('%', #{roomNo}, '%')
+        <if test="query.roomNo != null and query.roomNo != ''">
+            AND r.room_no LIKE CONCAT('%', #{query.roomNo}, '%')
         </if>
-        <if test="status != null and status != ''">
-            AND r.status = #{status}
+        <if test="query.status != null and query.status != ''">
+            AND r.status = #{query.status}
         </if>
-        <if test="roomTypeId != null">
-            AND r.room_type_id = #{roomTypeId}
+        <if test="query.roomTypeId != null">
+            AND r.room_type_id = #{query.roomTypeId}
         </if>
         ORDER BY r.sort_order ASC, r.room_no ASC
     </select>

+ 323 - 0
forge-server/forge-business/forge-hotel/编码习惯与规范.md

@@ -0,0 +1,323 @@
+# Forge-Hotel 编码习惯与规范
+
+> 本文档记录个人在 Forge 项目中的编码习惯和偏好,所有代码编写(含 AI 辅助生成)必须遵循以下规范。
+> 
+> **适用范围**: forge-hotel 模块及后续所有业务模块
+> **最后更新**: 2026-08-14
+
+---
+
+## 一、后端编码习惯
+
+### 1.1 依赖注入
+
+- **使用 `@Autowired` 字段注入**,不使用 `@RequiredArgsConstructor` 构造器注入
+- 每个依赖字段单独加 `@Autowired` 注解
+- 示例:
+```java
+@RestController
+@RequestMapping("/hotel/dish")
+public class HotelDishController {
+    @Autowired
+    private HotelDishService hotelDishService;
+}
+```
+
+### 1.2 Controller 查询参数
+
+- **分页查询接口的筛选参数必须用实体类接收**,禁止逐个 `@RequestParam`
+- 只有 `@PathVariable`(路径参数如 id)和确实无法用实体类表达的参数(如安全校验参数)才单独接收
+- 状态更新类接口(如 `/{id}/status`)属于操作类,可保持 `@RequestParam`
+- 示例:
+```java
+// ✅ 正确
+public RespInfo<IPage<HotelDishVO>> dishPage(PageQuery pageQuery, HotelDish query) { ... }
+
+// ❌ 错误
+public RespInfo<IPage<HotelDishVO>> dishPage(PageQuery pageQuery,
+    @RequestParam(required = false) Long categoryId,
+    @RequestParam(required = false) String status,
+    @RequestParam(required = false) String name) { ... }
+```
+- Service 接口和 Mapper 方法签名同步使用实体类参数
+- Mapper XML 中用 `@Param("query")` 标注,引用时用 `#{query.fieldName}`
+
+### 1.3 统一返回封装
+
+- 所有接口统一使用 `RespInfo` 封装返回值
+- 成功:`RespInfo.success(data)` 或 `RespInfo.success()`(无返回值时)
+- 失败:`RespInfo.error(msg)` 或抛出 `BusinessException`
+
+### 1.4 实体类规范
+
+- 实体继承 `TenantEntity`(自带 tenantId + 审计字段)
+- 主键使用 `@TableId(value = "id", type = IdType.ASSIGN_ID)`(雪花算法)
+- 逻辑删除字段:`@TableLogic(value = "0", delval = "id")`,类型为 `Long`(bigint),删除时写入主键 ID
+- 使用 Lombok:`@Data` + `@EqualsAndHashCode(callSuper = true)` + `@Serial`
+- 每个字段必须有 Javadoc 注释
+- 示例:
+```java
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_business_hours")
+public class HotelBusinessHours extends TenantEntity {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 状态: ENABLED-启用, DISABLED-禁用 */
+    private String status;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}
+```
+
+### 1.5 SQL 编写规范
+
+- **SQL 必须写在 Mapper XML 中**,禁止在 Service 层用 `LambdaQueryWrapper` 构建查询
+- 例外:仅 `selectById`、`insert`、`updateById`、`deleteById` 等 MyBatis-Plus 内置方法允许
+- 原因:`DataScopeInterceptor` 按 `mapperMethod` 精确匹配改写 SQL
+- Mapper XML 中的查询必须显式过滤未删除数据:`AND del_flag = 0`
+- 原因:自定义 XML SQL 不会被 MP 自动补全逻辑删除条件
+
+### 1.6 操作日志
+
+- 每个 Controller 方法必须加 `@OperationLog` 注解
+- 格式:`@OperationLog(module = "模块名", type = OperationType.XXX, desc = "操作描述")`
+- 操作类型:`QUERY` / `ADD` / `UPDATE` / `DELETE`
+
+### 1.7 包结构
+
+```
+controller/     # REST 控制器
+domain/         # 数据库实体(Entity)
+dto/            # 请求 DTO(新增/修改用)
+vo/             # 响应 VO(查询返回用)
+mapper/         # MyBatis Mapper 接口
+service/        # 服务接口
+  └── impl/     # 服务实现
+constant/       # 常量
+```
+
+### 1.8 Service 层规范
+
+- Service 之间**禁止互相注入**(避免循环依赖)
+- 跨 Service 协调逻辑上提到 Controller 层
+- 事务注解:`@Transactional(rollbackFor = Exception.class)`
+- 校验方法命名:`validateXxx(Xxx entity/dto)`
+- 存在性校验方法命名:`requireXxx(Long id)`(不存在直接抛异常)
+
+---
+
+## 二、前端编码习惯
+
+### 2.1 页面组件选择
+
+- **标准 CRUD 页面**:使用 `AiCrudPage` 组件(自动处理搜索栏、工具栏、表格、分页布局)
+- **分页位置**:右下角(AiCrudPage 内置)
+- **配置少的简单列表**:不加多余筛选条件,保持界面简洁
+- 页面容器样式:`height: 100%`
+
+### 2.2 AiCrudPage 配置规范
+
+- `api-config` 格式:
+```javascript
+:api-config="{
+  list: 'get@/hotel/xxx/page',
+  detail: 'get@/hotel/xxx/:id',
+  add: 'post@/hotel/xxx',
+  update: 'put@/hotel/xxx',
+  delete: 'post@/hotel/xxx/remove/:id',
+}"
+```
+- URL 占位符用**冒号格式**:`:id`,禁止花括号 `{id}`
+- `tableColumns` 和 `editSchema` 必须定义为 `computed`(确保字典异步加载后响应式更新)
+- 操作列使用 `h('a', { class: 'text-primary cursor-pointer hover:opacity-80', onClick: ... })` 模式
+- 刷新方法:`crudRef.value?.refresh()`(不是 reload)
+- 编辑方法:`crudRef.value?.handleEdit(row)`
+
+### 2.3 字段命名
+
+- 前端字段统一使用 **camelCase** 命名
+- 与后端 API 返回字段保持一致
+
+### 2.4 表格渲染规范
+
+- **空值处理**:空值字段显示空白,**不使用 `-` 作为占位符**
+- **图片列**:
+  - 列宽设置 `100px`
+  - 用 `div` 包裹 `AuthImage` 组件,设置 `overflow: hidden` 防止图片溢出
+  - 禁止直接用 `NAvatar` 的 src 属性
+- **状态标签**:使用 `NTag` 组件,配合颜色映射
+```javascript
+const statusColorMap = { ENABLED: 'success', DISABLED: 'default' }
+render: row => h(NTag, { type: statusColorMap[row.status] || 'default', size: 'small' }, 
+  { default: () => statusLabelMap[row.status] || row.status })
+```
+
+### 2.5 操作按钮样式
+
+使用 UnoCSS 语义化颜色类区分操作类型:
+
+| 类名 | 颜色 | 场景 |
+|------|------|------|
+| `text-primary` | 蓝 | 编辑、查看、授权 |
+| `text-warning` | 黄 | 刷新缓存、重置、封禁 |
+| `text-error` | 红 | 删除、强制下线 |
+| `text-success` | 绿 | 启用、发布、通过 |
+| `text-info` | 灰蓝 | 详情、统计 |
+
+### 2.6 字典使用
+
+- **禁止硬编码**下拉选项和状态标签
+- 下拉使用 `DictSelect` 组件或 `useDict()` composable
+- 标签展示使用 `DictTag` 组件
+- Schema 中的字典选项必须用 `computed` 包裹
+
+### 2.7 图片预览
+
+- 采用全屏覆盖层实现(`Teleport to="body"`)
+- `position: fixed` + 半透明黑色背景
+- 右上角 × 关闭按钮,支持点击背景或 ESC 关闭
+- 左右箭头切换图片,底部圆点指示器 + 页码计数
+- 图片自适应:`max-width: 85vw` + `max-height: 80vh`
+
+### 2.8 时间段选择
+
+- 时间类字段使用**下拉选择**(Select),不使用手动输入框
+- 时间选项按业务场景设定间隔(如每 30 分钟一档:09:00, 09:30, 10:00...23:30)
+
+### 2.9 API 文件规范
+
+- 导入路径:`import { request } from '@/utils'`(不是 `@/utils/request`)
+- 函数命名:`getXxxPage` / `getXxxDetail` / `createXxx` / `updateXxx` / `deleteXxx`
+- 按模块用注释分隔:`// ==================== 模块名 ====================`
+- 每个函数加 JSDoc 注释
+
+---
+
+## 三、数据库编码习惯
+
+### 3.1 表结构规范
+
+- 所有业务表必须包含:`id`, `tenant_id`, `create_by`, `create_time`, `create_dept`, `update_by`, `update_time`
+- 字符集 `utf8mb4`,引擎 `InnoDB`
+- 主键用 `bigint`(雪花算法),不用自增
+- 金额字段用 `bigint`,单位**分**
+- 时间字段用 `datetime`(对应 Java `LocalDateTime`)
+- 状态字段用 `varchar`,值为大写英文(如 `ENABLED` / `DISABLED`、`ON_SALE` / `OFF_SHELF`)
+
+### 3.2 逻辑删除规范
+
+- 业务表默认使用逻辑删除
+- `del_flag` 字段类型 `bigint NOT NULL DEFAULT 0`
+- 删除时写入当前行主键 ID(不是固定值 `1`)
+- 实体必须显式声明 `@TableLogic(value = "0", delval = "id")`
+
+### 3.3 Flyway 迁移脚本
+
+- 命名格式:`V<版本号>__<lower_snake_case_description>.sql`
+- 版本号必须大于 `1.0.0`,单调递增
+- 脚本必须具备**防重复保护**:
+  - 建表:`CREATE TABLE IF NOT EXISTS`
+  - 插入:`INSERT ... SELECT ... WHERE NOT EXISTS`
+  - 新增列/索引前查 `information_schema`
+- `INSERT` 必须显式写列名,禁止依赖表字段顺序
+- 业务内置数据 `tenant_id` 必须为 `1`,禁止写 `0`
+- 菜单资源 SQL 使用动态 `parent_id`(子查询),避免硬编码 ID
+- 字典数据格式:`sys_dict_type` + `sys_dict_data`,`dict_value` 与后端枚举值保持一致
+
+### 3.4 字典规范
+
+- 字典类型命名:小写下划线,业务级用 `hotel_` 前缀,系统级用 `sys_` 前缀
+- `dict_value` 必须与后端枚举/状态值严格一致
+- `dict_label` 只负责展示文案
+- `list_class` 负责标签样式(success/primary/warning/info/default)
+- 每个字典数据单独一行 INSERT + WHERE NOT EXISTS 防重复
+
+---
+
+## 四、API 设计习惯
+
+### 4.1 RESTful 风格
+
+| 操作 | HTTP 方法 | 路径 | 说明 |
+|------|-----------|------|------|
+| 分页查询 | GET | `/xxx/page` | 筛选参数用实体类接收 |
+| 查询全部 | GET | `/xxx/all` 或 `/xxx/list` | 不分页列表 |
+| 查询详情 | GET | `/xxx/:id` | 路径参数 |
+| 新增 | POST | `/xxx` | RequestBody |
+| 修改 | PUT | `/xxx` | RequestBody |
+| 删除 | POST | `/xxx/remove/:id` | 路径参数 |
+| 状态切换 | POST | `/xxx/:id/toggle` 或 `/:id/toggle-status` | 路径参数 |
+| 状态更新 | PUT | `/xxx/:id/status` | `@RequestParam String status` |
+| 批量操作 | POST | `/xxx/batch` | RequestBody |
+
+### 4.2 权限标识
+
+- 格式:`模块:资源:操作`,如 `hotel:dish:query`、`hotel:businessHours:add`
+- 按钮权限资源类型 `resource_type = 3`
+
+---
+
+## 五、交互设计习惯
+
+### 5.1 列表页面
+
+- 配置项少的列表页不加多余筛选(如营业时段不加餐段筛选)
+- 分页统一在右下角
+- 操作列包含:编辑 + 状态切换 + 删除
+
+### 5.2 批量操作
+
+- 有批量生成的管理页面,应同步提供批量删除、批量下载(ZIP)、单条下载功能
+- 保证操作完整性与用户体验一致性
+
+### 5.3 危险操作
+
+- 删除、解绑等不可逆操作必须弹出**二次确认**
+- 已绑定的二维码显示"解绑"按钮而非"重新绑定"
+
+### 5.4 扫码绑定
+
+- 绑定成功后,点击"完成"跳转至扫码绑定主界面(无参数状态)
+- 解绑后返回扫码界面
+
+---
+
+## 六、多租户习惯
+
+### 6.1 租户上下文
+
+- 业务数据 `tenant_id` 必须设为 `1`(默认租户),禁止设 `0`
+- 免登录场景(如扫码):先忽略租户过滤查到实体,再根据实体的 `tenantId` 动态设置租户上下文
+- 使用 `TenantContextHolder.getTenantId()` 获取当前租户 ID
+- 兜底逻辑:`return tenantId == null ? 1L : tenantId;`
+
+---
+
+## 七、其他习惯
+
+### 7.1 注释风格
+
+- 实体类每个字段必须有 Javadoc 注释
+- Controller 每个方法必须有 Javadoc(描述接口用途)
+- Service 接口方法必须有 Javadoc(含参数说明)
+- 关键业务逻辑加行内注释说明
+
+### 7.2 安全红线
+
+- 禁止硬编码密钥、AK/SK、数据库密码
+- 禁止在日志中打印手机号、身份证、银行卡
+- API Key/Secret 返回前端必须脱敏(保留前4后4,中间 `****`)
+
+### 7.3 构建与验证
+
+- Maven 构建:`mvn compile -pl forge-business/forge-hotel -am -DskipTests`
+- 前端 Lint:`pnpm lint:fix`
+- 改完代码必须编译/Lint 验证通过

+ 420 - 0
forge-server/forge-business/forge-hotel/酒店AGENTS.md

@@ -0,0 +1,420 @@
+# AGENTS.md - 酒店二维码模块开发规范
+
+> **重要**: 此文件会被 IDEA QoderWork 插件自动读取,为 AI 助手提供项目上下文和开发规范
+
+**项目**: Forge Admin - 酒店二维码模块 (Hotel QR Code Module)  
+**技术栈**: Spring Boot 3.2 + Vue 3.5 + Naive UI 2.42 + MyBatis-Plus 3.5  
+**最后更新**: 2026-08-10
+
+---
+
+## 一、核心开发规范(必须遵守)
+
+### 1.1 后端规范
+
+**启动类**: `ForgeAdminServerApplication`(主应用入口,聚合所有插件)
+
+**依赖注入**:
+```java
+@RequiredArgsConstructor  // ✅ Lombok 构造器注入
+private final HotelQrCodeMapper qrCodeMapper;
+
+// ❌ 禁止使用 @Autowired
+```
+
+**响应体**:
+```java
+// ✅ 必须使用 RespInfo
+return RespInfo.success(data);
+return RespInfo.error("错误信息");
+
+// ❌ 禁止使用 DataResult / ResponseUtil
+```
+
+**ID生成**:
+```java
+// ✅ 使用雪花算法(MyBatis-Plus ASSIGN_ID)
+@TableId(value = "id", type = IdType.ASSIGN_ID)
+private Long id;
+
+// ❌ 禁止使用 UUID / 自增ID
+```
+
+**实体继承**:
+```java
+// ✅ 所有实体必须继承 TenantEntity
+public class HotelQrCode extends TenantEntity { ... }
+// TenantEntity 包含: tenantId, createBy, createTime, createDept, updateBy, updateTime
+```
+
+**逻辑删除**:
+```java
+// ✅ 必须显式声明 @TableLogic
+@TableLogic(value = "0", delval = "id")
+private Long delFlag;
+
+// Mapper XML 查询必须显式过滤: AND del_flag = 0
+```
+
+**Controller路由**:
+```java
+@RestController
+@RequestMapping("/hotel")
+public class HotelQrCodeController { ... }
+
+// 实际访问: /hotel/qrcode/page, /hotel/room/page 等
+```
+
+**SQL 规范**:
+- 查询类 SQL **禁止**在 Service 层用 `LambdaQueryWrapper` 构建
+- 必须写在 Mapper XML 中(DataScopeInterceptor 按 mapperMethod 精确匹配)
+- 例外:仅单表 `selectById`、`insert`、`updateById`、`deleteById` 等内置方法允许
+
+**租户 ID 规则**:
+- 业务数据的 `tenant_id` **必须设为 `1`**(默认租户),**禁止设 `0`**
+- `TenantLineInnerInterceptor` 自动追加 `WHERE tenant_id = 当前租户ID`
+
+### 1.2 前端规范
+
+**组件语法**:
+```vue
+<!-- ✅ 必须使用 Composition API -->
+<script setup>
+import { ref, computed } from 'vue'
+const data = ref([])
+</script>
+
+<!-- ❌ 禁止使用 Options API (Vue 2语法) -->
+```
+
+**列表页面**:
+```vue
+<!-- ✅ 必须使用 AiCrudPage 组件 -->
+<AiCrudPage
+  ref="crudRef"
+  :api-config="{ list: 'get@/hotel/qrcode/page' }"
+  :search-schema="searchSchema"
+  :columns="tableColumns"
+  row-key="id"
+/>
+
+<!-- ❌ 禁止手动拼接 NInput/NSelect/NButton 搜索栏 -->
+```
+
+**字典使用**:
+```vue
+<!-- ✅ 必须使用字典组件,禁止硬编码 -->
+<script setup>
+import DictTag from '@/components/DictTag.vue'
+import { useDict } from '@/composables/useDict'
+const { dict } = useDict('sys_normal_disable')
+</script>
+
+<template>
+  <DictTag dictType="sys_normal_disable" :value="row.status" />
+</template>
+
+<!-- ❌ 禁止在前端写死 options 或标签映射 -->
+```
+
+**按钮样式约定**:
+| 类名 | 颜色 | 场景 |
+|------|------|------|
+| `text-primary` | 蓝 | 编辑、查看、绑定、下载 |
+| `text-warning` | 黄 | 解绑、禁用、重置 |
+| `text-error` | 红 | 删除 |
+| `text-success` | 绿 | 启用 |
+| `text-info` | 灰蓝 | 详情、日志 |
+
+---
+
+## 二、项目架构
+
+### 2.1 模块位置
+
+```
+forge-server/forge-business/forge-hotel/     # 酒店业务模块
+├── controller/    # REST 控制器
+├── service/       # 服务接口
+│   └── impl/      # 服务实现
+├── mapper/        # MyBatis Mapper 接口 + XML
+├── domain/        # 数据库实体
+├── dto/           # 请求 DTO
+── vo/            # 响应 VO
+├── constant/      # 常量定义
+└── utils/         # 工具类(二维码生成、签名校验)
+
+forge-admin-ui/src/views/hotel/            # 前端页面
+├── qrcode.vue     # 二维码管理(AiCrudPage)
+├── room.vue       # 房间管理(AiCrudPage)
+└── roomType.vue   # 房型管理(AiCrudPage)
+
+forge-h5-ui/src/pages/hotel/             # H5 移动端
+└── scan-bind.vue  # 扫码绑定房间页面
+```
+
+### 2.2 三端架构
+
+| 端 | 技术 | 部署 | 核心功能 |
+|---|------|------|---------|
+| PC 管理后台 | Vue3 + Naive UI + AiCrudPage | PC浏览器 | 批量生成、批量删除、批量下载ZIP、绑定/重绑/解绑、状态管理 |
+| H5 移动端 | UniApp + html5-qrcode | 手机浏览器(HTTPS) | 员工扫码绑定房间、查看绑定状态 |
+| 开放接口 | REST API | 免登录 | 顾客扫码查询二维码信息 |
+
+---
+
+## 三、技术栈清单
+
+### 后端
+- Java 17 + Spring Boot 3.2
+- MyBatis-Plus 3.5 + 动态数据源
+- Sa-Token 1.38(认证授权)
+- 多租户隔离(TenantLineInnerInterceptor)
+- ZXing 3.5.3(二维码生成)
+- MySQL 8.0+ / Redis 6.0+
+
+### 前端
+- Vue 3.5 + Naive UI 2.42
+- Vite 7 + Pinia 3 + UnoCSS 66
+- AiCrudPage / AiSearch / AiTable(框架标准组件)
+- html5-qrcode(H5 扫码)
+
+---
+
+## 四、数据库设计
+
+### 4.1 表结构
+
+**hotel_qr_code(二维码表)**:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键(雪花算法) |
+| tenant_id | BIGINT | 租户ID,默认1 |
+| short_code | VARCHAR(32) | 二维码唯一短码(8位大写字母+数字) |
+| sign | VARCHAR(64) | MD5签名值 |
+| room_id | BIGINT | 绑定房间ID(NULL=未绑定) |
+| room_no | VARCHAR(32) | 冗余房间号 |
+| status | VARCHAR(16) | 状态:0=启用, 1=禁用(对应字典 sys_normal_disable) |
+| batch_no | VARCHAR(32) | 批次号(格式:QR+yyyyMMddHHmmss) |
+| bind_user_id | BIGINT | 最近绑定人ID |
+| bind_user_name | VARCHAR(64) | 最近绑定人姓名 |
+| bind_time | DATETIME | 最近绑定时间 |
+| unbind_user_id | BIGINT | 最近解绑人ID |
+| unbind_user_name | VARCHAR(64) | 最近解绑人姓名 |
+| unbind_time | DATETIME | 最近解绑时间 |
+| del_flag | BIGINT | 逻辑删除标记(0=未删除,删除时写入主键ID) |
+
+**唯一索引**: `UNIQUE (tenant_id, short_code, del_flag)`
+
+**hotel_room(房间表)**:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| tenant_id | BIGINT | 租户ID |
+| room_no | VARCHAR(32) | 房间号(租户内唯一) |
+| room_type_id | BIGINT | 房型ID |
+| floor_no | VARCHAR(16) | 楼层 |
+| status | VARCHAR(16) | 状态:VACANT/OCCUPIED/CLEANING/MAINTENANCE |
+| sort_order | INT | 排序号 |
+| remark | VARCHAR(256) | 备注 |
+| del_flag | BIGINT | 逻辑删除标记 |
+
+**唯一索引**: `UNIQUE (tenant_id, room_no, del_flag)`
+
+**hotel_room_type(房型表)**:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| tenant_id | BIGINT | 租户ID |
+| type_name | VARCHAR(64) | 房型名称 |
+| description | VARCHAR(256) | 房型描述 |
+| sort_order | INT | 排序号 |
+| del_flag | BIGINT | 逻辑删除标记 |
+
+**hotel_qr_bind_log(绑定日志表)**:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| tenant_id | BIGINT | 租户ID |
+| qr_code_id | BIGINT | 二维码ID |
+| short_code | VARCHAR(32) | 冗余短码 |
+| room_id | BIGINT | 房间ID |
+| room_no | VARCHAR(32) | 冗余房间号 |
+| action_type | VARCHAR(16) | 操作类型:BIND/REBIND/UNBIND |
+| operator_id | BIGINT | 操作人ID |
+| operator_name | VARCHAR(64) | 操作人姓名 |
+| operate_time | DATETIME | 操作时间 |
+| remark | VARCHAR(256) | 备注 |
+
+### 4.2 字典数据
+
+| 字典类型 | 字典值 | 标签 | 标签样式 |
+|----------|--------|------|----------|
+| sys_normal_disable | 0 | 正常 | success |
+| sys_normal_disable | 1 | 停用 | danger |
+| hotel_room_status | VACANT | 空闲 | success |
+| hotel_room_status | OCCUPIED | 入住 | primary |
+| hotel_room_status | CLEANING | 打扫中 | warning |
+| hotel_room_status | MAINTENANCE | 维护中 | danger |
+| hotel_qr_bind_action | BIND | 绑定 | success |
+| hotel_qr_bind_action | REBIND | 重绑 | warning |
+| hotel_qr_bind_action | UNBIND | 解绑 | danger |
+
+---
+
+## 五、API 接口清单
+
+### 5.1 管理端接口(需 Sa-Token 登录)
+
+**二维码管理**:
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/hotel/qrcode/page` | 分页查询二维码 |
+| GET | `/hotel/qrcode/:id` | 查询二维码详情 |
+| POST | `/hotel/qrcode/batch-generate` | 批量生成二维码 |
+| POST | `/hotel/qrcode/:id/bind` | 绑定房间 |
+| POST | `/hotel/qrcode/:id/rebind` | 重新绑定房间 |
+| POST | `/hotel/qrcode/:id/unbind` | 解绑房间 |
+| PUT | `/hotel/qrcode/:id/status` | 更新二维码状态 |
+| POST | `/hotel/qrcode/batch-remove` | 批量删除二维码(逻辑删除) |
+| POST | `/hotel/qrcode/batch-download` | 批量下载二维码ZIP |
+| GET | `/hotel/qrcode/:id/bind-logs` | 查询绑定日志 |
+
+**房间管理**:
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/hotel/room/page` | 分页查询房间 |
+| GET | `/hotel/room/:id` | 查询房间详情 |
+| POST | `/hotel/room` | 新增房间 |
+| PUT | `/hotel/room` | 修改房间 |
+| POST | `/hotel/room/remove/:id` | 删除房间 |
+| GET | `/hotel/room/list-all` | 查询全部房间(下拉用) |
+
+**房型管理**:
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/hotel/room-type/list` | 查询房型列表 |
+| GET | `/hotel/room-type/:id` | 查询房型详情 |
+| POST | `/hotel/room-type` | 新增房型 |
+| PUT | `/hotel/room-type` | 修改房型 |
+| POST | `/hotel/room-type/remove/:id` | 删除房型 |
+
+### 5.2 开放接口(免登录)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/hotel/open/scan?shortCode=xxx&sign=xxx` | 扫码查询二维码信息 |
+
+---
+
+## 六、二维码 URL 格式
+
+```
+{baseUrl}/#/pages/hotel/scan-bind?c={shortCode}&t={tenantId}&s={sign}
+```
+
+| 参数 | 说明 |
+|------|------|
+| c | 二维码短码(8位) |
+| t | 租户ID |
+| s | MD5签名值 = MD5(shortCode + tenantId + secret) |
+
+**签名密钥配置**:
+- 默认值: `hotel_qr_default_secret`
+- 生产环境: 通过环境变量 `HOTEL_QR_SECRET` 覆盖
+
+---
+
+## 七、关键业务逻辑
+
+### 7.1 批量生成
+- 生成数量:10/50/100/200 个一批,上限 500
+- 短码:8位大写字母+数字,租户内唯一
+- 批次号:QR + yyyyMMddHHmmss
+- 状态:默认启用(status=0)
+
+### 7.2 绑定/重绑/解绑
+- **绑定**:未绑定的二维码可绑定房间,记录绑定人/绑定时间
+- **重绑**:已绑定的二维码可重新绑定,覆盖原绑定关系,更新绑定人/绑定时间
+- **解绑**:清除绑定关系,记录解绑人/解绑时间(不覆盖绑定信息)
+- 所有操作均记录到 `hotel_qr_bind_log` 表
+
+### 7.3 批量删除
+- 仅允许删除未绑定房间的二维码
+- 已绑定的二维码拒绝删除,提示先解绑
+- 逻辑删除(del_flag 写入主键ID)
+
+### 7.4 批量下载ZIP
+- 勾选多个二维码,后端生成 PNG 图片打包为 ZIP
+- 文件名以短码命名:`{shortCode}.png`
+- 单条下载:操作列"下载"按钮
+
+### 7.5 扫码查询(开放接口)
+- 免登录,根据 shortCode 查询二维码(忽略租户过滤)
+- 签名校验:MD5(shortCode + tenantId + secret)
+- 使用二维码所属租户 ID 设置租户上下文,执行后续查询
+
+---
+
+## 八、环境变量
+
+| 变量名 | 必填 | 默认值 | 说明 |
+|--------|------|--------|------|
+| HOTEL_QR_SECRET | 否 | hotel_qr_default_secret | 二维码签名密钥(生产环境必须修改) |
+
+---
+
+## 九、后续扩展预留
+
+| 扩展点 | 说明 |
+|--------|------|
+| 钉钉 H5 集成 | 通过 DingTalk SSO 获取员工身份,调用绑定/重绑接口 |
+| 小程序 openid 绑定 | 在 `/hotel/open/` 下新增小程序专用接口 |
+| 顾客点餐流程 | 扫码后进入点餐 H5/小程序页面 |
+| 房间状态实时推送 | WebSocket 推送房间状态变更 |
+
+---
+
+## 十、给 AI 助手的指令
+
+当协助开发酒店模块时,请:
+
+1. **严格遵循规范**: 使用 `@RequiredArgsConstructor`、`RespInfo`、雪花算法ID、Composition API
+2. **使用 AiCrudPage**: 列表页面必须使用 `AiCrudPage` 组件,禁止手动拼接搜索栏
+3. **使用字典组件**: 状态/类型等字段必须使用 `DictTag` / `useDict()`,禁止硬编码
+4. **SQL 写在 XML**: 查询类 SQL 必须写在 Mapper XML 中,禁止 Service 层用 LambdaQueryWrapper
+5. **逻辑删除**: 所有业务表默认逻辑删除,`@TableLogic` 必须显式声明
+6. **租户隔离**: 业务数据 `tenant_id=1`,查询自动追加租户过滤
+7. **参考现有代码**: 查看 `forge-hotel` 模块中已有实现
+
+**禁止**:
+-  不使用 `@Autowired`(使用 `@RequiredArgsConstructor`)
+- ❌ 不使用 `DataResult` / `ResponseUtil`(使用 `RespInfo`)
+- ❌ 不使用 UUID / 自增ID(使用雪花算法)
+- ❌ 不在 Service 层构建查询 SQL(写在 Mapper XML)
+-  不手动拼接搜索栏(使用 AiCrudPage)
+- ❌ 不硬编码字典值(使用 DictTag / useDict)
+- ❌ 不设 `tenant_id=0`(必须为 1)
+
+---
+
+## 十一、文件路径
+
+- 后端模块: `forge-server/forge-business/forge-hotel/`
+- PC前端页面: `forge-admin-ui/src/views/hotel/`
+- H5前端页面: `forge-h5-ui/src/pages/hotel/`
+- API文件: `forge-admin-ui/src/api/hotel.js`
+- 数据库迁移: `forge-server/db/migration/`
+- 开发文档: `forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md`
+
+---
+
+**配置版本**: v2.0  
+**维护者**: QoderWork AI Assistant

+ 18 - 10
forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md

@@ -11,9 +11,12 @@
 | 功能 | 说明 |
 |------|------|
 | 二维码批量生成 | 批量生成不绑定房间的二维码,支持 10/50/100/200 个一批 |
-| 二维码绑定/重绑/解绑 | 员工通过 PC 后台或钉钉 H5 扫码绑定房间,记录操作人和时间 |
-| 二维码状态管理 | 启用/禁用二维码 |
+| 二维码绑定/重绑/解绑 | 员工通过 PC 后台扫码绑定房间,记录操作人和时间 |
+| 二维码状态管理 | 启用/禁用二维码(字典 sys_normal_disable: 0=启用, 1=禁用) |
 | 绑定日志追溯 | 完整记录每次绑定/重绑/解绑操作 |
+| 批量删除 | 勾选多个二维码批量逻辑删除,已绑定房间的拒绝删除 |
+| 批量下载ZIP | 勾选多个二维码生成 PNG 图片打包为 ZIP 下载 |
+| 单条下载 | 操作列下载单个二维码 PNG 图片 |
 | 房间管理 | 房间 CRUD,关联房型 |
 | 房型管理 | 房型 CRUD |
 | 扫码查询(开放接口) | 免登录接口,供顾客/钉钉H5/小程序调用 |
@@ -21,7 +24,7 @@
 ### 1.2 三端架构
 
 ```
-PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重绑/解绑、禁用/启用
+PC 管理后台(Sa-Token)  →  批量生成、列表、批量删除、批量下载ZIP、单条下载、绑定/重绑/解绑、禁用/启用
 钉钉 H5(DingTalk SSO)  →  员工扫码绑定房间、重绑、查看绑定状态
 微信/支付宝小程序         →  顾客扫码点餐(免登录,调用 /hotel/open/scan)
 ```
@@ -71,11 +74,14 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
 | sign | VARCHAR(64) | MD5签名值 |
 | room_id | BIGINT | 绑定房间ID(NULL=未绑定) |
 | room_no | VARCHAR(32) | 冗余房间号 |
-| status | VARCHAR(16) | 状态:ACTIVE/DISABLED |
+| status | VARCHAR(16) | 状态:0=启用, 1=禁用(对应字典 sys_normal_disable) |
 | batch_no | VARCHAR(32) | 批次号(格式:QR+yyyyMMddHHmmss) |
 | bind_user_id | BIGINT | 最近绑定人ID |
 | bind_user_name | VARCHAR(64) | 最近绑定人姓名 |
 | bind_time | DATETIME | 最近绑定时间 |
+| unbind_user_id | BIGINT | 最近解绑人ID |
+| unbind_user_name | VARCHAR(64) | 最近解绑人姓名 |
+| unbind_time | DATETIME | 最近解绑时间 |
 | del_flag | BIGINT | 逻辑删除标记 |
 
 **唯一索引**: `UNIQUE (tenant_id, short_code)`
@@ -109,8 +115,8 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
 
 | 字典类型 | 字典值 | 标签 | 标签样式 |
 |----------|--------|------|----------|
-| hotel_qr_status | ACTIVE | 启用 | success |
-| hotel_qr_status | DISABLED | 禁用 | danger |
+| sys_normal_disable | 0 | 正常 | success |
+| sys_normal_disable | 1 | 停用 | danger |
 | hotel_room_status | VACANT | 空闲 | success |
 | hotel_room_status | OCCUPIED | 入住 | primary |
 | hotel_room_status | CLEANING | 打扫中 | warning |
@@ -136,6 +142,8 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
 | POST | `/hotel/qrcode/:id/rebind` | 重新绑定房间 |
 | POST | `/hotel/qrcode/:id/unbind` | 解绑房间 |
 | PUT | `/hotel/qrcode/:id/status` | 更新二维码状态 |
+| POST | `/hotel/qrcode/batch-remove` | 批量删除二维码(逻辑删除) |
+| POST | `/hotel/qrcode/batch-download` | 批量下载二维码ZIP |
 | GET | `/hotel/qrcode/:id/bind-logs` | 查询绑定日志 |
 
 #### 房间管理
@@ -179,7 +187,7 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
   "data": {
     "qrCodeId": 1234567890,
     "shortCode": "AB12CD34",
-    "status": "ACTIVE",
+    "status": "0",
     "bound": false,
     "room": null,
     "bindInfo": null
@@ -194,7 +202,7 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
   "data": {
     "qrCodeId": 1234567890,
     "shortCode": "AB12CD34",
-    "status": "ACTIVE",
+    "status": "0",
     "bound": true,
     "room": {
       "roomId": 9876543210,
@@ -235,7 +243,7 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
 
 | 页面 | 路径 | 说明 |
 |------|------|------|
-| 二维码管理 | `forge-admin-ui/src/views/hotel/qrcode.vue` | 自定义页面(批量生成、绑定/重绑/解绑、状态切换、日志查看) |
+| 二维码管理 | `forge-admin-ui/src/views/hotel/qrcode.vue` | AiCrudPage 配置化页面(批量生成、批量删除、批量下载ZIP、单条下载、绑定/重绑/解绑、状态切换、日志查看) |
 | 房间管理 | `forge-admin-ui/src/views/hotel/room.vue` | AiCrudPage 配置化页面 |
 | 房型管理 | `forge-admin-ui/src/views/hotel/roomType.vue` | AiCrudPage 配置化页面 |
 | API 文件 | `forge-admin-ui/src/api/hotel.js` | 统一 API 封装 |
@@ -345,6 +353,6 @@ PC 管理后台(Sa-Token)  →  批量生成、列表、导出、绑定/重
 |--------|------|
 | 钉钉 H5 集成 | 通过 DingTalk SSO 获取员工身份,调用绑定/重绑接口 |
 | 小程序 openid 绑定 | 在 `/hotel/open/` 下新增小程序专用接口 |
-| 二维码导出打印 | 使用 EasyExcel 批量导出二维码图片 |
+| 二维码导出打印 | 使用 EasyExcel 批量导出二维码图片(已实现批量下载ZIP) |
 | 顾客点餐流程 | 扫码后进入点餐 H5/小程序页面 |
 | 房间状态实时推送 | WebSocket 推送房间状态变更 |

+ 456 - 0
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md

@@ -0,0 +1,456 @@
+### 1.3 Forge 已有代码
+
+```
+forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/
+├── controller/
+│   ├── HotelQrCodeController.java      ✅ 二维码管理
+│   ├── HotelBusinessHoursController.java ✅ 营业时段管理
+│   ├── HotelDishSpecController.java    ✅ 菜品规格管理(规格组 + 选项)
+│   └── open/
+│       └── HotelOpenController.java    ✅ 开放接口(扫码查询)
+├── domain/
+│   ├── HotelQrCode.java                ✅ 二维码实体
+│   ├── HotelQrBindLog.java             ✅ 绑定日志实体
+│   ├── HotelRoom.java                  ✅ 房间实体
+│   ├── HotelRoomType.java              ✅ 房型实体
+│   ├── HotelBusinessHours.java         ✅ 营业时段实体
+│   ├── HotelDishSpecGroup.java         ✅ 规格组实体
+│   └── HotelDishSpecOption.java        ✅ 规格选项实体
+├── dto/
+│   ├── QrCodeBatchGenerateDTO.java     ✅
+│   ├── QrCodeBindDTO.java              ✅
+│   ├── QrCodeQueryDTO.java             ✅
+│   ├── HotelRoomDTO.java               ✅
+│   └── HotelRoomTypeDTO.java           ✅
+├── vo/
+│   ├── HotelQrCodeVO.java              ✅
+│   ├── HotelQrBindLogVO.java           ✅
+│   ├── QrCodeScanVO.java               ✅
+│   ├── HotelRoomVO.java                ✅
+│   └── HotelRoomTypeVO.java            ✅
+├── mapper/
+│   ├── HotelQrCodeMapper.java          ✅
+│   ├── HotelQrBindLogMapper.java       ✅
+│   ├── HotelRoomMapper.java            ✅
+│   ├── HotelRoomTypeMapper.java        ✅
+│   ├── HotelBusinessHoursMapper.java   ✅
+│   ├── HotelDishSpecGroupMapper.java   ✅
+│   └── HotelDishSpecOptionMapper.java  ✅
+├── service/
+│   ├── HotelQrCodeService.java         ✅
+│   ├── HotelBusinessHoursService.java  ✅
+│   ├── HotelDishSpecGroupService.java  ✅
+│   ├── HotelDishSpecOptionService.java ✅
+│   └── impl/
+│       ├── HotelQrCodeServiceImpl.java ✅
+│       ├── HotelBusinessHoursServiceImpl.java ✅
+│       ├── HotelDishSpecGroupServiceImpl.java ✅
+│       └── HotelDishSpecOptionServiceImpl.java ✅
+├── constant/
+│   └── HotelQrConstants.java           ✅
+└── utils/
+    └── QrCodeUtils.java                ✅
+
+forge-admin-ui/src/views/hotel/
+├── qrcode.vue                          ✅ 二维码管理页面(AiCrudPage)
+├── room.vue                            ✅ 房间管理页面(AiCrudPage)
+├── dish.vue                            ✅ 菜品管理页面(AiCrudPage,含图片预览/上下架/售罄)
+├── dishCategory.vue                    ✅ 菜品分类管理页面(AiCrudPage,含启禁用)
+├── dishSpec.vue                        ✅ 菜品规格管理(AiCrudPage + 选项管理弹窗)
+└── businessHours.vue                   ✅ 营业时段管理(AiCrudPage,时间段下拉选择)
+
+forge-admin-ui/src/api/
+└── hotel.js                            ✅ 酒店模块 API(含菜品/分类/规格/营业时段接口)
+
+forge-h5-ui/src/pages/hotel/
+└── scan-bind.vue                       ✅ 扫码绑定页面
+```
+
+---
+
+## 二、模块迁移状态总览
+
+| # | 模块 | 后端 | 数据库 | 前端 PC | 前端 H5 | 状态 |
+|---|------|------|--------|--------|--------|------|
+| 1 | room(房间/房型管理) | ✅ | ✅ | ✅ | ✅ | **已完成** |
+| 2 | qrcode(二维码管理) | ✅ | ✅ | ✅ | ✅ | **已完成** |
+| 3 | open(开放扫码接口) | ✅ | — | — | ✅ | **已完成** |
+| 4 | dish-spec(菜品规格管理) | ✅ | ✅ | ✅ | — | **已完成** |
+| 5 | business-hours(营业时段) | ✅ | ✅ | ✅ | — | **已完成** |
+| 6 | hotelconfig(酒店基础配置) | ❌ | ❌ | ❌ | — | **暂缓**(暂不需要,配送费 + 预计配送时长待讨论) |
+| 7 | dish(菜品管理) | ✅ | ✅ | ✅ | — | **核心已完成**(加料/日志待开发) |
+| 8 | order(订单管理) | ❌ | ❌ | ❌ | ❌ | **待开发** |
+| 9 | notification(实时通知) | ❌ | — | ❌ | — | **待开发** |
+
+---
+
+## 三、各模块详细任务清单
+
+### 3.1 hotelconfig — 酒店基础配置 ⏸️ 暂缓
+
+> **状态**: 暂缓开发。暂时用不到,其中**配送费**和**预计配送时长**功能待后续讨论后再实现。
+
+#### 后端(待开发)
+
+- [ ] 实体:`HotelConfig.java` — 酒店基础配置(名称/主题/配送费/预计配送时长)
+- [ ] Mapper: `HotelConfigMapper.java` + XML
+- [ ] Service: `HotelConfigService.java` — 获取/保存配置
+- [ ] Controller: `HotelConfigController.java` — 配置接口
+
+#### 数据库(待开发)
+
+- [ ] 建表:`hotel_config`(IF NOT EXISTS)
+- [ ] 菜单:酒店配置管理(sys_resource)
+
+#### 前端(待开发)
+
+- [ ] 页面:`forge-admin-ui/src/views/hotel/config.vue` — 酒店基础配置表单
+
+#### ⚠️ 待讨论项
+
+- **配送费规则**:按距离/按区域/固定费用?是否支持阶梯定价?
+- **预计配送时长**:固定值还是动态计算?单位分钟?
+- 这两项功能需要与业务方确认具体需求后再开发
+
+#### 源框架参考
+
+| 文件 | 说明 |
+|------|------|
+| `hotelconfig/domain/HotelConfig.java` | 配置实体 |
+| `hotelconfig/domain/HotelBusinessHours.java` | 营业时段实体 |
+| `hotelconfig/controller/HotelConfigController.java` | 全部接口 |
+| `hotelconfig/service/HotelConfigService.java` | 全部业务逻辑 |
+| `hotelconfig/mapper/xml/HotelConfigMapper.xml` | 配置查询 SQL |
+| 前端:`views/hotel/admin/HotelConfigManagement.vue` | 配置管理页面 |
+| 前端:`views/hotel/admin/BusinessHoursManagement.vue` | 营业时段页面 |
+| 前端:`views/hotel/admin/BusinessHoursForm.vue` | 营业时段表单 |
+| 前端:`api/hotel/HotelConfigService.js` | 配置 API |
+| 前端:`api/hotel/HotelBusinessHoursService.js` | 营业时段 API |
+
+---
+
+### 3.2 dish — 菜品管理 ⭐⭐⭐
+
+#### 后端
+
+- [x] 实体:`HotelDish.java` — 菜品主表
+- [x] 实体:`HotelDishCategory.java` — 菜品分类
+- [x] 实体:`HotelDishSpecGroup.java` — 规格组(源框架同时存在 spec 和 spec_group,Forge 统一用 spec_group)
+- [x] 实体:`HotelDishSpecOption.java` — 规格选项
+- [x] 实体:`HotelDishAddition.java` — 加料
+- [x] 实体:`HotelDishLog.java` — 操作日志
+- [x] DTO: `HotelDishDTO.java` — 菜品传输对象
+- [x] DTO: `HotelDishCategoryDTO.java` — 分类传输对象
+- [x] DTO: `DishBatchOperationDTO.java` — 批量操作传输对象
+- [x] VO: `HotelDishVO.java` / `HotelDishCategoryVO.java` — 视图对象
+- [x] Mapper: `HotelDishMapper.java` + XML(含管理后台分页查询)
+- [x] Mapper: `HotelDishCategoryMapper.java` + XML(含分页查询)
+- [x] Mapper: `HotelDishSpecGroupMapper.java`
+- [x] Mapper: `HotelDishSpecOptionMapper.java`
+- [x] Mapper: `HotelDishAdditionMapper.java` + XML
+- [x] Mapper: `HotelDishLogMapper.java`
+- [x] Service: `HotelDishService.java` — 菜品 CRUD + 上下架 + 售罄/恢复 + 删除
+- [x] Service: `HotelDishCategoryService.java` — 分类 CRUD + 启用/禁用
+- [x] Service: `HotelDishSpecGroupService.java` — 规格组 CRUD + 级联删除选项
+- [x] Service: `HotelDishSpecOptionService.java` — 规格选项 CRUD
+- [x] Controller: `HotelDishController.java` — 菜品接口(分页/详情/新增/修改/删除/上下架/售罄/恢复)
+- [x] Controller: `HotelDishCategoryController.java` — 分类接口(分页/新增/修改/删除/启禁用)
+- [x] Controller: `HotelDishSpecController.java` — 规格组 + 规格选项统一接口
+- [ ] Controller: 加料/日志 Controller(待开发)
+
+#### 数据库
+
+- [x] 建表:`hotel_dish`(IF NOT EXISTS)— 含 image(主图)+ images(附加图片)字段
+- [x] 建表:`hotel_dish_category`(IF NOT EXISTS)
+- [x] 建表:`hotel_dish_spec_group`(IF NOT EXISTS)
+- [x] 建表:`hotel_dish_spec_option`(IF NOT EXISTS)
+- [x] 建表:`hotel_dish_addition`(IF NOT EXISTS)
+- [x] 建表:`hotel_dish_log`(IF NOT EXISTS)
+- [x] 字典:`hotel_dish_status`(ON_SALE/OFF_SHELF)
+- [x] 字典:`hotel_dish_sold_out`(0=正常/1=售罄)
+- [x] 菜单:菜品管理 + 菜品分类管理(sys_resource)
+- [x] 菜单权限:菜品增删改查按钮权限
+- 迁移脚本:`V1.0.101__add_hotel_dish_module_tables.sql`
+
+#### 前端
+
+- [x] 页面:`forge-admin-ui/src/views/hotel/dish.vue` — 菜品管理(AiCrudPage)
+  - 序号列、主图缩略图(AuthImage)、全屏图片预览(主图 + 副图左右切换)
+  - 编辑按钮(仅下架可编辑)、上架/下架、售罄/恢复、删除
+  - 编辑表单:主厨推荐 (Switch)、状态 (Radio)、价格/制作时长/排序 (inputNumber)、主图/附加图片 (imageUpload)
+- [x] 页面:`forge-admin-ui/src/views/hotel/dishCategory.vue` — 菜品分类管理(AiCrudPage)
+  - 编辑按钮、启用/禁用操作
+- [x] API: `forge-admin-ui/src/api/hotel.js` 中追加菜品/分类相关接口
+
+#### 源框架参考
+
+| 文件 | 说明 |
+|------|------|
+| `dish/domain/HotelDish.java` | 菜品实体 |
+| `dish/domain/HotelDishCategory.java` | 分类实体 |
+| `dish/domain/HotelDishSpecGroup.java` | 规格组实体 |
+| `dish/domain/HotelDishSpecOption.java` | 规格选项实体 |
+| `dish/domain/HotelDishAddition.java` | 加料实体 |
+| `dish/domain/HotelDishLog.java` | 日志实体 |
+| `dish/controller/HotelDishController.java` | 菜品全部接口 |
+| `dish/controller/HotelDishCategoryController.java` | 分类接口 |
+| `dish/controller/HotelDishSpecGroupController.java` | 规格组接口 |
+| `dish/controller/HotelDishSpecOptionController.java` | 规格选项接口 |
+| `dish/service/HotelDishService.java` | 菜品业务逻辑(417 行) |
+| `dish/service/HotelDishCategoryService.java` | 分类业务逻辑 |
+| `dish/mapper/xml/HotelDishMapper.xml` | 菜品 SQL |
+| `dto/DishBatchOperationDTO.java` | 批量操作 DTO |
+| 前端:`views/hotel/dish/DishManagement.vue` | 菜品管理页面 |
+| 前端:`views/hotel/dish/DishForm.vue` | 菜品表单 |
+| 前端:`views/hotel/dish/CategoryManagement.vue` | 分类管理 |
+| 前端:`views/hotel/dish/CategoryForm.vue` | 分类表单 |
+| 前端:`api/hotel/HotelDishService.js` | 菜品 API |
+| 前端:`api/hotel/HotelDishSpecService.js` | 规格 API |
+
+#### 注意事项
+
+- 源框架同时存在 `hotel_dish_spec` 和 `hotel_dish_spec_group` 两张表,Forge 统一使用 `hotel_dish_spec_group`
+- 源框架删除菜品时校验未完成订单 → 需要依赖 order 模块(可先预留校验接口,order 完成后再联通)
+- 菜品价格使用 `BigDecimal`,金额单位与源框架保持一致(元)
+
+---
+
+### 3.3 order — 订单管理 ⭐⭐⭐⭐(最复杂)
+
+#### 后端
+
+- [ ] 实体:`HotelOrder.java` — 订单主表
+- [ ] 实体:`HotelOrderItem.java` — 订单明细
+- [ ] DTO: `OrderCreateDTO.java` — 创建订单请求
+- [ ] DTO: `OrderDashboardDTO.java` — 看板统计响应
+- [ ] VO: `HotelOrderVO.java` — 订单视图对象
+- [ ] Mapper: `HotelOrderMapper.java` + XML(含看板统计/今日营收/订单号生成)
+- [ ] Mapper: `HotelOrderItemMapper.java` + XML
+- [ ] Service: `HotelOrderService.java` — 订单完整生命周期(创建/接单/拒单/备餐/出餐/配送/完成/退单)
+- [ ] Controller: `HotelOrderController.java` — 订单全部接口(约 20+ 个)
+
+#### 数据库
+
+- [ ] 建表:`hotel_order`(IF NOT EXISTS)
+- [ ] 建表:`hotel_order_item`(IF NOT EXISTS)
+- [ ] 字典:`hotel_order_status`(PLACED/ACCEPTED/PREPARING/READY/DELIVERING/COMPLETED/REJECTED/REFUNDED/REFUND_REQUESTED)
+- [ ] 字典:`hotel_pay_method`(MOCK/CASH/ONLINE 等)
+- [ ] 字典:`hotel_delivery_time_type`(ASAP/CUSTOM)
+- [ ] 菜单:订单管理(sys_resource)
+- [ ] 菜单权限:订单查看/接单/拒单/备餐/配送/退单审核 按钮权限
+
+#### 前端
+
+- [ ] 页面:`forge-admin-ui/src/views/hotel/order.vue` — 订单管理列表(AiCrudPage)
+- [ ] 页面:`forge-admin-ui/src/views/hotel/orderDashboard.vue` — 订单看板(卡片统计 + 实时通知)
+- [ ] API: `forge-admin-ui/src/api/hotel.js` 中追加订单相关接口
+
+#### 源框架参考
+
+| 文件 | 说明 |
+|------|------|
+| `order/domain/HotelOrder.java` | 订单实体 |
+| `order/domain/HotelOrderItem.java` | 订单明细实体 |
+| `order/controller/HotelOrderController.java` | 全部接口(430 行) |
+| `order/service/HotelOrderService.java` | 订单业务逻辑(749 行) |
+| `order/mapper/xml/HotelOrderMapper.xml` | 订单 SQL |
+| `order/mapper/xml/HotelOrderItemMapper.xml` | 明细 SQL |
+| `dto/OrderCreateDTO.java` | 创建订单 DTO |
+| `dto/OrderDashboardDTO.java` | 看板 DTO |
+| 前端:`views/hotel/hotelfront/HotelFrontOrders.vue` | 前台订单页面 |
+| 前端:`views/hotel/kitchen/KitchenOrders.vue` | 厨房订单页面 |
+| 前端:`views/hotel/restaurant/RestaurantOrders.vue` | 餐厅订单页面 |
+| 前端:`views/hotel/customer/CustomerMenu.vue` | 顾客点餐页面 |
+| 前端:`api/hotel/HotelOrderService.js` | 订单 API |
+
+#### 订单状态机
+
+```
+PLACED(已下单)
+  ├── → ACCEPTED(已接单)→ PREPARING(备餐中)→ READY(出餐)→ DELIVERING(配送中)→ COMPLETED(已完成)
+  ├── → REJECTED(已拒单)
+  └── → REFUNDED(已退单,未接单时直接退)
+
+ACCEPTED(已接单)
+  └── → REFUND_REQUESTED(退单待审核)
+         ├── → REFUNDED(审核通过)
+         └── → ACCEPTED(审核驳回,恢复)
+```
+
+#### 注意事项
+
+- 源框架使用 `synchronized` 防止并发接单 → Forge 应使用 Redisson 分布式锁(`@Idempotent` 或手动加锁)
+- 源框架创建订单时自动计算金额(不信任前端价格)→ Forge 保持此安全策略
+- 源框架订单完成后自动累加菜品销量 → 需要依赖 dish 模块
+- 源框架退单流程较复杂(客户退单 + 前台退单 + 审核退单),需完整迁移
+
+---
+
+### 3.4 notification — 实时通知 ⭐⭐
+
+#### 后端
+
+- [ ] Config: `RedisPubSubConfig.java` — Redis 消息监听容器
+- [ ] Service: `OrderNotificationService.java` — 发布消息到 Redis 频道
+- [ ] Listener: `OrderNotificationListener.java` — 监听 Redis 消息 → 转发 SSE
+- [ ] Manager: `SseEmitterManager.java` — 管理 SSE 长连接 + 广播
+- [ ] Controller: `NotificationController.java` — SSE 订阅/取消/连接数
+
+#### 数据库
+
+- 无需新建表(使用 Redis 频道 `hotel:order:notification`)
+
+#### 前端
+
+- [ ] 组件:SSE 订阅工具(EventSource 封装)
+- [ ] 集成:订单看板/订单列表页面接收实时通知
+
+#### 源框架参考
+
+| 文件 | 说明 |
+|------|------|
+| `notification/config/RedisPubSubConfig.java` | Redis Pub/Sub 配置 |
+| `notification/service/OrderNotificationService.java` | 通知发布服务 |
+| `notification/listener/OrderNotificationListener.java` | Redis 消息监听器 |
+| `notification/manager/SseEmitterManager.java` | SSE 连接管理器 |
+| `notification/controller/NotificationController.java` | SSE 端点 |
+
+#### 注意事项
+
+- 源框架使用 `javax.annotation.Resource` → Forge 改用 `@RequiredArgsConstructor`
+- 源框架使用 `ResponseUtil` → Forge 改用 `RespInfo`
+- SSE 在 Forge 框架中需要走 Sa-Token 拦截器配置(排除或放行)
+- 此模块依赖 order 模块的事件发布,需在 order 之后或同步开发
+
+---
+
+## 四、框架适配速查表
+
+| 适配项 | jeeplus(源) | Forge(目标) |
+|--------|--------------|--------------|
+| 实体基类 | `BaseEntity` | `TenantEntity`(含 tenantId + 审计字段) |
+| ID 生成 | `UUID.randomUUID()` | `@TableId(type = IdType.ASSIGN_ID)` 雪花算法 |
+| 响应体 | `ResponseEntity` / `ResponseUtil` | `RespInfo.success(data)` / `RespInfo.error(msg)` |
+| 注入方式 | `@Resource` | `@RequiredArgsConstructor` + `private final` |
+| 权限注解 | `@PreAuthorize("hasAuthority('xxx')")` | `@SaCheckPermission("xxx")` |
+| 日志注解 | `@ApiLog` | `@OperationLog` |
+| SQL 位置 | Service 层 `QueryWrapper` | Mapper XML(DataScopeInterceptor 要求) |
+| 逻辑删除 | 手动 `setDelFlag(1)` + `updateById` | `@TableLogic(value="0", delval="id")` 自动 |
+| 租户隔离 | 无 | `TenantLineInnerInterceptor` 自动追加 `tenant_id` |
+| 字典 | 硬编码状态值 | `DictSelect` / `DictTag` / `useDict()` |
+| 前端列表 | 手写 NInput/NSelect | `AiCrudPage` 配置化 |
+| 分页参数 | `Page<T>` | `pageNum` + `pageSize`(`@RequestParam`) |
+| 分布式锁 | `synchronized` | Redisson(`@Idempotent` 或手动) |
+
+---
+
+## 五、建议开发顺序
+
+```
+✅ 1. room(房间/房型管理)     ← 已完成
+✅ 2. qrcode(二维码管理)      ← 已完成
+✅ 3. open(开放扫码接口)      ← 已完成
+✅ 4. dish-spec(菜品规格管理) ← 已完成
+✅ 5. business-hours(营业时段)← 已完成
+⏸️ 6. hotelconfig(酒店配置)   ← 暂缓(配送费/配送时长待讨论)
+ 7. dish(加料/日志)         ← 待开发
+⬜ 8. order(订单管理)         ← 依赖 dish + room,最复杂
+⬜ 9. notification(实时通知)  ← 依赖 order 事件发布
+```
+
+---
+
+## 六、数据库表汇总
+
+### 已完成(Forge 已有)
+
+| 表名 | 说明 |
+|------|------|
+| `hotel_qr_code` | 二维码表 |
+| `hotel_qr_bind_log` | 二维码绑定日志表 |
+| `hotel_room` | 房间表 |
+| `hotel_room_type` | 房型表 |
+
+### 待创建
+
+| 表名 | 说明 | 所属模块 |
+|------|------|--------|
+| `hotel_config` | 酒店基础配置 | hotelconfig(暂缓) |
+| `hotel_order` | 订单 | order |
+| `hotel_order_item` | 订单明细 | order |
+
+### 已完成(dish + dish-spec + business-hours 模块)
+
+| 表名 | 说明 | 迁移脚本 |
+|------|------|--------|
+| `hotel_dish` | 菜品(含主图/附加图片/主厨推荐/售罄/上下架) | V1.0.101 |
+| `hotel_dish_category` | 菜品分类 | V1.0.101 |
+| `hotel_dish_spec_group` | 菜品规格组 | V1.0.101(建表)+ V1.0.102(菜单) |
+| `hotel_dish_spec_option` | 规格选项 | V1.0.101(建表) |
+| `hotel_dish_addition` | 加料 | V1.0.101 |
+| `hotel_dish_log` | 菜品操作日志 | V1.0.101 |
+| `hotel_business_hours` | 营业时段 | V1.0.103(建表 + 菜单 + 字典) |
+
+### 已完成字典
+
+| 字典类型 | 说明 | 迁移脚本 |
+|---------|------|--------|
+| `hotel_dish_status` | 菜品状态(在售/下架) | V1.0.101 |
+| `hotel_dish_sold_out` | 售罄状态(正常/售罄) | V1.0.101 |
+| `hotel_meal_type` | 餐段类型(早餐/午餐/晚餐) | V1.0.103 |
+| `hotel_business_hours_status` | 营业时段状态(启用/禁用) | V1.0.103 |
+
+### 待新增字典
+
+| 字典类型 | 说明 | 所属模块 |
+|---------|------|--------|
+| `hotel_order_status` | 订单状态(9 种) | order |
+| `hotel_pay_method` | 支付方式 | order |
+| `hotel_delivery_time_type` | 配送时间类型 | order |
+
+---
+
+## 七、前端页面汇总
+
+### 已完成
+
+| 页面 | 路径 | 组件 | 说明 |
+|------|------|------|------|
+| 二维码管理 | `views/hotel/qrcode.vue` | AiCrudPage | 批量生成/绑定/解绑/下载 |
+| 房间管理 | `views/hotel/room.vue` | AiCrudPage | 房间/房型 CRUD |
+| 菜品管理 | `views/hotel/dish.vue` | AiCrudPage | 图片预览/上下架/售罄/主厨推荐 |
+| 菜品分类 | `views/hotel/dishCategory.vue` | AiCrudPage | 分类 CRUD + 启禁用 |
+| 菜品规格 | `views/hotel/dishSpec.vue` | AiCrudPage | 规格组列表 + 选项管理弹窗 |
+| 营业时段 | `views/hotel/businessHours.vue` | AiCrudPage | 时段列表 + 时间段下拉选择 + 启禁用 |
+| 扫码绑定 (H5) | `forge-h5-ui/src/pages/hotel/scan-bind.vue` | UniApp | 扫码绑定房间 |
+
+### 待开发
+
+| 页面 | 路径 | 说明 | 源框架参考 |
+|------|------|------|-----------|
+| 酒店配置 | `views/hotel/config.vue` | 单例配置表单(暂缓) | `HotelConfigManagement.vue` |
+| 订单管理 | `views/hotel/order.vue` | AiCrudPage | `HotelFrontOrders.vue` |
+| 订单看板 | `views/hotel/orderDashboard.vue` | 卡片统计+SSE | 无直接参考 |
+
+---
+
+## 八、关键决策记录
+
+### 8.1 暂缓开发项
+
+| 模块 | 原因 | 待讨论点 |
+|------|------|--------|
+| hotelconfig(酒店基础配置) | 暂不需要 | 配送费规则(按距离/区域/固定?)、预计配送时长(固定/动态?) |
+
+### 8.2 技术决策
+
+| 决策 | 说明 |
+|------|------|
+| 规格组统一命名 | 源框架同时存在 `hotel_dish_spec` 和 `hotel_dish_spec_group`,Forge 统一使用 `hotel_dish_spec_group` |
+| 营业时段时间选择 | 使用下拉选择(09:00~23:30,每 30 分钟一档),而非时间选择器 |
+| 餐段类型 | 仅保留早餐/午餐/晚餐三种,移除夜宵 |
+| 前端组件规范 | 统一使用 AiCrudPage 配置化页面,保持分页/筛选/操作列格式一致 |
+
+---
+
+*文档最后更新:2026-08-14(营业时段/菜品规格已完成,酒店基础配置暂缓,待开发:加料/日志/订单/通知)*