Explorar el Código

酒店端 移动端代码功能

徐滕 hace 1 semana
padre
commit
5d330c2214
Se han modificado 23 ficheros con 3546 adiciones y 213 borrados
  1. 5 0
      forge-admin-ui/src/api/hotel.js
  2. 17 4
      forge-admin-ui/src/components/ai-form/AiCrudPage.vue
  3. 18 1
      forge-admin-ui/src/components/ai-form/AiSearch.vue
  4. 115 2
      forge-admin-ui/src/views/hotel/dish.vue
  5. 169 174
      forge-admin-ui/src/views/hotel/order.vue
  6. 47 2
      forge-admin-ui/src/views/hotel/restaurantOrder.vue
  7. 103 0
      forge-h5-ui/src/api/index.js
  8. 21 0
      forge-h5-ui/src/pages.json
  9. 742 0
      forge-h5-ui/src/pages/hotel/hotel-order-refund.vue
  10. 478 0
      forge-h5-ui/src/pages/hotel/hotel-order.vue
  11. 1660 0
      forge-h5-ui/src/pages/hotel/staff-order.vue
  12. 27 2
      forge-h5-ui/src/pages/index/index.vue
  13. 11 0
      forge-server/db/migration/V1.0.119__add_hotel_dish_log_operator_fields.sql
  14. 10 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelDishController.java
  15. 6 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDishLog.java
  16. 8 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.java
  17. 9 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java
  18. 15 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/OrderDashboardVO.java
  19. 10 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishService.java
  20. 13 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishServiceImpl.java
  21. 11 1
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml
  22. 26 7
      forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md
  23. 25 20
      forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md

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

@@ -227,6 +227,11 @@ export function getDishSalesRanking(limit = 10) {
   return request.get('/hotel/dish/ranking', { params: { limit } })
 }
 
+/** 分页查询菜品操作日志 */
+export function getDishLogPage(params) {
+  return request.get('/hotel/dish/logs', { params })
+}
+
 // ==================== 菜品规格管理 ====================
 
 /** 分页查询规格组 */

+ 17 - 4
forge-admin-ui/src/components/ai-form/AiCrudPage.vue

@@ -779,8 +779,21 @@ const commandActionFormRef = ref(null)
 /**
  * ==================== 响应式数据 ====================
  */
-// 搜索参数
-const searchParams = ref({})
+
+/** 从搜索 schema 提取默认值 */
+function buildDefaultSearchParams() {
+  const result = {}
+  const fields = flattenRuntimeFormFields(props.searchSchema || [])
+  fields.forEach((field) => {
+    if (field.field && field.defaultValue !== undefined) {
+      result[field.field] = field.defaultValue
+    }
+  })
+  return result
+}
+
+// 搜索参数(从 schema 提取默认值)
+const searchParams = ref(buildDefaultSearchParams())
 
 // 表格数据
 const dataSource = ref([])
@@ -3355,10 +3368,10 @@ async function handleSearch(params) {
 }
 
 /**
- * 重置
+ * 重置(恢复到 schema 默认值)
  */
 function handleReset() {
-  searchParams.value = {}
+  searchParams.value = buildDefaultSearchParams()
   pagination.value.page = 1
   loadList()
 }

+ 18 - 1
forge-admin-ui/src/components/ai-form/AiSearch.vue

