| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780 |
- <template>
- <div class="hotel-order-page">
- <AiCrudPage
- ref="crudRef"
- :api-config="{
- list: 'get@/hotel/order/page',
- }"
- :search-schema="searchSchema"
- :columns="tableColumns"
- row-key="id"
- :hide-add="true"
- :hide-batch-delete="true"
- :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>
- </template>
- </AiCrudPage>
- <!-- 订单详情弹窗 -->
- <NModal
- v-model:show="detailVisible"
- title="订单详情"
- preset="card"
- style="width: 680px;"
- :mask-closable="true"
- >
- <NSpin :show="detailLoading">
- <div v-if="detailData" class="order-detail">
- <!-- 基本信息 -->
- <div class="detail-section">
- <div class="detail-header">
- <span class="detail-order-no">{{ detailData.orderNo }}</span>
- <DictTag dict-type="hotel_order_status" :value="String(detailData.status)" size="small" />
- </div>
- <div class="detail-grid">
- <div class="detail-item">
- <span class="detail-label">房间号</span>
- <span class="detail-value">{{ detailData.roomNo }}</span>
- </div>
- <div class="detail-item">
- <span class="detail-label">联系人</span>
- <span class="detail-value">{{ detailData.contactName }}</span>
- </div>
- <div class="detail-item">
- <span class="detail-label">联系电话</span>
- <span class="detail-value">{{ detailData.contactPhone }}</span>
- </div>
- <div class="detail-item">
- <span class="detail-label">支付方式</span>
- <span class="detail-value">
- <DictTag v-if="detailData.payMethod" dict-type="hotel_pay_method" :value="detailData.payMethod" size="small" />
- </span>
- </div>
- <div class="detail-item">
- <span class="detail-label">配送时间</span>
- <span class="detail-value">
- <template v-if="detailData.deliveryTimeType === 'CUSTOM'">
- {{ detailData.customDeliveryTime }}
- </template>
- <template v-else>
- 立即送达
- </template>
- </span>
- </div>
- <div class="detail-item">
- <span class="detail-label">下单时间</span>
- <span class="detail-value">{{ detailData.createTime }}</span>
- </div>
- </div>
- <div v-if="detailData.note" class="detail-note">
- <span class="detail-label">备注:</span>{{ detailData.note }}
- </div>
- </div>
- <!-- 金额信息 -->
- <div class="detail-section">
- <div class="detail-amount-row">
- <span>菜品总额:<strong>¥{{ calcItemsTotal }}</strong></span>
- <span>配送费:<strong>¥{{ detailData.deliveryFee }}</strong></span>
- <span class="detail-total">合计:<strong>¥{{ detailData.totalAmount }}</strong></span>
- </div>
- </div>
- <!-- 时间线 -->
- <div v-if="hasTimeInfo" class="detail-section">
- <div class="detail-label" style="margin-bottom: 6px;">
- 时间记录
- </div>
- <div class="detail-grid">
- <div v-if="detailData.acceptTime" class="detail-item">
- <span class="detail-label">接单</span>
- <span class="detail-value">{{ detailData.acceptTime }}</span>
- </div>
- <div v-if="detailData.readyTime" class="detail-item">
- <span class="detail-label">出餐</span>
- <span class="detail-value">{{ detailData.readyTime }}</span>
- </div>
- <div v-if="detailData.deliverTime" class="detail-item">
- <span class="detail-label">配送</span>
- <span class="detail-value">{{ detailData.deliverTime }}</span>
- </div>
- <div v-if="detailData.completeTime" class="detail-item">
- <span class="detail-label">完成</span>
- <span class="detail-value">{{ detailData.completeTime }}</span>
- </div>
- <div v-if="detailData.refundTime" class="detail-item">
- <span class="detail-label">退单</span>
- <span class="detail-value">{{ detailData.refundTime }}</span>
- </div>
- </div>
- <div v-if="detailData.rejectReason" class="detail-note">
- <span class="detail-label">拒单原因:</span>{{ detailData.rejectReason }}
- </div>
- <div v-if="detailData.refundReason" class="detail-note">
- <span class="detail-label">退单原因:</span>{{ detailData.refundReason }}
- </div>
- </div>
- <!-- 菜品明细 -->
- <div class="detail-section">
- <div class="detail-label" style="margin-bottom: 6px;">
- 菜品明细
- </div>
- <NDataTable
- :columns="detailItemColumns"
- :data="detailData.items || []"
- :bordered="true"
- size="small"
- :pagination="false"
- />
- </div>
- </div>
- </NSpin>
- </NModal>
- <!-- 原因输入弹窗(拒单/退单审核) -->
- <NModal
- v-model:show="reasonVisible"
- :title="reasonTitle"
- preset="dialog"
- style="width: 420px;"
- :show-icon="false"
- :mask-closable="false"
- positive-text="确认"
- negative-text="取消"
- @positive-click="handleReasonConfirm"
- >
- <NInput
- v-model:value="reasonText"
- type="textarea"
- placeholder="请输入原因(选填)"
- :rows="3"
- />
- </NModal>
- </div>
- </template>
- <script setup>
- import { NDataTable, NInput, NModal, NSpin, useDialog, useMessage } from 'naive-ui'
- import { computed, h, onMounted, onUnmounted, ref } from 'vue'
- import {
- acceptOrder,
- approveRefund,
- completeOrder,
- deliverOrder,
- getOrderDashboard,
- getOrderDetail,
- getOrderPage,
- hotelRefund,
- prepareOrder,
- readyOrder,
- rejectOrder,
- rejectRefund,
- } from '@/api/hotel'
- import { AiCrudPage } from '@/components/ai-form'
- import DictTag from '@/components/DictTag.vue'
- import { useDict } from '@/composables/useDict'
- import { useOrderNotification } from '@/composables/useOrderNotification'
- defineOptions({ name: 'HotelOrder' })
- const message = useMessage()
- const dialog = useDialog()
- const { dict } = useDict('hotel_order_status', 'hotel_pay_method')
- const crudRef = ref(null)
- // ==================== SSE 实时通知 ====================
- const { connected, connect, disconnect, playSound } = useOrderNotification({
- onNewOrder: () => {
- crudRef.value?.refresh()
- loadDashboard()
- },
- onRefundRequest: () => {
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- /** 监听页面内通知事件(由 useOrderNotification 派发) */
- function handleHotelNotification(e) {
- const { type, message: msg, level } = e.detail
- const nType = level === 'warning' ? 'warning' : 'info'
- $notification[nType]({
- title: type === 'new-order' ? '新订单' : '退单申请',
- content: msg,
- duration: 5000,
- })
- }
- onMounted(() => {
- loadDashboard()
- connect()
- // 页面加载时,检查是否有待接单订单,如果有则触发语音通知
- checkPendingOrders()
- window.addEventListener('hotel-notification', handleHotelNotification)
- })
- onUnmounted(() => {
- disconnect()
- window.removeEventListener('hotel-notification', handleHotelNotification)
- })
- // ==================== 看板统计 ====================
- 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
- }
- }
- /**
- * 页面加载时检查待接单订单,如果有则触发语音通知。
- * 这样刷新页面就能听到声音提醒了。
- */
- async function checkPendingOrders() {
- try {
- // 查询待接单订单(status=1)
- const res = await getOrderPage({
- pageNum: 1,
- pageSize: 1,
- status: 1, // 待接单
- })
- if (res.code === 200 && res.data?.total > 0) {
- console.warn('[SSE] 📢 检测到待接单订单,触发语音通知')
- // 播放声音
- playSound('new-order')
- // 触发一次新订单通知(显示桌面弹窗)
- window.dispatchEvent(new CustomEvent('hotel-notification', {
- detail: {
- type: 'new-order',
- message: `您有 ${res.data.total} 个待接单订单`,
- level: 'info',
- },
- }))
- }
- }
- catch (err) {
- console.error('[SSE] 检查待接单订单失败:', err)
- }
- }
- // ==================== 搜索配置 ====================
- const statusOptions = computed(() => {
- const list = dict.value.hotel_order_status || []
- return list.map(item => ({ label: item.label, value: Number(item.value) }))
- })
- const payMethodOptions = computed(() => dict.value.hotel_pay_method || [])
- const searchSchema = computed(() => [
- {
- field: 'roomNo',
- label: '房间号',
- type: 'input',
- props: { placeholder: '请输入房间号' },
- },
- {
- field: 'status',
- label: '订单状态',
- type: 'select',
- props: { placeholder: '请选择状态', options: statusOptions.value },
- },
- {
- field: 'contactName',
- label: '联系人',
- type: 'input',
- props: { placeholder: '请输入联系人姓名' },
- },
- {
- field: 'payMethod',
- label: '支付方式',
- type: 'select',
- props: { placeholder: '请选择支付方式', options: payMethodOptions.value },
- },
- ])
- // ==================== 状态标签渲染 ====================
- function renderStatus(status) {
- return h(DictTag, { dictType: 'hotel_order_status', value: String(status), size: 'small' })
- }
- // ==================== 表格列配置 ====================
- const tableColumns = computed(() => [
- { prop: 'orderNo', label: '订单号', width: 180 },
- { prop: 'roomNo', label: '房间号', width: 90 },
- { prop: 'contactName', label: '联系人', width: 90 },
- { prop: 'itemCount', label: '菜品数', width: 70 },
- {
- prop: 'totalAmount',
- label: '金额(元)',
- width: 100,
- render: row => `¥${row.totalAmount}`,
- },
- {
- prop: 'status',
- label: '状态',
- width: 110,
- render: row => renderStatus(row.status),
- },
- { prop: 'createTime', label: '下单时间', width: 172 },
- {
- label: '操作',
- width: 320,
- fixed: 'right',
- render: (row) => {
- const actions = []
- const s = row.status
- // 查看详情(所有状态)
- actions.push(h('a', {
- class: 'text-info cursor-pointer hover:opacity-80',
- onClick: () => openDetail(row),
- }, '详情'))
- // 接单(仅待接单)
- if (s === 1) {
- actions.push(h('a', {
- class: 'text-success cursor-pointer hover:opacity-80',
- onClick: () => handleAction('accept', row),
- }, '接单'))
- actions.push(h('a', {
- class: 'text-error cursor-pointer hover:opacity-80',
- onClick: () => handleAction('reject', row),
- }, '拒单'))
- }
- // 备餐(仅已接单)
- if (s === 2) {
- actions.push(h('a', {
- class: 'text-primary cursor-pointer hover:opacity-80',
- onClick: () => handleAction('prepare', row),
- }, '备餐'))
- }
- // 出餐(仅备餐中)
- if (s === 3) {
- actions.push(h('a', {
- class: 'text-success cursor-pointer hover:opacity-80',
- onClick: () => handleAction('ready', row),
- }, '出餐'))
- }
- // 配送(仅已出餐)
- if (s === 4) {
- actions.push(h('a', {
- class: 'text-primary cursor-pointer hover:opacity-80',
- onClick: () => handleAction('deliver', row),
- }, '配送'))
- }
- // 完成(仅配送中)
- if (s === 5) {
- actions.push(h('a', {
- class: 'text-success cursor-pointer hover:opacity-80',
- onClick: () => handleAction('complete', row),
- }, '完成'))
- }
- // 退单审核(仅退单待审核)
- if (s === 8) {
- actions.push(h('a', {
- class: 'text-success cursor-pointer hover:opacity-80',
- onClick: () => handleAction('approveRefund', row),
- }, '同意退单'))
- actions.push(h('a', {
- class: 'text-error cursor-pointer hover:opacity-80',
- onClick: () => handleAction('rejectRefund', row),
- }, '驳回'))
- }
- // 前台退单(除已完成、已退单、已拒单外)
- if (s !== 6 && s !== 7 && s !== 9) {
- actions.push(h('a', {
- class: 'text-warning cursor-pointer hover:opacity-80',
- onClick: () => handleAction('hotelRefund', row),
- }, '退单'))
- }
- return h('div', { class: 'flex gap-2' }, actions)
- },
- },
- ])
- // ==================== 订单操作 ====================
- const reasonVisible = ref(false)
- const reasonTitle = ref('')
- const reasonText = ref('')
- const reasonCallback = ref(null)
- function handleAction(action, row) {
- switch (action) {
- case 'accept':
- dialog.warning({
- title: '确认接单',
- content: `确认接受订单 ${row.orderNo} 吗?`,
- positiveText: '确认接单',
- negativeText: '取消',
- onPositiveClick: async () => {
- await acceptOrder(row.id)
- message.success('接单成功')
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- break
- case 'reject':
- openReasonModal('拒单', async (reason) => {
- await rejectOrder(row.id, reason)
- message.success('已拒单')
- crudRef.value?.refresh()
- loadDashboard()
- })
- break
- case 'prepare':
- dialog.warning({
- title: '开始备餐',
- content: `确认开始备餐 ${row.orderNo}?`,
- positiveText: '确认',
- negativeText: '取消',
- onPositiveClick: async () => {
- await prepareOrder(row.id)
- message.success('已开始备餐')
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- break
- case 'ready':
- dialog.warning({
- title: '出餐完成',
- content: `确认订单 ${row.orderNo} 出餐完成?`,
- positiveText: '确认出餐',
- negativeText: '取消',
- onPositiveClick: async () => {
- await readyOrder(row.id)
- message.success('已出餐')
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- break
- case 'deliver':
- dialog.warning({
- title: '开始配送',
- content: `确认开始配送订单 ${row.orderNo}?`,
- positiveText: '确认配送',
- negativeText: '取消',
- onPositiveClick: async () => {
- await deliverOrder(row.id)
- message.success('已开始配送')
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- break
- case 'complete':
- dialog.warning({
- title: '完成配送',
- content: `确认订单 ${row.orderNo} 配送完成?`,
- positiveText: '确认完成',
- negativeText: '取消',
- onPositiveClick: async () => {
- await completeOrder(row.id)
- message.success('订单已完成')
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- break
- case 'hotelRefund':
- openReasonModal('前台退单', async (reason) => {
- await hotelRefund(row.id, reason)
- message.success('退单成功')
- crudRef.value?.refresh()
- loadDashboard()
- })
- break
- case 'approveRefund':
- dialog.warning({
- title: '同意退单',
- content: `确认同意订单 ${row.orderNo} 的退单申请?`,
- positiveText: '同意退单',
- negativeText: '取消',
- onPositiveClick: async () => {
- await approveRefund(row.id)
- message.success('已同意退单')
- crudRef.value?.refresh()
- loadDashboard()
- },
- })
- break
- case 'rejectRefund':
- openReasonModal('驳回退单', async (reason) => {
- await rejectRefund(row.id, reason)
- message.success('已驳回退单申请')
- crudRef.value?.refresh()
- loadDashboard()
- })
- break
- }
- }
- function openReasonModal(title, callback) {
- reasonTitle.value = title
- reasonText.value = ''
- reasonCallback.value = callback
- reasonVisible.value = true
- }
- async function handleReasonConfirm() {
- if (reasonCallback.value) {
- await reasonCallback.value(reasonText.value || undefined)
- }
- }
- // ==================== 订单详情 ====================
- const detailVisible = ref(false)
- const detailLoading = ref(false)
- const detailData = ref(null)
- const detailItemColumns = [
- { title: '菜品名称', key: 'dishName', minWidth: 120 },
- { title: '规格', key: 'specDesc', width: 120, render: row => row.specDesc || '' },
- { title: '加料', key: 'additionsDesc', width: 120, render: row => row.additionsDesc || '' },
- { title: '数量', key: 'quantity', width: 60 },
- { title: '单价', key: 'unitPrice', width: 80, render: row => `¥${row.unitPrice}` },
- { title: '小计', key: 'subtotal', width: 80, render: row => `¥${row.subtotal}` },
- { title: '备注', key: 'note', width: 100, render: row => row.note || '' },
- ]
- const calcItemsTotal = computed(() => {
- if (!detailData.value?.items)
- return '0.00'
- const sum = detailData.value.items.reduce((acc, item) => acc + Number(item.subtotal || 0), 0)
- return sum.toFixed(2)
- })
- const hasTimeInfo = computed(() => {
- if (!detailData.value)
- return false
- const d = detailData.value
- return d.acceptTime || d.readyTime || d.deliverTime || d.completeTime || d.refundTime || d.rejectReason || d.refundReason
- })
- async function openDetail(row) {
- detailVisible.value = true
- detailLoading.value = true
- detailData.value = null
- try {
- const res = await getOrderDetail(row.id)
- if (res.code === 200) {
- detailData.value = res.data
- }
- }
- catch {
- message.error('加载订单详情失败')
- }
- finally {
- detailLoading.value = false
- }
- }
- </script>
- <style scoped>
- .hotel-order-page {
- height: 100%;
- }
- /* ==================== 看板统计 ==================== */
- .order-dashboard {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 0 4px;
- }
- .dashboard-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 56px;
- }
- .dashboard-value {
- font-size: 18px;
- font-weight: 700;
- line-height: 1.2;
- color: #333;
- }
- .dashboard-value.warning {
- color: #f0a020;
- }
- .dashboard-value.primary {
- color: #2080f0;
- }
- .dashboard-value.success {
- color: #18a058;
- }
- .dashboard-label {
- font-size: 12px;
- color: #999;
- margin-top: 2px;
- }
- .dashboard-divider {
- width: 1px;
- height: 28px;
- background: #e8e8e8;
- margin: 0 4px;
- }
- /* ==================== SSE连接指示器 ==================== */
- .sse-indicator {
- display: flex;
- align-items: center;
- margin-left: 4px;
- }
- .sse-dot {
- display: inline-block;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #18a058;
- animation: sse-pulse 2s ease-in-out infinite;
- }
- @keyframes sse-pulse {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.4;
- }
- }
- /* ==================== 订单详情 ==================== */
- .order-detail {
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- .detail-section {
- padding-bottom: 12px;
- border-bottom: 1px solid #f0f0f0;
- }
- .detail-section:last-child {
- border-bottom: none;
- padding-bottom: 0;
- }
- .detail-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
- }
- .detail-order-no {
- font-size: 16px;
- font-weight: 700;
- color: #333;
- }
- .detail-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 8px 16px;
- }
- .detail-item {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .detail-label {
- font-size: 12px;
- color: #999;
- }
- .detail-value {
- font-size: 14px;
- color: #333;
- }
- .detail-note {
- margin-top: 8px;
- font-size: 13px;
- color: #666;
- padding: 6px 10px;
- background: #fafafa;
- border-radius: 4px;
- }
- .detail-amount-row {
- display: flex;
- gap: 24px;
- font-size: 14px;
- color: #333;
- }
- .detail-total {
- margin-left: auto;
- font-size: 16px;
- color: #f0a020;
- }
- </style>
|