@@ -64,7 +64,7 @@
 
 <script setup>
 import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 import AiForm from './AiForm.vue'
 
 const props = defineProps({
@@ -146,6 +146,21 @@ const formData = ref({ ...props.modelValue })
 const searchLoading = ref(false)
 const resetLoading = ref(false)
 
+// 同步父组件 modelValue 变化到表单(用于设置初始默认值、重置等场景)
+let isInternalUpdate = false
+watch(() => props.modelValue, (newVal) => {
+  if (isInternalUpdate) {
+    isInternalUpdate = false
+    return
+  }
+  // 深比较避免无意义更新
+  const currentStr = JSON.stringify(formData.value)
+  const newStr = JSON.stringify(newVal || {})
+  if (currentStr !== newStr) {
+    formData.value = { ...newVal }
+  }
+}, { deep: true })
+
 // 兼容 options 和 schema 两种命名
 const schema = computed(() => props.schema.length > 0 ? props.schema : props.options)
 const formContext = computed(() => ({
@@ -165,6 +180,7 @@ async function handleSearch() {
   try {
     searchLoading.value = true
     await formRef.value?.validate()
+    isInternalUpdate = true
     emit('search', { ...formData.value })
     emit('update:modelValue', { ...formData.value })
   }
@@ -205,6 +221,7 @@ async function handleReset() {
     formRef.value?.reset()
     formData.value = {}
 
+    isInternalUpdate = true
     emit('reset')
     emit('update:modelValue', {})
 

+ 115 - 2
forge-admin-ui/src/views/hotel/dish.vue

@@ -37,18 +37,50 @@
         </div>
       </Transition>
     </Teleport>
+
+    <!-- 操作日志弹窗 -->
+    <NModal v-model:show="logModalVisible" preset="card" title="菜品操作日志" style="width: 700px;" @after-leave="closeLogModal">
+      <NSpin :show="logLoading">
+        <NTable striped size="small" v-if="logList.length > 0">
+          <thead>
+            <tr>
+              <th>操作类型</th>
+              <th>操作人</th>
+              <th>操作时间</th>
+              <th>备注</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="log in logList" :key="log.id">
+              <td>
+                <NTag :type="getLogTypeColor(log.operationType)" size="small">
+                  {{ getLogTypeLabel(log.operationType) }}
+                </NTag>
+              </td>
+              <td>{{ log.operatorName || '-' }}</td>
+              <td>{{ formatDateTime(log.createTime) }}</td>
+              <td>{{ log.remark || '-' }}</td>
+            </tr>
+          </tbody>
+        </NTable>
+        <div v-else class="text-center py-8 text-gray-500">
+          暂无操作日志
+        </div>
+      </NSpin>
+    </NModal>
   </div>
 </template>
 
 <script setup>
-import { NTag } from 'naive-ui'
 import { computed, h, ref } from 'vue'
-import { getDishCategoryEnabled, markDishSoldOut, restoreDish, updateDishStatus } from '@/api/hotel'
+import { getDishCategoryEnabled, getDishLogPage, markDishSoldOut, restoreDish, updateDishStatus } from '@/api/hotel'
 import { AiCrudPage } from '@/components/ai-form'
 import AuthImage from '@/components/common/AuthImage.vue'
+import { NModal, NSpin, NTable, NTag, useMessage } from 'naive-ui'
 
 defineOptions({ name: 'HotelDish' })
 
+const message = useMessage()
 const crudRef = ref(null)
 
 // 图片预览
@@ -56,6 +88,82 @@ const previewVisible = ref(false)
 const previewUrls = ref([])
 const previewIndex = ref(0)
 
+// 操作日志弹窗
+const logModalVisible = ref(false)
+const logList = ref([])
+const logLoading = ref(false)
+const currentDishId = ref(null)
+
+async function openLogModal(dishId) {
+  currentDishId.value = dishId
+  logModalVisible.value = true
+  await loadLogs()
+}
+
+async function loadLogs(pageNum = 1, pageSize = 20) {
+  if (!currentDishId.value)
+    return
+  logLoading.value = true
+  try {
+    const res = await getDishLogPage({ pageNum, pageSize, dishId: currentDishId.value })
+    if (res.code === 200) {
+      logList.value = res.data?.records || []
+    }
+    else {
+      message.error(res.msg || '加载日志失败')
+    }
+  }
+  catch (err) {
+    console.error(err)
+    message.error('加载日志失败')
+  }
+  finally {
+    logLoading.value = false
+  }
+}
+
+function closeLogModal() {
+  logModalVisible.value = false
+  logList.value = []
+  currentDishId.value = null
+}
+
+// 操作日志类型映射
+const logTypeLabelMap = {
+  CREATE: '新增',
+  EDIT: '编辑',
+  DELETE: '删除',
+  ON_SALE: '上架',
+  OFF_SHELF: '下架',
+  SOLD_OUT: '售罄',
+  RESTORE: '恢复',
+}
+
+const logTypeColorMap = {
+  CREATE: 'success',
+  EDIT: 'info',
+  DELETE: 'error',
+  ON_SALE: 'success',
+  OFF_SHELF: 'warning',
+  SOLD_OUT: 'error',
+  RESTORE: 'success',
+}
+
+function getLogTypeLabel(type) {
+  return logTypeLabelMap[type] || type
+}
+
+function getLogTypeColor(type) {
+  return logTypeColorMap[type] || 'default'
+}
+
+function formatDateTime(time) {
+  if (!time)
+    return '-'
+  const date = new Date(time)
+  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
+}
+
 function openPreview(row) {
   const urls = []
   if (row.image)
@@ -241,6 +349,11 @@ const tableColumns = computed(() => [
         class: 'text-error cursor-pointer hover:opacity-80',
         onClick: () => crudRef.value?.handleDelete(row),
       }, '删除'))
+      // 操作日志
+      actions.push(h('a', {
+        class: 'text-info cursor-pointer hover:opacity-80',
+        onClick: () => openLogModal(row.id),
+      }, '操作日志'))
       return h('div', { class: 'flex gap-3' }, actions)
     },
   },

+ 169 - 174
forge-admin-ui/src/views/hotel/order.vue

@@ -1,5 +1,44 @@
 <template>
   <div class="hotel-order-page">
+    <!-- 统计卡片 -->
+    <div class="stats-cards">
+      <div class="stat-card">
+        <div class="stat-label">
+          今日订单
+        </div>
+        <div class="stat-value">
+          {{ dashboard.todayTotal || 0 }}
+        </div>
+        <div v-if="dashboard.todayGrowth != null" class="stat-growth" :class="[dashboard.todayGrowth >= 0 ? 'up' : 'down']">
+          {{ dashboard.todayGrowth >= 0 ? '↑' : '↓' }} {{ Math.abs(Math.round(dashboard.todayGrowth * 100)) }}%
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-label">
+          进行中
+        </div>
+        <div class="stat-value stat-value--orange">
+          {{ inProgressTotal }}
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-label">
+          已完成
+        </div>
+        <div class="stat-value stat-value--green">
+          {{ dashboard.todayCompletedCount || 0 }}
+        </div>
+      </div>
+      <div class="stat-card">
+        <div class="stat-label">
+          退单
+        </div>
+        <div class="stat-value stat-value--red">
+          {{ dashboard.refundCount || 0 }}
+        </div>
+      </div>
+    </div>
+
     <AiCrudPage
       ref="crudRef"
       :api-config="{
@@ -13,47 +52,8 @@
       :show-export="false"
       :show-import="false"
       :hide-selection="true"
-    >
-      <!-- 工具栏:看板统计 -->
-      <template #toolbar-start>
-        <div class="order-dashboard">
-          <div class="dashboard-item">
-            <span class="dashboard-value warning">{{ dashboard.pendingCount }}</span>
-            <span class="dashboard-label">待接单</span>
-          </div>
-          <div v-if="connected" class="sse-indicator" title="实时通知已连接">
-            <span class="sse-dot" />
-          </div>
-          <div class="dashboard-item">
-            <span class="dashboard-value primary">{{ dashboard.preparingCount }}</span>
-            <span class="dashboard-label">备餐中</span>
-          </div>
-          <div class="dashboard-item">
-            <span class="dashboard-value primary">{{ dashboard.deliveringCount }}</span>
-            <span class="dashboard-label">配送中</span>
-          </div>
-          <div class="dashboard-divider" />
-          <div class="dashboard-item">
-            <span class="dashboard-value">{{ dashboard.todayTotal }}</span>
-            <span class="dashboard-label">今日订单</span>
-          </div>
-          <div class="dashboard-item">
-            <span class="dashboard-value success">¥{{ dashboard.todayRevenue }}</span>
-            <span class="dashboard-label">今日营收</span>
-          </div>
-          <div class="dashboard-divider" />
-          <div class="dashboard-item pause-control">
-            <NButton
-              :type="isPaused ? 'warning' : 'success'"
-              size="small"
-              @click="handleTogglePause"
-            >
-              {{ isPaused ? '恢复接单' : '暂停接单' }}
-            </NButton>
-          </div>
-        </div>
-      </template>
-    </AiCrudPage>
+      :before-load-list="transformSearchParams"
+    />
 
     <!-- 取消订单确认弹窗(待支付订单) -->
     <NModal
@@ -109,7 +109,6 @@ import {
   approveRefund,
   completeOrder,
   deliverOrder,
-  getPauseStatus,
   getOrderDashboard,
   getOrderPage,
   hotelRefund,
@@ -118,7 +117,6 @@ import {
   readyOrder,
   rejectOrder,
   rejectRefund,
-  togglePause,
 } from '@/api/hotel'
 import { AiCrudPage } from '@/components/ai-form'
 import DictTag from '@/components/DictTag.vue'
@@ -134,62 +132,64 @@ const { dict } = useDict('hotel_order_status', 'hotel_pay_method', 'hotel_refund
 const crudRef = ref(null)
 const router = useRouter()
 
-// ==================== 暂停接单状态 ====================
-const isPaused = ref(false)
+function getTodayStr() {
+  const d = new Date()
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+// ==================== 看板统计 ====================
+const dashboard = ref({
+  pendingCount: 0,
+  acceptedCount: 0,
+  preparingCount: 0,
+  readyCount: 0,
+  deliveringCount: 0,
+  todayTotal: 0,
+  todayRevenue: '0.00',
+  todayCompletedCount: 0,
+  refundCount: 0,
+  todayGrowth: null,
+})
+
+const dashboardDate = ref(null)
+
+const inProgressTotal = computed(() => {
+  const d = dashboard.value
+  return (d.pendingCount || 0) + (d.acceptedCount || 0) + (d.preparingCount || 0) + (d.readyCount || 0) + (d.deliveringCount || 0)
+})
 
-async function loadPauseStatus() {
+async function loadDashboard(queryDate) {
   try {
-    const res = await getPauseStatus()
-    if (res.code === 200) {
-      isPaused.value = res.data
+    const params = {}
+    if (queryDate)
+      params.queryDate = queryDate
+    const res = await getOrderDashboard(params)
+    if (res.code === 200 && res.data) {
+      dashboard.value = res.data
     }
   }
   catch (err) {
-    console.error('[订单页面] 加载暂停状态失败:', err)
+    // ignore
+    void err
   }
 }
 
-async function handleTogglePause() {
-  const willPause = !isPaused.value
-  const title = willPause ? '暂停接单' : '恢复接单'
-  const content = willPause
-    ? '暂停后顾客扫码将无法下单,确定要暂停接单吗?'
-    : '恢复后顾客可以正常扫码下单,确定要恢复接单吗?'
-
-  dialog.warning({
-    title,
-    content,
-    positiveText: '确定',
-    negativeText: '取消',
-    onPositiveClick: async () => {
-      try {
-        const res = await togglePause()
-        if (res.code === 200) {
-          isPaused.value = res.data
-          message.success(isPaused.value ? '已暂停接单' : '已恢复接单')
-        }
-      }
-      catch (err) {
-        message.error('操作失败')
-        console.error('[订单页面] 切换暂停状态失败:', err)
-      }
-    },
-  })
-}
+// ==================== 暂停接单状态 ====================
+// 暂停接单功能已移至其他入口管理
 
 // ==================== SSE 实时通知 ====================
 const { connected, connect, disconnect, playSound } = useOrderNotification({
   onNewOrder: () => {
     crudRef.value?.refresh()
-    loadDashboard()
+    loadDashboard(dashboardDate.value)
   },
   onRefundRequest: () => {
     crudRef.value?.refresh()
-    loadDashboard()
+    loadDashboard(dashboardDate.value)
   },
   onOrderReady: () => {
     crudRef.value?.refresh()
-    loadDashboard()
+    loadDashboard(dashboardDate.value)
   },
 })
 
@@ -210,8 +210,8 @@ function handleHotelNotification(e) {
 }
 
 onMounted(() => {
-  loadDashboard()
-  loadPauseStatus()
+  dashboardDate.value = getTodayStr()
+  loadDashboard(dashboardDate.value)
   connect()
   window.addEventListener('hotel-notification', handleHotelNotification)
 })
@@ -221,36 +221,14 @@ onUnmounted(() => {
   window.removeEventListener('hotel-notification', handleHotelNotification)
 })
 
-// 监听 SSE 连接状态,连接成功后检查待处理订单(浏览器刷新和页面标签刷新都会触发)
+// 监听 SSE 连接状态,连接成功后检查待处理订单
 watch(connected, (newVal) => {
   if (newVal) {
-    console.log('[订单页面] SSE 连接已建立,检查待处理订单')
+    console.warn('[订单页面] SSE 连接已建立,检查待处理订单')
     checkPendingOrders()
   }
 })
 
-// ==================== 看板统计 ====================
-const dashboard = ref({
-  pendingCount: 0,
-  preparingCount: 0,
-  deliveringCount: 0,
-  todayTotal: 0,
-  todayRevenue: '0.00',
-})
-
-async function loadDashboard() {
-  try {
-    const res = await getOrderDashboard()
-    if (res.code === 200 && res.data) {
-      dashboard.value = res.data
-    }
-  }
-  catch (err) {
-    // ignore
-    void err
-  }
-}
-
 /**
  * 页面加载时检查待接单订单,如果有则触发语音通知。
  * 这样刷新页面就能听到声音提醒了。
@@ -299,6 +277,17 @@ const searchSchema = computed(() => [
     props: { placeholder: '请输入房间号' },
   },
   {
+    field: 'createTime',
+    label: '下单时间',
+    type: 'daterange',
+    defaultValue: [getTodayStr(), getTodayStr()],
+    props: {
+      startPlaceholder: '开始日期',
+      endPlaceholder: '结束日期',
+      valueFormat: 'yyyy-MM-dd',
+    },
+  },
+  {
     field: 'status',
     label: '订单状态',
     type: 'select',
@@ -318,6 +307,27 @@ const searchSchema = computed(() => [
   },
 ])
 
+/** 搜索参数转换:daterange 数组 → createTimeStart/createTimeEnd */
+function transformSearchParams(params) {
+  if (params.createTime && Array.isArray(params.createTime) && params.createTime.length === 2) {
+    params.createTimeStart = params.createTime[0]
+    params.createTimeEnd = params.createTime[1]
+    // 同步看板日期
+    dashboardDate.value = params.createTime[0]
+    loadDashboard(params.createTime[0])
+  }
+  else {
+    // 无日期筛选时默认查今天,同步看板
+    const today = getTodayStr()
+    params.createTimeStart = today
+    params.createTimeEnd = today
+    dashboardDate.value = today
+    loadDashboard(today)
+  }
+  delete params.createTime
+  return params
+}
+
 // ==================== 状态标签渲染 ====================
 function renderStatus(status) {
   return h(DictTag, { dictType: 'hotel_order_status', value: String(status), size: 'small' })
@@ -458,6 +468,11 @@ const reasonTitle = ref('')
 const reasonText = ref('')
 const reasonCallback = ref(null)
 
+// ==================== 取消订单(待支付) ====================
+const cancelVisible = ref(false)
+const cancelOrder = ref(null)
+const cancelReason = ref('')
+
 function handleAction(action, row) {
   switch (action) {
     case 'accept':
@@ -470,7 +485,7 @@ function handleAction(action, row) {
           await acceptOrder(row.id)
           message.success('接单成功')
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
@@ -485,7 +500,7 @@ function handleAction(action, row) {
           // 这里只保证列表刷新,让操作员看到订单仍为待接单、退款状态可重试
         }
         crudRef.value?.refresh()
-        loadDashboard()
+        loadDashboard(dashboardDate.value)
       })
       break
     case 'prepare':
@@ -498,7 +513,7 @@ function handleAction(action, row) {
           await prepareOrder(row.id)
           message.success('已开始备餐')
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
@@ -512,7 +527,7 @@ function handleAction(action, row) {
           await readyOrder(row.id)
           message.success('已出餐')
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
@@ -526,7 +541,7 @@ function handleAction(action, row) {
           await deliverOrder(row.id)
           message.success('已开始配送')
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
@@ -540,7 +555,7 @@ function handleAction(action, row) {
           await completeOrder(row.id)
           message.success('订单已完成')
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
@@ -571,7 +586,7 @@ function handleAction(action, row) {
             // 退款失败时订单保持“退单待审核”,错误详情已由请求拦截器统一弹窗展示
           }
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
@@ -585,7 +600,7 @@ function handleAction(action, row) {
           // 错误详情已由请求拦截器统一弹窗展示
         }
         crudRef.value?.refresh()
-        loadDashboard()
+        loadDashboard(dashboardDate.value)
       })
       break
     case 'refund':
@@ -603,25 +618,20 @@ function handleAction(action, row) {
             // 失败原因已由拦截器弹窗展示,订单 refund_status 置为可重试
           }
           crudRef.value?.refresh()
-          loadDashboard()
+          loadDashboard(dashboardDate.value)
         },
       })
       break
   }
 }
 
-// ==================== 取消订单(待支付) ====================
-const cancelVisible = ref(false)
-const cancelOrder = ref(null)
-const cancelReason = ref('')
-
 async function handleCancelConfirm() {
   try {
     await hotelRefund(cancelOrder.value.id, cancelReason.value || undefined)
     message.success('订单已取消')
     cancelVisible.value = false
     crudRef.value?.refresh()
-    loadDashboard()
+    loadDashboard(dashboardDate.value)
   }
   catch {
     // 错误详情已由请求拦截器统一弹窗展示
@@ -640,89 +650,74 @@ async function handleReasonConfirm() {
     await reasonCallback.value(reasonText.value || undefined)
   }
 }
-
 </script>
 
 <style scoped>
 .hotel-order-page {
+  display: flex;
+  flex-direction: column;
   height: 100%;
+  min-height: 0;
 }
 
-/* ==================== 看板统计 ==================== */
-.order-dashboard {
+.stats-cards {
+  flex-shrink: 0;
   display: flex;
-  align-items: center;
   gap: 16px;
-  padding: 0 4px;
+  padding: 16px 16px 0;
+  margin-bottom: 16px;
 }
 
-.dashboard-item {
+.stat-card {
+  flex: 1;
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px 12px;
   display: flex;
   flex-direction: column;
   align-items: center;
-  min-width: 56px;
+  gap: 4px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
 }
 
-.dashboard-value {
-  font-size: 18px;
+.stat-label {
+  font-size: 13px;
+  color: #999;
+}
+
+.stat-value {
+  font-size: 28px;
   font-weight: 700;
+  color: #1a1a1a;
   line-height: 1.2;
-  color: #333;
 }
 
-.dashboard-value.warning {
+.stat-value--orange {
   color: #f0a020;
 }
-
-.dashboard-value.primary {
-  color: #2080f0;
-}
-
-.dashboard-value.success {
+.stat-value--green {
   color: #18a058;
 }
-
-.dashboard-label {
-  font-size: 12px;
-  color: #999;
-  margin-top: 2px;
-}
-
-.dashboard-divider {
-  width: 1px;
-  height: 28px;
-  background: #e8e8e8;
-  margin: 0 4px;
+.stat-value--red {
+  color: #d03050;
 }
 
-.pause-control {
-  min-width: auto;
+.stat-growth {
+  font-size: 12px;
+  font-weight: 600;
 }
 
-/* ==================== SSE连接指示器 ==================== */
-.sse-indicator {
-  display: flex;
-  align-items: center;
-  margin-left: 4px;
+.stat-growth.up {
+  color: #d03050;
 }
-
-.sse-dot {
-  display: inline-block;
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  background: #18a058;
-  animation: sse-pulse 2s ease-in-out infinite;
+.stat-growth.down {
+  color: #18a058;
 }
 
-@keyframes sse-pulse {
-  0%,
-  100% {
-    opacity: 1;
-  }
-  50% {
-    opacity: 0.4;
-  }
+/* AiCrudPage 占剩余空间 */
+:deep(.ai-crud-page) {
+  flex: 1;
+  min-height: 0;
+  height: auto;
 }
-
 </style>

+ 47 - 2
forge-admin-ui/src/views/hotel/restaurantOrder.vue

@@ -134,6 +134,36 @@
             </div>
           </div>
 
+          <!-- 已完成列表 -->
+          <div v-if="completedOrders.length > 0" class="order-section">
+            <h3 class="section-title">
+              <span class="section-dot completed" />
+              已完成
+              <span class="section-count">{{ completedOrders.length }}</span>
+            </h3>
+            <div class="order-list">
+              <div v-for="order in completedOrders" :key="order.id" class="order-row order-row-completed" @click="openDetail(order)">
+                <div class="order-room">
+                  {{ order.roomNo || '--' }}
+                </div>
+                <div class="order-info">
+                  <div class="order-no">
+                    {{ order.orderNo }}
+                  </div>
+                  <div class="order-items">
+                    {{ buildItemSummary(order) }}
+                  </div>
+                  <div class="order-meta">
+                    <span class="order-status-tag">
+                      <DictTag dict-type="hotel_order_status" :value="String(order.status)" size="small" />
+                    </span>
+                    <span class="order-amount">¥{{ order.totalAmount }}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+
           <!-- 已拒单列表 -->
           <div v-if="rejectedOrders.length > 0" class="order-section">
             <h3 class="section-title">
@@ -166,8 +196,8 @@
           </div>
 
           <!-- 空状态 -->
-          <div v-if="pendingOrders.length === 0 && inProgressOrders.length === 0 && rejectedOrders.length === 0" class="empty-state">
-            <span class="text-gray-400">暂无活跃订单</span>
+          <div v-if="pendingOrders.length === 0 && inProgressOrders.length === 0 && completedOrders.length === 0 && rejectedOrders.length === 0" class="empty-state">
+            <span class="text-gray-400">暂无订单</span>
           </div>
         </div>
       </NTabPane>
@@ -815,6 +845,12 @@ const rejectedOrders = computed(() => {
   })
 })
 
+const completedOrders = computed(() => {
+  return allOrders.value.filter((o) => {
+    return o.status === 6
+  })
+})
+
 function buildItemSummary(order) {
   if (order.items && order.items.length > 0) {
     const names = []
@@ -1454,6 +1490,9 @@ function handleCompleteFromDetail() {
 .section-dot.rejected {
   background: #999;
 }
+.section-dot.completed {
+  background: #18a058;
+}
 
 .section-count {
   font-size: 12px;
@@ -1494,6 +1533,12 @@ function handleCompleteFromDetail() {
   opacity: 0.85;
 }
 
+.order-row-completed {
+  border-left-color: #18a058;
+  background: #f6ffed;
+  opacity: 0.8;
+}
+
 .order-room {
   font-size: 20px;
   font-weight: 700;

+ 103 - 0
forge-h5-ui/src/api/index.js

@@ -379,4 +379,107 @@ export default {
     data,
     skipAuthRefresh: true,
   }),
+
+  // ==================== 酒店前台 - 订单管理(需登录) ====================
+  /** 酒店前台订单详情 */
+  hotelStaffOrderDetail: (id) => request({
+    url: '/hotel/order/detail',
+    method: 'get',
+    params: { id },
+  }),
+  /** 酒店前台退单 */
+  hotelHotelRefund: (id, reason) => request({
+    url: '/hotel/order/hotelRefund',
+    method: 'post',
+    params: { id, reason },
+  }),
+  /** 同意退单 */
+  hotelApproveRefund: (id, reason) => request({
+    url: '/hotel/order/refundApprove',
+    method: 'post',
+    params: { id, reason },
+  }),
+  /** 驳回退单 */
+  hotelRejectRefund: (id, reason) => request({
+    url: '/hotel/order/refundReject',
+    method: 'post',
+    params: { id, reason },
+  }),
+
+  // ==================== 员工侧 - 餐厅订单管理(需登录) ====================
+  /** 餐厅订单看板统计 */
+  hotelStaffDashboard: (params) => request({
+    url: '/hotel/order/dashboard',
+    method: 'get',
+    params,
+  }),
+  /** 餐厅订单分页查询 */
+  hotelStaffOrderPage: (params) => request({
+    url: '/hotel/order/page',
+    method: 'get',
+    params,
+  }),
+  /** 餐厅接单 */
+  hotelStaffAccept: (id) => request({
+    url: '/hotel/order/accept',
+    method: 'post',
+    params: { id },
+  }),
+  /** 餐厅拒单 */
+  hotelStaffReject: (id, reason) => request({
+    url: '/hotel/order/reject',
+    method: 'post',
+    params: { id, reason },
+  }),
+  /** 开始配送 */
+  hotelStaffDeliver: (id) => request({
+    url: '/hotel/order/deliver',
+    method: 'post',
+    params: { id },
+  }),
+  /** 完成配送 */
+  hotelStaffComplete: (id) => request({
+    url: '/hotel/order/complete',
+    method: 'post',
+    params: { id },
+  }),
+  /** 查询全部营业时段 */
+  hotelStaffBusinessHoursAll: () => request({
+    url: '/hotel/business-hours/all',
+    method: 'get',
+  }),
+  /** 新增营业时段 */
+  hotelStaffBusinessHoursCreate: (data) => request({
+    url: '/hotel/business-hours',
+    method: 'post',
+    data,
+  }),
+  /** 修改营业时段 */
+  hotelStaffBusinessHoursUpdate: (data) => request({
+    url: '/hotel/business-hours',
+    method: 'put',
+    data,
+  }),
+  /** 删除营业时段 */
+  hotelStaffBusinessHoursDelete: (id) => request({
+    url: '/hotel/business-hours/remove',
+    method: 'post',
+    params: { id },
+  }),
+  /** 切换营业时段状态 */
+  hotelStaffBusinessHoursToggle: (id) => request({
+    url: '/hotel/business-hours/toggle',
+    method: 'post',
+    params: { id },
+  }),
+  /** 查询暂停接单状态 */
+  hotelStaffPauseStatus: () => request({
+    url: '/hotel/order/pause/status',
+    method: 'get',
+  }),
+  /** 切换暂停接单状态 */
+  hotelStaffPauseToggle: () => request({
+    url: '/hotel/order/pause/toggle',
+    method: 'post',
+  }),
 }

+ 21 - 0
forge-h5-ui/src/pages.json

@@ -84,6 +84,27 @@
 				"navigationStyle": "custom"
 			}
 		},
+		{
+			"path": "pages/hotel/staff-order",
+			"style": {
+				"navigationBarTitleText": "餐厅订单",
+				"navigationStyle": "custom"
+			}
+		},
+		{
+			"path": "pages/hotel/hotel-order",
+			"style": {
+				"navigationBarTitleText": "酒店订单",
+				"navigationStyle": "custom"
+			}
+		},
+		{
+			"path": "pages/hotel/hotel-order-refund",
+			"style": {
+				"navigationBarTitleText": "退单管理",
+				"navigationStyle": "custom"
+			}
+		},
 		// #endif
 		{
 			"path": "pages/hotel/customer/room-confirm",

+ 742 - 0
forge-h5-ui/src/pages/hotel/hotel-order-refund.vue

@@ -0,0 +1,742 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-back" @click="goBack">
+        <text class="nav-arrow">‹</text>
+      </view>
+      <text class="nav-title">{{ isDetailMode ? '订单详情' : '退单管理' }}</text>
+    </view>
+
+    <!-- ==================== 退单列表模式 ==================== -->
+    <view v-if="!isDetailMode" class="content">
+      <view v-if="!refundOrders.length && !loading" class="empty-state">
+        <text class="empty-icon"></text>
+        <text class="empty-text">暂无退单记录</text>
+      </view>
+
+      <view v-for="order in refundOrders" :key="order.id" class="order-card" @click="openDetail(order)">
+        <view class="oc-header">
+          <view class="oc-room">
+            <text class="oc-room-icon">🏠</text>
+            <text class="oc-room-no">{{ order.roomNo || '--' }}</text>
+          </view>
+          <view class="oc-status" :class="statusTagClass(order.status)">
+            <text>{{ statusLabel(order.status) }}</text>
+          </view>
+        </view>
+        <text class="oc-items">{{ buildItemSummary(order) }}</text>
+        <view class="oc-footer">
+          <text class="oc-amount">¥{{ order.totalAmount }}</text>
+          <text class="oc-meta">{{ formatShortTime(order.createTime) }} · {{ order.orderNo }}</text>
+        </view>
+        <view v-if="order.refundReason" class="oc-reason">
+          <text class="oc-reason-label">退单原因:</text>
+          <text class="oc-reason-text">{{ order.refundReason }}</text>
+        </view>
+        <view v-if="order.status === 8" class="oc-actions">
+          <view class="btn btn--success" @click.stop="handleApprove(order)">同意退单</view>
+          <view class="btn btn--error" @click.stop="handleReject(order)">驳回</view>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading-more"><text>加载中...</text></view>
+    </view>
+
+    <!-- ==================== 订单详情模式 ==================== -->
+    <scroll-view v-else class="content" scroll-y>
+      <view v-if="loading" class="loading-state"><text>加载中...</text></view>
+
+      <view v-if="orderData" class="detail-content">
+        <!-- 订单信息卡片 -->
+        <view class="info-card">
+          <view class="card-header">
+            <view>
+              <text class="room-name">{{ orderData.roomNo }} {{ orderData.roomName || '' }}</text>
+              <text class="order-meta">{{ orderData.orderNo }} · {{ formatShortTime(orderData.createTime) }}</text>
+            </view>
+            <view class="oc-status" :class="statusTagClass(orderData.status)">
+              <text>{{ statusLabel(orderData.status) }}</text>
+            </view>
+          </view>
+
+          <view class="card-info-grid">
+            <view class="info-item">
+              <text class="info-label">联系人</text>
+              <text class="info-value">{{ orderData.contactName || '-' }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">电话</text>
+              <text class="info-value">{{ orderData.contactPhone || '-' }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">支付方式</text>
+              <text class="info-value">{{ payMethodLabel(orderData.payMethod) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">金额</text>
+              <text class="info-value info-amount">¥{{ orderData.totalAmount }}</text>
+            </view>
+          </view>
+
+          <!-- 菜品明细 -->
+          <view class="items-section">
+            <text class="items-title">菜品明细</text>
+            <view v-for="(item, idx) in (orderData.items || [])" :key="idx" class="item-row">
+              <text class="item-name">{{ item.dishName }} ×{{ item.quantity }} — ¥{{ item.subtotal }}</text>
+              <text v-if="item.specDesc" class="item-spec">({{ item.specDesc }})</text>
+            </view>
+            <view class="items-total">
+              <text>合计 ¥{{ orderData.totalAmount }} · 免配送费</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 退单操作区域 -->
+        <view v-if="canRefund" class="refund-card">
+          <text class="refund-title"> 退单操作</text>
+          <text class="refund-desc">宾馆前台可在任何阶段发起退单。退款将原路返回至客人支付账户。</text>
+          <textarea class="refund-input" v-model="refundReason" placeholder="请输入退单原因..." :maxlength="200" />
+          <view class="refund-btn" @click="handleRefundConfirm">
+            <text>确认退单 · ¥{{ orderData.totalAmount }}</text>
+          </view>
+        </view>
+
+        <!-- 退单审核操作(退单待审核状态) -->
+        <view v-if="orderData.status === 8" class="refund-card refund-card--audit">
+          <text class="refund-title">⚠ 退单审核</text>
+          <text class="refund-desc">顾客已发起退单申请,请选择处理方式。</text>
+          <textarea class="refund-input" v-model="auditReason" placeholder="审核备注(选填)..." :maxlength="200" />
+          <view class="audit-btns">
+            <view class="btn btn--success btn--block" @click="handleApproveConfirm">同意退单</view>
+            <view class="btn btn--error btn--block" @click="handleRejectConfirm">驳回退单</view>
+          </view>
+        </view>
+
+        <!-- 状态时间线 -->
+        <view v-if="hasTimeInfo" class="timeline-section">
+          <text class="timeline-title">📋 状态时间线</text>
+          <view class="timeline-list">
+            <view class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('create')" />
+              <text class="timeline-text">{{ formatTime(orderData.createTime) }} · 顾客下单</text>
+            </view>
+            <view v-if="orderData.payTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('pay')" />
+              <text class="timeline-text">{{ formatTime(orderData.payTime) }} · 支付成功 · ¥{{ orderData.paidAmount || orderData.totalAmount }}</text>
+            </view>
+            <view v-if="orderData.acceptTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('accept')" />
+              <text class="timeline-text">{{ formatTime(orderData.acceptTime) }} · 餐厅前台接单</text>
+            </view>
+            <view v-if="orderData.prepareTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('prepare')" />
+              <text class="timeline-text">{{ formatTime(orderData.prepareTime) }} · 开始备餐</text>
+            </view>
+            <view v-if="orderData.readyTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('ready')" />
+              <text class="timeline-text">{{ formatTime(orderData.readyTime) }} · 出餐完成</text>
+            </view>
+            <view v-if="orderData.deliverTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('deliver')" />
+              <text class="timeline-text">{{ formatTime(orderData.deliverTime) }} · 开始配送</text>
+            </view>
+            <view v-if="orderData.completeTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('complete')" />
+              <text class="timeline-text">{{ formatTime(orderData.completeTime) }} · 配送完成</text>
+            </view>
+            <view v-if="orderData.rejectTime" class="timeline-item">
+              <view class="timeline-dot reject" />
+              <text class="timeline-text">{{ formatTime(orderData.rejectTime) }} · 已拒单{{ orderData.rejectReason ? ':' + orderData.rejectReason : '' }}</text>
+            </view>
+            <view v-if="orderData.refundApplyTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('refundApply')" />
+              <text class="timeline-text">{{ formatTime(orderData.refundApplyTime) }} · 发起退单申请{{ orderData.refundReason ? ':' + orderData.refundReason : '' }}</text>
+            </view>
+            <view v-if="orderData.refundAuditTime" class="timeline-item">
+              <view class="timeline-dot" :class="getDotClass('refundAudit')" />
+              <text class="timeline-text">{{ formatTime(orderData.refundAuditTime) }} · 退单审核{{ orderData.status === 9 ? '通过' : '驳回' }}</text>
+            </view>
+            <view v-if="orderData.refundTime" class="timeline-item">
+              <view class="timeline-dot refund" />
+              <text class="timeline-text">{{ formatTime(orderData.refundTime) }} · 退款完成 · ¥{{ orderData.refundAmount || orderData.totalAmount }}</text>
+            </view>
+            <view v-if="orderData.cancelTime" class="timeline-item">
+              <view class="timeline-dot cancel" />
+              <text class="timeline-text">{{ formatTime(orderData.cancelTime) }} · 订单已取消</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 底部 TabBar -->
+    <view class="bottom-tab">
+      <view class="tab-item" @click="goOverview">
+        <text class="tab-icon">📋</text>
+        <text class="tab-label">订单总览</text>
+      </view>
+      <view class="tab-item" :class="{ 'tab-item--active': !isDetailMode }" @click="goRefundList">
+        <text class="tab-icon">💰</text>
+        <text class="tab-label">退单管理</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from 'vue'
+import api from '@/api'
+
+defineOptions({ name: 'HotelOrderRefund' })
+
+// ==================== 路由参数 ====================
+const orderId = ref('')
+const isDetailMode = ref(false)
+
+// ==================== 数据 ====================
+const orderData = ref(null)
+const loading = ref(false)
+const refundOrders = ref([])
+const refundReason = ref('')
+const auditReason = ref('')
+
+// ==================== 工具 ====================
+function buildItemSummary(order) {
+  if (!order.items || !order.items.length) return ''
+  return order.items.map(i => i.dishName).join('、')
+}
+
+function formatShortTime(timeStr) {
+  if (!timeStr) return ''
+  const match = String(timeStr).match(/(\d{2}:\d{2})/)
+  return match ? match[1] : timeStr
+}
+
+function formatTime(timeStr) {
+  if (!timeStr) return ''
+  const match = String(timeStr).match(/(\d{2}:\d{2})/)
+  return match ? match[1] : timeStr
+}
+
+function payMethodLabel(method) {
+  const map = { ALIPAY: '支付宝', WECHAT: '微信支付', MOCK: '模拟支付', CASH: '现金' }
+  return map[method] || method || '-'
+}
+
+function statusLabel(s) {
+  const map = { 0: '待支付', 1: '待接单', 2: '已接单', 3: '备餐中', 4: '已出餐', 5: '配送中', 6: '已完成', 7: '已拒单', 8: '退单审核中', 9: '已退单', 10: '已超时取消', 11: '已取消' }
+  return map[s] || '未知'
+}
+
+function statusTagClass(s) {
+  if (s === 1) return 'tag--pending'
+  if (s >= 2 && s <= 3) return 'tag--preparing'
+  if (s === 4) return 'tag--ready'
+  if (s === 5) return 'tag--delivering'
+  if (s === 6) return 'tag--done'
+  if (s === 7) return 'tag--rejected'
+  if (s === 8) return 'tag--refund'
+  if (s === 9) return 'tag--refunded'
+  return 'tag--default'
+}
+
+// 终态订单不允许退单
+const canRefund = computed(() => {
+  if (!orderData.value) return false
+  const s = orderData.value.status
+  return s !== 6 && s !== 7 && s !== 9 && s !== 10 && s !== 11
+})
+
+const hasTimeInfo = computed(() => {
+  if (!orderData.value) return false
+  const d = orderData.value
+  return d.payTime || d.acceptTime || d.prepareTime || d.readyTime || d.deliverTime
+    || d.completeTime || d.rejectTime || d.refundApplyTime || d.refundAuditTime
+    || d.refundTime || d.cancelTime
+})
+
+// 时间线圆点颜色
+function getDotClass(stepType) {
+  if (!orderData.value) return ''
+  const status = orderData.value.status
+  if (stepType === 'reject') return 'reject'
+  if (stepType === 'cancel') return 'cancel'
+
+  const stepStatusMap = {
+    create: 0, pay: 1, accept: 2, prepare: 3,
+    ready: 4, deliver: 5, complete: 6,
+    refundApply: 8, refundAudit: 9,
+  }
+  const stepStatus = stepStatusMap[stepType]
+  if (stepStatus === undefined) return ''
+  if (status > stepStatus) return 'completed'
+  return 'active'
+}
+
+// ==================== 数据加载 ====================
+async function loadOrderDetail() {
+  if (!orderId.value) return
+  loading.value = true
+  try {
+    const res = await api.hotelStaffOrderDetail(orderId.value)
+    if (res.code === 200) {
+      orderData.value = res.data
+    }
+  } catch (e) {
+    uni.showToast({ title: '加载订单详情失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadRefundOrders() {
+  loading.value = true
+  try {
+    // 加载退单审核中(8) + 已退单(9) 的订单
+    const [res8, res9] = await Promise.all([
+      api.hotelStaffOrderPage({ pageNum: 1, pageSize: 50, status: 8 }),
+      api.hotelStaffOrderPage({ pageNum: 1, pageSize: 50, status: 9 }),
+    ])
+    let list = []
+    if (res8.code === 200 && res8.data?.records) list = list.concat(res8.data.records)
+    if (res9.code === 200 && res9.data?.records) list = list.concat(res9.data.records)
+    // 按创建时间倒序
+    list.sort((a, b) => new Date(b.createTime) - new Date(a.createTime))
+    refundOrders.value = list
+  } catch (e) {
+    console.error('[退单管理] 加载失败:', e)
+  } finally {
+    loading.value = false
+  }
+}
+
+// ==================== 退单操作 ====================
+async function handleRefundConfirm() {
+  if (!refundReason.value || refundReason.value.trim() === '') {
+    uni.showToast({ title: '请输入退单原因', icon: 'none' }); return
+  }
+  try {
+    await api.hotelHotelRefund(orderData.value.id, refundReason.value.trim())
+    uni.showToast({ title: '退单成功', icon: 'success' })
+    setTimeout(() => goBack(), 1500)
+  } catch (e) {
+    uni.showToast({ title: '退单失败', icon: 'none' })
+  }
+}
+
+async function handleApproveConfirm() {
+  uni.showModal({
+    title: '同意退单',
+    content: `确认同意订单 ${orderData.value.orderNo} 的退单申请?退款将原路返回。`,
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await api.hotelApproveRefund(orderData.value.id, auditReason.value || undefined)
+          uni.showToast({ title: '已同意退单', icon: 'success' })
+          loadOrderDetail()
+        } catch (e) {
+          uni.showToast({ title: '操作失败', icon: 'none' })
+        }
+      }
+    },
+  })
+}
+
+async function handleRejectConfirm() {
+  uni.showModal({
+    title: '驳回退单',
+    content: `确认驳回订单 ${orderData.value.orderNo} 的退单申请?`,
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await api.hotelRejectRefund(orderData.value.id, auditReason.value || undefined)
+          uni.showToast({ title: '已驳回退单', icon: 'success' })
+          loadOrderDetail()
+        } catch (e) {
+          uni.showToast({ title: '操作失败', icon: 'none' })
+        }
+      }
+    },
+  })
+}
+
+// 列表页的审核操作
+function handleApprove(order) {
+  uni.navigateTo({ url: `/pages/hotel/hotel-order-refund?id=${order.id}` })
+}
+function handleReject(order) {
+  uni.navigateTo({ url: `/pages/hotel/hotel-order-refund?id=${order.id}` })
+}
+
+// ==================== 导航 ====================
+function openDetail(order) {
+  uni.navigateTo({ url: `/pages/hotel/hotel-order-refund?id=${order.id}` })
+}
+
+function goBack() {
+  uni.navigateBack({ fail: () => { uni.navigateTo({ url: '/pages/hotel/hotel-order' }) } })
+}
+
+function goOverview() {
+  uni.navigateTo({ url: '/pages/hotel/hotel-order' })
+}
+
+function goRefundList() {
+  if (isDetailMode.value) {
+    uni.redirectTo({ url: '/pages/hotel/hotel-order-refund?mode=refund' })
+  }
+}
+
+// ==================== 生命周期 ====================
+onMounted(() => {
+  // 获取页面参数
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1]
+  const options = currentPage.$page?.options || currentPage.options || {}
+  orderId.value = options.id || ''
+  isDetailMode.value = !!orderId.value
+
+  if (isDetailMode.value) {
+    loadOrderDetail()
+  } else {
+    loadRefundOrders()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f0f2f5;
+  display: flex;
+  flex-direction: column;
+  padding-bottom: 120rpx;
+}
+
+/* ==================== 顶部导航 ==================== */
+.nav-bar {
+  background: #1a365d;
+  padding: 20rpx 24rpx 24rpx;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.nav-back {
+  width: 56rpx;
+  height: 56rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.nav-arrow {
+  font-size: 44rpx;
+  color: #fff;
+  font-weight: 300;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #fff;
+}
+
+/* ==================== 内容区 ==================== */
+.content {
+  flex: 1;
+  padding: 20rpx;
+}
+
+/* ==================== 订单卡片(列表模式) ==================== */
+.order-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+.oc-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.oc-room {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+.oc-room-icon { font-size: 32rpx; }
+.oc-room-no { font-size: 32rpx; font-weight: 700; color: #1a1a1a; }
+.oc-status {
+  padding: 6rpx 16rpx;
+  border-radius: 8rpx;
+  font-size: 22rpx;
+  font-weight: 600;
+}
+.tag--pending { background: #fff0f0; color: #d03050; }
+.tag--preparing { background: #fff8e6; color: #f0a020; }
+.tag--ready { background: #e8f0fe; color: #2080f0; }
+.tag--delivering { background: #e8f8ee; color: #18a058; }
+.tag--done { background: #f0f0f0; color: #999; }
+.tag--rejected { background: #f0f0f0; color: #999; }
+.tag--refund { background: #fff3e0; color: #e67e22; }
+.tag--refunded { background: #f0f0f0; color: #999; }
+.tag--default { background: #f0f0f0; color: #999; }
+
+.oc-items {
+  font-size: 26rpx;
+  color: #666;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 12rpx;
+}
+.oc-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.oc-amount { font-size: 30rpx; font-weight: 700; color: #f0a020; }
+.oc-meta { font-size: 22rpx; color: #bbb; }
+.oc-reason {
+  margin-top: 12rpx;
+  padding: 12rpx 16rpx;
+  background: #fff8f8;
+  border-radius: 8rpx;
+  display: flex;
+  gap: 8rpx;
+}
+.oc-reason-label { font-size: 22rpx; color: #999; }
+.oc-reason-text { font-size: 22rpx; color: #d03050; }
+.oc-actions {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 16rpx;
+  justify-content: flex-end;
+}
+
+/* ==================== 按钮 ==================== */
+.btn {
+  padding: 12rpx 28rpx;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+  font-weight: 600;
+  text-align: center;
+}
+.btn--success { background: #e8f8ee; color: #18a058; }
+.btn--error { background: #fff0f0; color: #d03050; }
+.btn--block { width: 100%; padding: 20rpx; font-size: 28rpx; }
+
+/* ==================== 详情卡片 ==================== */
+.info-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  padding-bottom: 16rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+  margin-bottom: 16rpx;
+}
+.room-name {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1a1a1a;
+  display: block;
+}
+.order-meta {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 6rpx;
+  display: block;
+}
+.card-info-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+  margin-bottom: 16rpx;
+}
+.info-item {
+  width: calc(50% - 10rpx);
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+.info-label { font-size: 22rpx; color: #999; }
+.info-value { font-size: 26rpx; color: #333; }
+.info-amount { font-weight: 700; color: #f0a020; font-size: 28rpx; }
+
+/* 菜品明细 */
+.items-section {
+  background: #f8f9fa;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+}
+.items-title {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.item-row {
+  padding: 6rpx 0;
+}
+.item-name { font-size: 26rpx; color: #333; }
+.item-spec { font-size: 22rpx; color: #999; margin-left: 8rpx; }
+.items-total {
+  border-top: 1rpx dashed #e0e0e0;
+  margin-top: 12rpx;
+  padding-top: 12rpx;
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #333;
+}
+
+/* ==================== 退单操作 ==================== */
+.refund-card {
+  background: #fff5f5;
+  border: 1rpx solid #ffd0d0;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+}
+.refund-card--audit {
+  background: #fff8f0;
+  border-color: #ffe0b2;
+}
+.refund-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #d03050;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.refund-desc {
+  font-size: 24rpx;
+  color: #d03050;
+  display: block;
+  margin-bottom: 16rpx;
+  line-height: 1.5;
+}
+.refund-input {
+  width: 100%;
+  min-height: 120rpx;
+  background: #fff;
+  border: 1rpx solid #ffd0d0;
+  border-radius: 8rpx;
+  padding: 16rpx;
+  font-size: 26rpx;
+  color: #333;
+  box-sizing: border-box;
+  margin-bottom: 16rpx;
+}
+.refund-btn {
+  background: #d03050;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  text-align: center;
+}
+.refund-btn text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 700;
+}
+.audit-btns {
+  display: flex;
+  gap: 16rpx;
+}
+
+/* ==================== 时间线 ==================== */
+.timeline-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+.timeline-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #1a1a1a;
+  display: block;
+  margin-bottom: 20rpx;
+}
+.timeline-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+  padding-left: 8rpx;
+}
+.timeline-item {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.timeline-dot {
+  width: 16rpx;
+  height: 16rpx;
+  border-radius: 50%;
+  background: #f0a020;
+  flex-shrink: 0;
+}
+.timeline-dot.completed { background: #18a058; }
+.timeline-dot.active { background: #f0a020; }
+.timeline-dot.reject { background: #f0a020; }
+.timeline-dot.refund { background: #d03050; }
+.timeline-dot.cancel { background: #999; }
+.timeline-text {
+  font-size: 24rpx;
+  color: #666;
+}
+
+/* ==================== 空状态 / 加载 ==================== */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 0;
+  gap: 16rpx;
+}
+.empty-icon { font-size: 80rpx; }
+.empty-text { font-size: 28rpx; color: #bbb; }
+.loading-state, .loading-more {
+  text-align: center;
+  padding: 60rpx 0;
+  font-size: 26rpx;
+  color: #bbb;
+}
+
+/* ==================== 底部 TabBar ==================== */
+.bottom-tab {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 100rpx;
+  background: #fff;
+  border-top: 1rpx solid #eee;
+  display: flex;
+  z-index: 100;
+}
+.tab-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4rpx;
+}
+.tab-item--active .tab-label {
+  color: #1a365d;
+  font-weight: 600;
+}
+.tab-icon { font-size: 36rpx; }
+.tab-label { font-size: 22rpx; color: #999; }
+</style>

+ 478 - 0
forge-h5-ui/src/pages/hotel/hotel-order.vue

@@ -0,0 +1,478 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <text class="nav-title">🏨 宾馆前台 · 订单总览</text>
+    </view>
+
+    <!-- 统计卡片 -->
+    <view class="stats-row">
+      <view class="stat-card">
+        <text class="stat-label">今日订单</text>
+        <text class="stat-value">{{ dashboard.todayTotal || 0 }}</text>
+        <text v-if="dashboard.todayGrowth != null" :class="['stat-growth', dashboard.todayGrowth >= 0 ? 'up' : 'down']">
+          {{ dashboard.todayGrowth >= 0 ? '↑' : '↓' }} {{ Math.abs(Math.round(dashboard.todayGrowth * 100)) }}%
+        </text>
+      </view>
+      <view class="stat-card">
+        <text class="stat-label">进行中</text>
+        <text class="stat-value stat-value--orange">{{ inProgressTotal }}</text>
+      </view>
+      <view class="stat-card">
+        <text class="stat-label">退单</text>
+        <text class="stat-value stat-value--red">{{ refundCount }}</text>
+      </view>
+    </view>
+
+    <!-- 搜索 + 筛选 -->
+    <view class="filter-bar">
+      <view class="search-box">
+        <text class="search-icon">🔍</text>
+        <input class="search-input" v-model="searchRoomNo" placeholder="搜索房间号..." confirm-type="search" @confirm="loadOrders" />
+      </view>
+      <picker :range="statusFilterLabels" :value="statusFilterIndex" @change="onStatusFilterPick">
+        <view class="filter-picker">
+          <text>{{ currentStatusFilterLabel }}</text>
+          <text class="filter-arrow">▼</text>
+        </view>
+      </picker>
+    </view>
+
+    <!-- 订单列表 -->
+    <view class="order-list">
+      <view v-if="!orders.length && !loading" class="empty-state">
+        <text class="empty-icon"></text>
+        <text class="empty-text">暂无订单</text>
+      </view>
+
+      <view v-for="order in orders" :key="order.id" class="order-card" @click="openDetail(order)">
+        <view class="oc-header">
+          <view class="oc-room">
+            <text class="oc-room-icon">🏠</text>
+            <text class="oc-room-no">{{ order.roomNo || '--' }}</text>
+          </view>
+          <view class="oc-status" :class="statusTagClass(order.status)">
+            <text>{{ statusLabel(order.status) }}</text>
+          </view>
+        </view>
+        <text class="oc-items">{{ buildItemSummary(order) }}</text>
+        <view class="oc-footer">
+          <text class="oc-amount">¥{{ order.totalAmount }}</text>
+          <text class="oc-meta">{{ formatShortTime(order.createTime) }} · {{ order.orderNo }}</text>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading-more">
+        <text>加载中...</text>
+      </view>
+      <view v-if="noMore && orders.length" class="no-more">
+        <text>— 没有更多了 —</text>
+      </view>
+    </view>
+
+    <!-- 底部 TabBar -->
+    <view class="bottom-tab">
+      <view class="tab-item tab-item--active" @click="goOverview">
+        <text class="tab-icon">📋</text>
+        <text class="tab-label">订单总览</text>
+      </view>
+      <view class="tab-item" @click="goRefund">
+        <text class="tab-icon">💰</text>
+        <text class="tab-label">退单管理</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from 'vue'
+import api from '@/api'
+
+defineOptions({ name: 'HotelOrder' })
+
+// ==================== 数据 ====================
+const dashboard = ref({})
+const orders = ref([])
+const loading = ref(false)
+const pageNum = ref(1)
+const pageSize = 20
+const noMore = ref(false)
+const searchRoomNo = ref('')
+const statusFilter = ref('')
+
+// ==================== 状态筛选 ====================
+const statusFilterOptions = [
+  { label: '全部', value: '' },
+  { label: '待支付', value: '0' },
+  { label: '待接单', value: '1' },
+  { label: '已接单', value: '2' },
+  { label: '备餐中', value: '3' },
+  { label: '已出餐', value: '4' },
+  { label: '配送中', value: '5' },
+  { label: '已完成', value: '6' },
+  { label: '已拒单', value: '7' },
+  { label: '退单审核中', value: '8' },
+  { label: '已退单', value: '9' },
+]
+const statusFilterLabels = computed(() => statusFilterOptions.map(o => o.label))
+const statusFilterIndex = computed(() => {
+  const idx = statusFilterOptions.findIndex(o => o.value === statusFilter.value)
+  return idx >= 0 ? idx : 0
+})
+const currentStatusFilterLabel = computed(() => statusFilterOptions[statusFilterIndex.value]?.label || '全部')
+
+function onStatusFilterPick(e) {
+  statusFilter.value = statusFilterOptions[e.detail.value].value
+  resetAndLoad()
+}
+
+// ==================== 统计 ====================
+const inProgressTotal = computed(() => {
+  const d = dashboard.value
+  return (d.pendingCount || 0) + (d.acceptedCount || 0) + (d.preparingCount || 0) + (d.readyCount || 0) + (d.deliveringCount || 0)
+})
+const refundCount = computed(() => orders.value.filter(o => o.status === 8 || o.status === 9).length)
+
+// ==================== 工具 ====================
+function getTodayStr() {
+  const d = new Date()
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function buildItemSummary(order) {
+  if (!order.items || !order.items.length) return ''
+  return order.items.map(i => i.dishName).join('、')
+}
+
+function formatShortTime(createTime) {
+  if (!createTime) return ''
+  const d = new Date(createTime)
+  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+}
+
+function statusLabel(s) {
+  const map = { 0: '待支付', 1: '待接单', 2: '已接单', 3: '备餐中', 4: '已出餐', 5: '配送中', 6: '已完成', 7: '已拒单', 8: '退单审核中', 9: '已退单', 10: '已超时取消', 11: '已取消' }
+  return map[s] || '未知'
+}
+
+function statusTagClass(s) {
+  if (s === 1) return 'tag--pending'
+  if (s >= 2 && s <= 3) return 'tag--preparing'
+  if (s === 4) return 'tag--ready'
+  if (s === 5) return 'tag--delivering'
+  if (s === 6) return 'tag--done'
+  if (s === 7) return 'tag--rejected'
+  if (s === 8) return 'tag--refund'
+  if (s === 9) return 'tag--refunded'
+  return 'tag--default'
+}
+
+// ==================== 数据加载 ====================
+async function loadDashboard() {
+  try {
+    const res = await api.hotelStaffDashboard({ queryDate: getTodayStr() })
+    if (res.code === 200 && res.data) {
+      dashboard.value = res.data
+    }
+  } catch (e) { void e }
+}
+
+async function loadOrders(append = false) {
+  if (loading.value) return
+  loading.value = true
+  try {
+    const params = {
+      pageNum: append ? pageNum.value : 1,
+      pageSize,
+      queryDate: getTodayStr(),
+    }
+    if (searchRoomNo.value.trim()) params.roomNo = searchRoomNo.value.trim()
+    if (statusFilter.value !== '') params.status = Number(statusFilter.value)
+
+    const res = await api.hotelStaffOrderPage(params)
+    if (res.code === 200 && res.data) {
+      const records = res.data.records || []
+      if (append) {
+        orders.value = orders.value.concat(records)
+      } else {
+        orders.value = records
+      }
+      noMore.value = records.length < pageSize
+      if (!append) pageNum.value = 1
+      if (!noMore.value) pageNum.value++
+    }
+  } catch (e) {
+    console.error('[酒店订单] 加载失败:', e)
+  } finally {
+    loading.value = false
+  }
+}
+
+function resetAndLoad() {
+  pageNum.value = 1
+  noMore.value = false
+  orders.value = []
+  loadOrders()
+}
+
+function loadMore() {
+  if (!noMore.value && !loading.value) {
+    loadOrders(true)
+  }
+}
+
+// ==================== 导航 ====================
+function openDetail(order) {
+  uni.navigateTo({ url: `/pages/hotel/hotel-order-refund?id=${order.id}` })
+}
+
+function goOverview() {
+  // 当前页,无需跳转
+}
+
+function goRefund() {
+  uni.navigateTo({ url: '/pages/hotel/hotel-order-refund?mode=refund' })
+}
+
+// ==================== 生命周期 ====================
+onMounted(() => {
+  loadDashboard()
+  loadOrders()
+})
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f0f2f5;
+  display: flex;
+  flex-direction: column;
+  padding-bottom: 120rpx;
+}
+
+/* ==================== 顶部导航 ==================== */
+.nav-bar {
+  background: #1a365d;
+  padding: 20rpx 30rpx 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #fff;
+}
+
+/* ==================== 统计卡片 ==================== */
+.stats-row {
+  display: flex;
+  gap: 16rpx;
+  padding: 20rpx 20rpx 0;
+}
+.stat-card {
+  flex: 1;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx 16rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+.stat-label {
+  font-size: 24rpx;
+  color: #999;
+}
+.stat-value {
+  font-size: 44rpx;
+  font-weight: 700;
+  color: #1a1a1a;
+}
+.stat-value--orange { color: #f0a020; }
+.stat-value--red { color: #d03050; }
+.stat-growth {
+  font-size: 20rpx;
+  font-weight: 600;
+}
+.stat-growth.up { color: #d03050; }
+.stat-growth.down { color: #18a058; }
+
+/* ==================== 搜索 + 筛选 ==================== */
+.filter-bar {
+  display: flex;
+  gap: 16rpx;
+  padding: 20rpx;
+  align-items: center;
+}
+.search-box {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  gap: 12rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+.search-icon {
+  font-size: 28rpx;
+}
+.search-input {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+}
+.filter-picker {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  white-space: nowrap;
+}
+.filter-picker text {
+  font-size: 26rpx;
+  color: #333;
+}
+.filter-arrow {
+  font-size: 20rpx;
+  color: #999;
+}
+
+/* ==================== 订单列表 ==================== */
+.order-list {
+  padding: 0 20rpx;
+}
+.order-card {
+  width: 100%;
+  box-sizing: border-box;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+}
+.oc-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+  min-width: 0;
+}
+.oc-room {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  min-width: 0;
+  flex: 1;
+}
+.oc-room-icon {
+  font-size: 32rpx;
+}
+.oc-room-no {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1a1a1a;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.oc-status {
+  padding: 6rpx 16rpx;
+  border-radius: 8rpx;
+  font-size: 22rpx;
+  font-weight: 600;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+.tag--pending { background: #fff0f0; color: #d03050; }
+.tag--preparing { background: #fff8e6; color: #f0a020; }
+.tag--ready { background: #e8f0fe; color: #2080f0; }
+.tag--delivering { background: #e8f8ee; color: #18a058; }
+.tag--done { background: #f0f0f0; color: #999; }
+.tag--rejected { background: #f0f0f0; color: #999; }
+.tag--refund { background: #fff3e0; color: #e67e22; }
+.tag--refunded { background: #f0f0f0; color: #999; }
+.tag--default { background: #f0f0f0; color: #999; }
+
+.oc-items {
+  font-size: 26rpx;
+  color: #666;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 12rpx;
+}
+.oc-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  min-width: 0;
+}
+.oc-amount {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #f0a020;
+  flex-shrink: 0;
+}
+.oc-meta {
+  font-size: 22rpx;
+  color: #bbb;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 1;
+  text-align: right;
+  margin-left: 12rpx;
+}
+
+/* ==================== 空状态 / 加载 ==================== */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 0;
+  gap: 16rpx;
+}
+.empty-icon { font-size: 80rpx; }
+.empty-text { font-size: 28rpx; color: #bbb; }
+.loading-more, .no-more {
+  text-align: center;
+  padding: 24rpx 0;
+  font-size: 24rpx;
+  color: #bbb;
+}
+
+/* ==================== 底部 TabBar ==================== */
+.bottom-tab {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 100rpx;
+  background: #fff;
+  border-top: 1rpx solid #eee;
+  display: flex;
+  z-index: 100;
+}
+.tab-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4rpx;
+}
+.tab-item--active .tab-label {
+  color: #1a365d;
+  font-weight: 600;
+}
+.tab-icon { font-size: 36rpx; }
+.tab-label { font-size: 22rpx; color: #999; }
+</style>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1660 - 0
forge-h5-ui/src/pages/hotel/staff-order.vue


+ 27 - 2
forge-h5-ui/src/pages/index/index.vue

@@ -251,6 +251,22 @@ const scanBindItem = {
   color: '#10b981',
   bgClass: 'bg-emerald',
 }
+// 餐厅订单看板入口:员工 PAD 端订单管理
+const staffOrderItem = {
+  key: 'staff-order',
+  label: '餐厅订单',
+  icon: '/static/icons/ai-icon/shopping-cart.svg',
+  color: '#d97706',
+  bgClass: 'bg-amber',
+}
+// 酒店前台订单入口
+const hotelOrderItem = {
+  key: 'hotel-order',
+  label: '酒店订单',
+  icon: '/static/icons/ai-icon/briefcase.svg',
+  color: '#1a365d',
+  bgClass: 'bg-blue',
+}
 // #endif
 
 const moreMenuItem = {
@@ -283,8 +299,9 @@ const menuItems = computed(() => {
   // 否则点击会 navigateTo 到不存在的页面而报错
   let prefix = []
   // #ifdef H5
+  // 员工 H5 入口(扫码绑定 + 餐厅订单)
   const hasScanBind = perms.includes('*:*:*') || perms.includes(SCAN_BIND_PERM)
-  prefix = hasScanBind ? [scanBindItem] : []
+  prefix = hasScanBind ? [scanBindItem, staffOrderItem, hotelOrderItem] : [staffOrderItem, hotelOrderItem]
   // #endif
   return [...prefix, componentDemoItem, ...sourceItems].slice(0, 7).concat(moreMenuItem)
 })
@@ -421,7 +438,7 @@ function isRegisteredH5Route(path) {
   ]
   // #ifdef H5
   // 员工扫码两页仅在 H5 构建中注册,小程序构建已被 pages.json 裁掉
-  registered.push('pages/hotel/scan-bind', 'pages/hotel/qr-scanner')
+  registered.push('pages/hotel/scan-bind', 'pages/hotel/qr-scanner', 'pages/hotel/staff-order', 'pages/hotel/hotel-order', 'pages/hotel/hotel-order-refund')
   // #endif
   return registered.includes(normalized)
 }
@@ -507,6 +524,14 @@ function handleShortcut(item) {
     uni.navigateTo({ url: '/pages/hotel/scan-bind' })
     return
   }
+  if (item.key === 'staff-order') {
+    uni.navigateTo({ url: '/pages/hotel/staff-order' })
+    return
+  }
+  if (item.key === 'hotel-order') {
+    uni.navigateTo({ url: '/pages/hotel/hotel-order' })
+    return
+  }
   // #endif
   if (item.fromBackend) {
     openBackendMenu(item)

+ 11 - 0
forge-server/db/migration/V1.0.119__add_hotel_dish_log_operator_fields.sql

@@ -0,0 +1,11 @@
+-- 酒店菜品操作日志表补充操作人字段
+-- 执行时间: 2026-09-09
+-- 说明: 为 hotel_dish_log 表增加 operator_id 和 operator_name 字段,用于记录操作人信息
+
+-- 1. 检查并添加 operator_id 字段
+ALTER TABLE hotel_dish_log
+    ADD COLUMN IF NOT EXISTS operator_id BIGINT COMMENT '操作人ID' AFTER remark;
+
+-- 2. 检查并添加 operator_name 字段
+ALTER TABLE hotel_dish_log
+    ADD COLUMN IF NOT EXISTS operator_name VARCHAR(64) COMMENT '操作人姓名' AFTER operator_id;

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

@@ -2,6 +2,7 @@ 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.domain.HotelDishLog;
 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;
@@ -126,4 +127,13 @@ public class HotelDishController {
     public RespInfo<List<HotelDishVO>> dishSalesRanking(@RequestParam(defaultValue = "10") int limit) {
         return RespInfo.success(hotelDishService.dishSalesRanking(limit));
     }
+
+    /**
+     * 分页查询菜品操作日志。
+     */
+    @GetMapping("/logs")
+    @OperationLog(module = "酒店菜品", type = OperationType.QUERY, desc = "查询菜品操作日志")
+    public RespInfo<IPage<HotelDishLog>> dishLogPage(PageQuery pageQuery, @RequestParam(required = false) Long dishId) {
+        return RespInfo.success(hotelDishService.dishLogPage(pageQuery, dishId));
+    }
 }

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

@@ -41,4 +41,10 @@ public class HotelDishLog extends TenantEntity {
 
     /** 备注 */
     private String remark;
+
+    /** 操作人ID */
+    private Long operatorId;
+
+    /** 操作人姓名 */
+    private String operatorName;
 }

+ 8 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.java

@@ -144,6 +144,14 @@ public class HotelOrder extends TenantEntity {
     @DateTimeFormat(pattern = "yyyy-MM-dd")
     private Date queryDate;
 
+    /** 下单时间区间-开始(非DB字段,用于日期范围筛选) */
+    @TableField(exist = false)
+    private String createTimeStart;
+
+    /** 下单时间区间-结束(非DB字段,用于日期范围筛选) */
+    @TableField(exist = false)
+    private String createTimeEnd;
+
     /** 订单明细(非DB字段) */
     @TableField(exist = false)
     private List<HotelOrderItem> items;

+ 9 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java

@@ -128,11 +128,20 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         if (vo == null) {
             vo = new OrderDashboardVO();
             vo.setPendingCount(0);
+            vo.setAcceptedCount(0);
             vo.setPreparingCount(0);
+            vo.setReadyCount(0);
             vo.setDeliveringCount(0);
             vo.setTodayTotal(0);
+            vo.setYesterdayTotal(0);
             vo.setTodayRevenue(BigDecimal.ZERO);
+            vo.setTodayCompletedCount(0);
+            vo.setRefundCount(0);
         }
+        // 计算今日订单环比昨日增长率
+        vo.setTodayGrowth(calcGrowth(
+                vo.getTodayTotal() != null ? vo.getTodayTotal() : 0,
+                vo.getYesterdayTotal() != null ? vo.getYesterdayTotal() : 0));
         return vo;
     }
 

+ 15 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/OrderDashboardVO.java

@@ -13,18 +13,33 @@ public class OrderDashboardVO {
     /** 待接单数 */
     private Integer pendingCount;
 
+    /** 已接单数 */
+    private Integer acceptedCount;
+
     /** 备餐中数 */
     private Integer preparingCount;
 
+    /** 已出餐数 */
+    private Integer readyCount;
+
     /** 配送中数 */
     private Integer deliveringCount;
 
     /** 今日订单总数(排除拒单和退单) */
     private Integer todayTotal;
 
+    /** 昨日订单总数(排除拒单和退单,用于环比) */
+    private Integer yesterdayTotal;
+
+    /** 今日订单环比昨日增长率(如0.12表示+12%,昨日为0时返回null) */
+    private Double todayGrowth;
+
     /** 今日营收(元) */
     private BigDecimal todayRevenue;
 
     /** 今日已完成订单数(status=6) */
     private Integer todayCompletedCount;
+
+    /** 今日退单数(status=8退单审核中 + status=9已退单) */
+    private Integer refundCount;
 }

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

@@ -2,6 +2,7 @@ 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.domain.HotelDishLog;
 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;
@@ -91,4 +92,13 @@ public interface HotelDishService {
      * @return 菜品列表
      */
     List<HotelDishVO> dishSalesRanking(int limit);
+
+    /**
+     * 分页查询菜品操作日志。
+     *
+     * @param pageQuery 分页参数
+     * @param dishId    菜品ID(可选)
+     * @return 日志分页数据
+     */
+    IPage<HotelDishLog> dishLogPage(PageQuery pageQuery, Long dishId);
 }

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

@@ -72,6 +72,16 @@ public class HotelDishServiceImpl implements HotelDishService {
         return dishMapper.selectSalesRanking(resolveTenantId(), limit);
     }
 
+    @Override
+    public IPage<HotelDishLog> dishLogPage(PageQuery pageQuery, Long dishId) {
+        Page<HotelDishLog> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        QueryWrapper<HotelDishLog> wrapper = new QueryWrapper<>();
+        wrapper.eq("tenant_id", resolveTenantId())
+                .eq(dishId != null, "dish_id", dishId)
+                .orderByDesc("create_time");
+        return dishLogMapper.selectPage(page, wrapper);
+    }
+
     // ==================== 菜品新增/编辑 ====================
 
     @Override
@@ -403,6 +413,9 @@ public class HotelDishServiceImpl implements HotelDishService {
         logEntity.setNewValue(newValue);
         logEntity.setRemark(remark);
         logEntity.setTenantId(resolveTenantId());
+        // 记录操作人
+        logEntity.setOperatorId(SessionHelper.getUserId());
+        logEntity.setOperatorName(SessionHelper.getUsername());
         dishLogMapper.insert(logEntity);
     }
 

+ 11 - 1
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml

@@ -85,6 +85,12 @@
         <if test="query.queryDate != null">
             AND DATE(o.create_time) = #{query.queryDate}
         </if>
+        <if test="query.createTimeStart != null and query.createTimeStart != ''">
+            AND DATE(o.create_time) &gt;= #{query.createTimeStart}
+        </if>
+        <if test="query.createTimeEnd != null and query.createTimeEnd != ''">
+            AND DATE(o.create_time) &lt;= #{query.createTimeEnd}
+        </if>
         ORDER BY o.create_time DESC
     </select>
 
@@ -102,11 +108,15 @@
     <select id="selectDashboard" resultType="com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO">
         SELECT
             SUM(CASE WHEN o.status = 1 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS pendingCount,
+            SUM(CASE WHEN o.status = 2 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS acceptedCount,
             SUM(CASE WHEN o.status = 3 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS preparingCount,
+            SUM(CASE WHEN o.status = 4 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS readyCount,
             SUM(CASE WHEN o.status = 5 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS deliveringCount,
             SUM(CASE WHEN DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS todayTotal,
+            SUM(CASE WHEN DATE(o.create_time) = DATE_SUB(IFNULL(#{queryDate}, CURDATE()), INTERVAL 1 DAY) AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS yesterdayTotal,
             IFNULL(SUM(CASE WHEN DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN o.total_amount ELSE 0 END), 0) AS todayRevenue,
-            SUM(CASE WHEN o.status = 6 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS todayCompletedCount
+            SUM(CASE WHEN o.status = 6 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS todayCompletedCount,
+            SUM(CASE WHEN o.status IN (8, 9) AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS refundCount
         FROM hotel_order o
         WHERE o.tenant_id = #{tenantId}
           AND o.del_flag = 0

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 26 - 7
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 25 - 20
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md