feat: 添加订单大厅看板 UI(四列看板 + 声音提醒 + 桌面通知)
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 56s
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 56s
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<!-- 文件职责:看板列容器(标题 + 计数 + 滚动区)。 -->
|
||||
<script setup lang="ts">
|
||||
import type { BoardColumnKey } from '../types';
|
||||
|
||||
import type { OrderBoardCard } from '#/api/order-board';
|
||||
|
||||
import BoardOrderCard from './BoardOrderCard.vue';
|
||||
|
||||
const {
|
||||
cards = [],
|
||||
colorClass = '',
|
||||
isLoading = false,
|
||||
columnKey,
|
||||
title = '',
|
||||
} = defineProps<{
|
||||
cards: OrderBoardCard[];
|
||||
colorClass: string;
|
||||
columnKey: BoardColumnKey;
|
||||
isLoading: boolean;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: [orderId: string];
|
||||
completePreparation: [orderId: string];
|
||||
confirmDelivery: [orderId: string];
|
||||
reject: [orderId: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ob-column" :class="[colorClass]">
|
||||
<!-- 1. 列标题 -->
|
||||
<div class="ob-column__header">
|
||||
<span class="ob-column__title">{{ title }}</span>
|
||||
<a-badge :count="cards.length" :overflow-count="999" />
|
||||
</div>
|
||||
|
||||
<!-- 2. 滚动区 -->
|
||||
<div class="ob-column__body">
|
||||
<a-spin :spinning="isLoading">
|
||||
<div v-if="cards.length === 0" class="ob-column__empty">暂无订单</div>
|
||||
<BoardOrderCard
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:column-key="columnKey"
|
||||
@accept="emit('accept', $event)"
|
||||
@reject="emit('reject', $event)"
|
||||
@complete-preparation="emit('completePreparation', $event)"
|
||||
@confirm-delivery="emit('confirmDelivery', $event)"
|
||||
/>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!-- 文件职责:订单看板卡片(信息 + 操作按钮)。 -->
|
||||
<script setup lang="ts">
|
||||
import type { BoardColumnKey } from '../types';
|
||||
|
||||
import type { OrderBoardCard } from '#/api/order-board';
|
||||
|
||||
import { CHANNEL_TAG_COLORS } from '../composables/order-board-page/constants';
|
||||
import {
|
||||
channelText,
|
||||
deliveryTypeText,
|
||||
elapsedMinutes,
|
||||
formatAmount,
|
||||
formatTime,
|
||||
} from '../composables/order-board-page/helpers';
|
||||
|
||||
const { card, columnKey } = defineProps<{
|
||||
card: OrderBoardCard;
|
||||
columnKey: BoardColumnKey;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: [orderId: string];
|
||||
completePreparation: [orderId: string];
|
||||
confirmDelivery: [orderId: string];
|
||||
reject: [orderId: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ob-card"
|
||||
:class="[
|
||||
columnKey === 'pending' && 'ob-card--pulse',
|
||||
card.isUrged && 'ob-card--urged',
|
||||
columnKey === 'completed' && 'ob-card--dimmed',
|
||||
]"
|
||||
>
|
||||
<!-- 1. 卡片头部 -->
|
||||
<div class="ob-card__header">
|
||||
<span class="ob-card__order-no">{{ card.orderNo }}</span>
|
||||
<a-tag
|
||||
v-if="card.channel"
|
||||
:color="CHANNEL_TAG_COLORS[card.channel] ?? 'default'"
|
||||
size="small"
|
||||
>
|
||||
{{ channelText(card.channel) }}
|
||||
</a-tag>
|
||||
<a-tag v-if="card.isUrged" color="red" size="small">
|
||||
催{{ card.urgeCount }}次
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<!-- 2. 卡片内容 -->
|
||||
<div class="ob-card__body">
|
||||
<div class="ob-card__row">
|
||||
<span>{{ deliveryTypeText(card.deliveryType) }}</span>
|
||||
<span v-if="card.tableNo">桌号: {{ card.tableNo }}</span>
|
||||
</div>
|
||||
<div v-if="card.customerName" class="ob-card__row">
|
||||
{{ card.customerName }}
|
||||
</div>
|
||||
<div v-if="card.itemsSummary" class="ob-card__row ob-card__items">
|
||||
{{ card.itemsSummary }}
|
||||
</div>
|
||||
<div class="ob-card__row">
|
||||
<span class="ob-card__amount">{{ formatAmount(card.paidAmount) }}</span>
|
||||
<span class="ob-card__time">{{ formatTime(card.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="columnKey === 'making'" class="ob-card__elapsed">
|
||||
已用 {{ elapsedMinutes(card.acceptedAt) }} 分钟
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 操作按钮 -->
|
||||
<div class="ob-card__actions">
|
||||
<template v-if="columnKey === 'pending'">
|
||||
<a-button type="primary" size="small" @click="emit('accept', card.id)">
|
||||
接单
|
||||
</a-button>
|
||||
<a-button size="small" danger @click="emit('reject', card.id)">
|
||||
拒单
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else-if="columnKey === 'making'">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="emit('completePreparation', card.id)"
|
||||
>
|
||||
出餐完成
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else-if="columnKey === 'delivering'">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="emit('confirmDelivery', card.id)"
|
||||
>
|
||||
{{ card.deliveryType === 2 ? '确认送达' : '确认取餐' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- 文件职责:订单大厅统计条。 -->
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
completedCount = 0,
|
||||
deliveringCount = 0,
|
||||
makingCount = 0,
|
||||
pendingCount = 0,
|
||||
todayTotal = 0,
|
||||
} = defineProps<{
|
||||
completedCount: number;
|
||||
deliveringCount: number;
|
||||
makingCount: number;
|
||||
pendingCount: number;
|
||||
todayTotal: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ob-stats">
|
||||
<div class="ob-stats__item">
|
||||
<span class="ob-stats__value">{{ todayTotal }}</span>
|
||||
<span class="ob-stats__label">今日订单</span>
|
||||
</div>
|
||||
<div class="ob-stats__item ob-stats__item--pending">
|
||||
<span class="ob-stats__value">{{ pendingCount }}</span>
|
||||
<span class="ob-stats__label">待接单</span>
|
||||
</div>
|
||||
<div class="ob-stats__item ob-stats__item--making">
|
||||
<span class="ob-stats__value">{{ makingCount }}</span>
|
||||
<span class="ob-stats__label">制作中</span>
|
||||
</div>
|
||||
<div class="ob-stats__item ob-stats__item--delivering">
|
||||
<span class="ob-stats__value">{{ deliveringCount }}</span>
|
||||
<span class="ob-stats__label">配送/待取</span>
|
||||
</div>
|
||||
<div class="ob-stats__item ob-stats__item--completed">
|
||||
<span class="ob-stats__value">{{ completedCount }}</span>
|
||||
<span class="ob-stats__label">已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- 文件职责:订单大厅工具栏(门店选择 + 渠道筛选 + 声音开关 + 连接状态)。 -->
|
||||
<script setup lang="ts">
|
||||
import type { BoardChannelFilter } from '../types';
|
||||
|
||||
import { CHANNEL_OPTIONS } from '../composables/order-board-page/constants';
|
||||
|
||||
const {
|
||||
channel = 'all',
|
||||
isConnected = false,
|
||||
isSoundEnabled = true,
|
||||
storeId = '',
|
||||
} = defineProps<{
|
||||
channel: BoardChannelFilter;
|
||||
isConnected: boolean;
|
||||
isSoundEnabled: boolean;
|
||||
storeId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:channel': [value: BoardChannelFilter];
|
||||
'update:isSoundEnabled': [value: boolean];
|
||||
'update:storeId': [value: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ob-toolbar">
|
||||
<!-- 1. 门店 ID 输入 -->
|
||||
<div class="ob-toolbar__store">
|
||||
<span class="ob-toolbar__label">门店ID:</span>
|
||||
<a-input
|
||||
:value="storeId"
|
||||
placeholder="输入门店 ID"
|
||||
style="width: 200px"
|
||||
@change="(e: any) => emit('update:storeId', e.target.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 2. 渠道筛选 -->
|
||||
<div class="ob-toolbar__channel">
|
||||
<a-radio-group
|
||||
:value="channel"
|
||||
button-style="solid"
|
||||
size="small"
|
||||
@change="(e: any) => emit('update:channel', e.target.value)"
|
||||
>
|
||||
<a-radio-button
|
||||
v-for="opt in CHANNEL_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 3. 声音开关 + 连接状态 -->
|
||||
<div class="ob-toolbar__right">
|
||||
<a-switch
|
||||
:checked="isSoundEnabled"
|
||||
checked-children="声音开"
|
||||
un-checked-children="声音关"
|
||||
@change="(val: boolean) => emit('update:isSoundEnabled', val)"
|
||||
/>
|
||||
<span
|
||||
class="ob-toolbar__status"
|
||||
:class="[
|
||||
isConnected ? 'ob-toolbar__status--on' : 'ob-toolbar__status--off',
|
||||
]"
|
||||
>
|
||||
{{ isConnected ? '已连接' : '未连接' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- 文件职责:拒单原因弹窗。 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const { open = false } = defineProps<{
|
||||
open: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [];
|
||||
confirm: [reason: string];
|
||||
}>();
|
||||
|
||||
const reason = ref('');
|
||||
|
||||
function handleOk() {
|
||||
if (!reason.value.trim()) return;
|
||||
emit('confirm', reason.value.trim());
|
||||
reason.value = '';
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
reason.value = '';
|
||||
emit('cancel');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" title="拒单原因" @ok="handleOk" @cancel="handleCancel">
|
||||
<a-textarea v-model:value="reason" :rows="3" placeholder="请输入拒单原因" />
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 文件职责:订单大厅卡片操作动作(接单、拒单、出餐、确认送达)。
|
||||
*/
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
acceptOrderApi,
|
||||
completePreparationApi,
|
||||
confirmDeliveryApi,
|
||||
rejectOrderApi,
|
||||
} from '#/api/order-board';
|
||||
|
||||
/** 卡片操作动作选项。 */
|
||||
interface CardActionsOptions {
|
||||
onRefresh: () => Promise<void>;
|
||||
onAccepted?: () => void;
|
||||
}
|
||||
|
||||
/** 创建卡片操作动作。 */
|
||||
export function createCardActions(options: CardActionsOptions) {
|
||||
const { onRefresh, onAccepted } = options;
|
||||
|
||||
// 1. 接单
|
||||
async function handleAccept(orderId: string): Promise<void> {
|
||||
await acceptOrderApi(orderId);
|
||||
message.success('接单成功');
|
||||
onAccepted?.();
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
// 2. 拒单
|
||||
async function handleReject(orderId: string, reason: string): Promise<void> {
|
||||
await rejectOrderApi(orderId, { reason });
|
||||
message.success('已拒单');
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
// 3. 出餐完成
|
||||
async function handleCompletePreparation(orderId: string): Promise<void> {
|
||||
await completePreparationApi(orderId);
|
||||
message.success('出餐完成');
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
// 4. 确认送达/取餐
|
||||
async function handleConfirmDelivery(orderId: string): Promise<void> {
|
||||
await confirmDeliveryApi(orderId);
|
||||
message.success('已确认送达');
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
return {
|
||||
handleAccept,
|
||||
handleReject,
|
||||
handleCompletePreparation,
|
||||
handleConfirmDelivery,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 文件职责:订单大厅常量与配置。
|
||||
*/
|
||||
import type { BoardChannelFilter, BoardColumnKey } from '../../types';
|
||||
|
||||
/** 看板列定义。 */
|
||||
export const BOARD_COLUMNS: {
|
||||
colorClass: string;
|
||||
key: BoardColumnKey;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ key: 'pending', title: '待接单', colorClass: 'ob-col-pending' },
|
||||
{ key: 'making', title: '制作中', colorClass: 'ob-col-making' },
|
||||
{ key: 'delivering', title: '配送/待取', colorClass: 'ob-col-delivering' },
|
||||
{ key: 'completed', title: '已完成', colorClass: 'ob-col-completed' },
|
||||
];
|
||||
|
||||
/** 渠道筛选选项。 */
|
||||
export const CHANNEL_OPTIONS: { label: string; value: BoardChannelFilter }[] = [
|
||||
{ value: 'all', label: '全部渠道' },
|
||||
{ value: 'miniprogram', label: '小程序' },
|
||||
{ value: 'scan', label: '扫码点餐' },
|
||||
{ value: 'staff', label: '收银台' },
|
||||
{ value: 'phone', label: '电话预约' },
|
||||
{ value: 'thirdparty', label: '第三方' },
|
||||
];
|
||||
|
||||
/** 订单状态映射。 */
|
||||
export const ORDER_STATUS_MAP: Record<number, string> = {
|
||||
0: '待付款',
|
||||
1: '待接单',
|
||||
2: '制作中',
|
||||
3: '待取餐',
|
||||
4: '已完成',
|
||||
5: '已取消',
|
||||
};
|
||||
|
||||
/** 渠道映射。 */
|
||||
export const CHANNEL_MAP: Record<number, string> = {
|
||||
0: '未知',
|
||||
1: '小程序',
|
||||
2: '扫码点餐',
|
||||
3: '收银台',
|
||||
4: '电话预约',
|
||||
5: '第三方',
|
||||
};
|
||||
|
||||
/** 履约类型映射。 */
|
||||
export const DELIVERY_TYPE_MAP: Record<number, string> = {
|
||||
0: '堂食',
|
||||
1: '自提',
|
||||
2: '外卖',
|
||||
};
|
||||
|
||||
/** 渠道标签颜色。 */
|
||||
export const CHANNEL_TAG_COLORS: Record<number, string> = {
|
||||
1: 'green',
|
||||
2: 'blue',
|
||||
3: 'orange',
|
||||
4: 'purple',
|
||||
5: 'red',
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 文件职责:订单大厅数据加载动作。
|
||||
*/
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { OrderBoardPageState } from '../../types';
|
||||
|
||||
import type { OrderBoardCard } from '#/api/order-board';
|
||||
|
||||
import {
|
||||
getOrderBoardApi,
|
||||
getOrderBoardStatsApi,
|
||||
getPendingSinceApi,
|
||||
} from '#/api/order-board';
|
||||
|
||||
/** 数据加载动作选项。 */
|
||||
interface DataActionsOptions {
|
||||
state: OrderBoardPageState;
|
||||
statsRef: Ref<{
|
||||
completedCount: number;
|
||||
deliveringCount: number;
|
||||
makingCount: number;
|
||||
pendingCount: number;
|
||||
todayTotal: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 创建数据加载动作。 */
|
||||
export function createDataActions(options: DataActionsOptions) {
|
||||
const { state, statsRef } = options;
|
||||
|
||||
// 1. 加载完整看板数据
|
||||
async function loadFullBoard(): Promise<void> {
|
||||
if (!state.selectedStoreId) return;
|
||||
state.isLoading = true;
|
||||
try {
|
||||
const channelParam = state.channel === 'all' ? undefined : state.channel;
|
||||
const result = await getOrderBoardApi({
|
||||
storeId: state.selectedStoreId,
|
||||
channel: channelParam,
|
||||
});
|
||||
state.pendingCards = result.pending;
|
||||
state.makingCards = result.making;
|
||||
state.deliveringCards = result.delivering;
|
||||
state.completedCards = result.completed;
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 加载统计数据
|
||||
async function loadStats(): Promise<void> {
|
||||
if (!state.selectedStoreId) return;
|
||||
const result = await getOrderBoardStatsApi({
|
||||
storeId: state.selectedStoreId,
|
||||
});
|
||||
statsRef.value = result;
|
||||
}
|
||||
|
||||
// 3. 重连补偿拉取
|
||||
async function catchUpSince(since: Date): Promise<OrderBoardCard[]> {
|
||||
if (!state.selectedStoreId) return [];
|
||||
return getPendingSinceApi({
|
||||
storeId: state.selectedStoreId,
|
||||
since: since.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return { loadFullBoard, loadStats, catchUpSince };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 文件职责:订单大厅纯工具函数(格式化、过滤等)。
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { CHANNEL_MAP, DELIVERY_TYPE_MAP } from './constants';
|
||||
|
||||
/** 格式化金额(保留两位小数)。 */
|
||||
export function formatAmount(value: number): string {
|
||||
return `¥${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 格式化时间为 HH:mm:ss。 */
|
||||
export function formatTime(value?: null | string): string {
|
||||
if (!value) return '--';
|
||||
return dayjs(value).format('HH:mm:ss');
|
||||
}
|
||||
|
||||
/** 格式化时间为 MM-DD HH:mm。 */
|
||||
export function formatDateTime(value?: null | string): string {
|
||||
if (!value) return '--';
|
||||
return dayjs(value).format('MM-DD HH:mm');
|
||||
}
|
||||
|
||||
/** 计算已用时间(分钟)。 */
|
||||
export function elapsedMinutes(since?: null | string): number {
|
||||
if (!since) return 0;
|
||||
return Math.floor(dayjs().diff(dayjs(since), 'minute'));
|
||||
}
|
||||
|
||||
/** 获取渠道文本。 */
|
||||
export function channelText(channel: number): string {
|
||||
return CHANNEL_MAP[channel] ?? '未知';
|
||||
}
|
||||
|
||||
/** 获取履约类型文本。 */
|
||||
export function deliveryTypeText(deliveryType: number): string {
|
||||
return DELIVERY_TYPE_MAP[deliveryType] ?? '未知';
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 文件职责:订单大厅声音播放与桌面通知。
|
||||
*/
|
||||
|
||||
/** 通知动作选项。 */
|
||||
interface NotificationActionsOptions {
|
||||
isSoundEnabled: { value: boolean };
|
||||
}
|
||||
|
||||
/** 创建通知动作。 */
|
||||
export function createNotificationActions(options: NotificationActionsOptions) {
|
||||
let newOrderAudio: HTMLAudioElement | null = null;
|
||||
let urgeAudio: HTMLAudioElement | null = null;
|
||||
let isPlaying = false;
|
||||
|
||||
// 1. 懒初始化音频(需要用户交互后才能播放)
|
||||
function initAudio(): void {
|
||||
if (!newOrderAudio) {
|
||||
newOrderAudio = new Audio('/sounds/new-order.mp3');
|
||||
newOrderAudio.loop = true;
|
||||
}
|
||||
if (!urgeAudio) {
|
||||
urgeAudio = new Audio('/sounds/urge.mp3');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 播放新订单提示音(循环播放直到接单)
|
||||
function playNewOrderSound(): void {
|
||||
if (!options.isSoundEnabled.value || isPlaying) return;
|
||||
initAudio();
|
||||
isPlaying = true;
|
||||
newOrderAudio?.play().catch(() => {
|
||||
// 浏览器可能阻止自动播放
|
||||
isPlaying = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 停止新订单提示音
|
||||
function stopNewOrderSound(): void {
|
||||
if (newOrderAudio) {
|
||||
newOrderAudio.pause();
|
||||
newOrderAudio.currentTime = 0;
|
||||
}
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
// 4. 播放催单提示音(播放一次)
|
||||
function playUrgeSound(): void {
|
||||
if (!options.isSoundEnabled.value) return;
|
||||
initAudio();
|
||||
urgeAudio?.play().catch(() => {});
|
||||
}
|
||||
|
||||
// 5. 显示桌面通知
|
||||
function showDesktopNotification(title: string, body: string): void {
|
||||
if (Notification.permission === 'granted') {
|
||||
// eslint-disable-next-line no-new
|
||||
new Notification(title, { body, icon: '/favicon.ico' });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 请求桌面通知权限
|
||||
function requestNotificationPermission(): void {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
playNewOrderSound,
|
||||
stopNewOrderSound,
|
||||
playUrgeSound,
|
||||
showDesktopNotification,
|
||||
requestNotificationPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
NewOrderPayload,
|
||||
OrderBoardPageState,
|
||||
OrderStatusChangedPayload,
|
||||
OrderUrgedPayload,
|
||||
} from '../../types';
|
||||
|
||||
/**
|
||||
* 文件职责:订单大厅 SignalR 连接与事件处理。
|
||||
*/
|
||||
import type { OrderBoardCard } from '#/api/order-board';
|
||||
|
||||
import { useSignalR } from '#/hooks/useSignalR';
|
||||
|
||||
/** SignalR 动作选项。 */
|
||||
interface SignalRActionsOptions {
|
||||
state: OrderBoardPageState;
|
||||
onNewOrder?: () => void;
|
||||
onUrge?: () => void;
|
||||
catchUpSince: (since: Date) => Promise<OrderBoardCard[]>;
|
||||
loadFullBoard: () => Promise<void>;
|
||||
loadStats: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 创建 SignalR 动作。 */
|
||||
export function createSignalRActions(options: SignalRActionsOptions) {
|
||||
const { state, onNewOrder, onUrge, catchUpSince, loadFullBoard, loadStats } =
|
||||
options;
|
||||
|
||||
const { isConnected, connect, disconnect, on, invoke } = useSignalR({
|
||||
onReconnected: async (lastDisconnectedAt) => {
|
||||
// 1. 重连后补偿拉取
|
||||
const missed = await catchUpSince(lastDisconnectedAt);
|
||||
if (missed.length > 0) {
|
||||
await loadFullBoard();
|
||||
await loadStats();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 建立连接并订阅事件
|
||||
async function connectAndSubscribe(): Promise<void> {
|
||||
if (!state.selectedStoreId) return;
|
||||
|
||||
await connect(state.selectedStoreId);
|
||||
|
||||
// 3. 订阅新订单
|
||||
on<NewOrderPayload>('NewOrder', () => {
|
||||
loadFullBoard();
|
||||
loadStats();
|
||||
onNewOrder?.();
|
||||
});
|
||||
|
||||
// 4. 订阅状态变更
|
||||
on<OrderStatusChangedPayload>('OrderStatusChanged', () => {
|
||||
loadFullBoard();
|
||||
loadStats();
|
||||
});
|
||||
|
||||
// 5. 订阅催单
|
||||
on<OrderUrgedPayload>('OrderUrged', (data) => {
|
||||
// 标记对应卡片为催单状态
|
||||
const allCards = [
|
||||
...state.pendingCards,
|
||||
...state.makingCards,
|
||||
...state.deliveringCards,
|
||||
];
|
||||
const card = allCards.find((c) => c.orderNo === data.orderNo);
|
||||
if (card) {
|
||||
card.isUrged = true;
|
||||
card.urgeCount = data.urgeCount;
|
||||
}
|
||||
onUrge?.();
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 切换门店
|
||||
async function switchStore(storeId: string): Promise<void> {
|
||||
await (isConnected.value ? invoke('JoinStore', storeId) : connect(storeId));
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
connectAndSubscribe,
|
||||
disconnect,
|
||||
switchStore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { OrderBoardPageState } from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:订单大厅页面编排器,组合所有子动作模块。
|
||||
*/
|
||||
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { createCardActions } from './order-board-page/card-actions';
|
||||
import { createDataActions } from './order-board-page/data-actions';
|
||||
import { createNotificationActions } from './order-board-page/notification-actions';
|
||||
import { createSignalRActions } from './order-board-page/signalr-actions';
|
||||
|
||||
/** 订单大厅页面 Hook。 */
|
||||
export function useOrderBoardPage() {
|
||||
// 1. 页面状态
|
||||
const state = reactive<OrderBoardPageState>({
|
||||
selectedStoreId: '',
|
||||
channel: 'all',
|
||||
isLoading: false,
|
||||
isSoundEnabled: true,
|
||||
pendingCards: [],
|
||||
makingCards: [],
|
||||
deliveringCards: [],
|
||||
completedCards: [],
|
||||
});
|
||||
|
||||
const stats = ref({
|
||||
todayTotal: 0,
|
||||
pendingCount: 0,
|
||||
makingCount: 0,
|
||||
deliveringCount: 0,
|
||||
completedCount: 0,
|
||||
});
|
||||
|
||||
// 2. 拒单弹窗状态
|
||||
const isRejectModalOpen = ref(false);
|
||||
const rejectingOrderId = ref('');
|
||||
|
||||
// 3. 创建通知动作
|
||||
const notificationActions = createNotificationActions({
|
||||
isSoundEnabled: {
|
||||
get value() {
|
||||
return state.isSoundEnabled;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 创建数据动作
|
||||
const dataActions = createDataActions({ state, statsRef: stats });
|
||||
|
||||
// 5. 创建 SignalR 动作
|
||||
const signalRActions = createSignalRActions({
|
||||
state,
|
||||
onNewOrder: () => {
|
||||
notificationActions.playNewOrderSound();
|
||||
notificationActions.showDesktopNotification(
|
||||
'新订单',
|
||||
'有新订单到达,请及时处理',
|
||||
);
|
||||
},
|
||||
onUrge: () => {
|
||||
notificationActions.playUrgeSound();
|
||||
},
|
||||
catchUpSince: dataActions.catchUpSince,
|
||||
loadFullBoard: dataActions.loadFullBoard,
|
||||
loadStats: dataActions.loadStats,
|
||||
});
|
||||
|
||||
// 6. 刷新回调
|
||||
async function refresh(): Promise<void> {
|
||||
await Promise.all([dataActions.loadFullBoard(), dataActions.loadStats()]);
|
||||
}
|
||||
|
||||
// 7. 创建卡片动作
|
||||
const cardActions = createCardActions({
|
||||
state,
|
||||
onRefresh: refresh,
|
||||
onAccepted: () => {
|
||||
// 接单后如果待接单列为空则停止声音
|
||||
if (state.pendingCards.length <= 1) {
|
||||
notificationActions.stopNewOrderSound();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 8. 拒单流程
|
||||
function openRejectModal(orderId: string): void {
|
||||
rejectingOrderId.value = orderId;
|
||||
isRejectModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmReject(reason: string): Promise<void> {
|
||||
await cardActions.handleReject(rejectingOrderId.value, reason);
|
||||
isRejectModalOpen.value = false;
|
||||
rejectingOrderId.value = '';
|
||||
}
|
||||
|
||||
function cancelReject(): void {
|
||||
isRejectModalOpen.value = false;
|
||||
rejectingOrderId.value = '';
|
||||
}
|
||||
|
||||
// 9. 监听门店切换
|
||||
watch(
|
||||
() => state.selectedStoreId,
|
||||
async (newStoreId) => {
|
||||
if (!newStoreId) return;
|
||||
await refresh();
|
||||
await signalRActions.switchStore(newStoreId);
|
||||
},
|
||||
);
|
||||
|
||||
// 10. 监听渠道切换
|
||||
watch(
|
||||
() => state.channel,
|
||||
() => dataActions.loadFullBoard(),
|
||||
);
|
||||
|
||||
// 11. 生命周期
|
||||
onMounted(() => {
|
||||
notificationActions.requestNotificationPermission();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
signalRActions.disconnect();
|
||||
notificationActions.stopNewOrderSound();
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
stats,
|
||||
isRejectModalOpen,
|
||||
isConnected: signalRActions.isConnected,
|
||||
// 数据
|
||||
loadFullBoard: dataActions.loadFullBoard,
|
||||
loadStats: dataActions.loadStats,
|
||||
// 卡片操作
|
||||
handleAccept: cardActions.handleAccept,
|
||||
handleCompletePreparation: cardActions.handleCompletePreparation,
|
||||
handleConfirmDelivery: cardActions.handleConfirmDelivery,
|
||||
openRejectModal,
|
||||
confirmReject,
|
||||
cancelReject,
|
||||
// SignalR
|
||||
connectAndSubscribe: signalRActions.connectAndSubscribe,
|
||||
};
|
||||
}
|
||||
90
apps/web-antd/src/views/order/board/index.vue
Normal file
90
apps/web-antd/src/views/order/board/index.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<!-- 文件职责:订单大厅页面入口。 -->
|
||||
<script setup lang="ts">
|
||||
import BoardColumn from './components/BoardColumn.vue';
|
||||
import BoardStatsBar from './components/BoardStatsBar.vue';
|
||||
import BoardToolbar from './components/BoardToolbar.vue';
|
||||
import RejectOrderModal from './components/RejectOrderModal.vue';
|
||||
import { BOARD_COLUMNS } from './composables/order-board-page/constants';
|
||||
import { useOrderBoardPage } from './composables/useOrderBoardPage';
|
||||
|
||||
import './styles/index.less';
|
||||
|
||||
const {
|
||||
state,
|
||||
stats,
|
||||
isRejectModalOpen,
|
||||
isConnected,
|
||||
handleAccept,
|
||||
handleCompletePreparation,
|
||||
handleConfirmDelivery,
|
||||
openRejectModal,
|
||||
confirmReject,
|
||||
cancelReject,
|
||||
} = useOrderBoardPage();
|
||||
|
||||
/** 根据列键获取对应卡片列表。 */
|
||||
function getCards(key: string) {
|
||||
switch (key) {
|
||||
case 'completed': {
|
||||
return state.completedCards;
|
||||
}
|
||||
case 'delivering': {
|
||||
return state.deliveringCards;
|
||||
}
|
||||
case 'making': {
|
||||
return state.makingCards;
|
||||
}
|
||||
case 'pending': {
|
||||
return state.pendingCards;
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ob-page">
|
||||
<!-- 1. 工具栏 -->
|
||||
<BoardToolbar
|
||||
v-model:store-id="state.selectedStoreId"
|
||||
v-model:channel="state.channel"
|
||||
v-model:is-sound-enabled="state.isSoundEnabled"
|
||||
:is-connected="isConnected"
|
||||
/>
|
||||
|
||||
<!-- 2. 统计条 -->
|
||||
<BoardStatsBar
|
||||
:today-total="stats.todayTotal"
|
||||
:pending-count="stats.pendingCount"
|
||||
:making-count="stats.makingCount"
|
||||
:delivering-count="stats.deliveringCount"
|
||||
:completed-count="stats.completedCount"
|
||||
/>
|
||||
|
||||
<!-- 3. 看板四列 -->
|
||||
<div class="ob-board">
|
||||
<BoardColumn
|
||||
v-for="col in BOARD_COLUMNS"
|
||||
:key="col.key"
|
||||
:title="col.title"
|
||||
:color-class="col.colorClass"
|
||||
:column-key="col.key"
|
||||
:cards="getCards(col.key)"
|
||||
:is-loading="state.isLoading"
|
||||
@accept="handleAccept"
|
||||
@reject="openRejectModal"
|
||||
@complete-preparation="handleCompletePreparation"
|
||||
@confirm-delivery="handleConfirmDelivery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 4. 拒单弹窗 -->
|
||||
<RejectOrderModal
|
||||
:open="isRejectModalOpen"
|
||||
@confirm="confirmReject"
|
||||
@cancel="cancelReject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
48
apps/web-antd/src/views/order/board/styles/animations.less
Normal file
48
apps/web-antd/src/views/order/board/styles/animations.less
Normal file
@@ -0,0 +1,48 @@
|
||||
/* 文件职责:订单大厅动画效果。 */
|
||||
|
||||
/* 待接单卡片脉冲边框 */
|
||||
.ob-card--pulse {
|
||||
animation: ob-pulse-border 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ob-pulse-border {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 3px rgb(250 140 22 / 30%),
|
||||
0 2px 8px rgb(0 0 0 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 催单标记闪烁 */
|
||||
.ob-card--urged::after {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
content: '催';
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
animation: ob-urge-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ob-urge-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
92
apps/web-antd/src/views/order/board/styles/card.less
Normal file
92
apps/web-antd/src/views/order/board/styles/card.less
Normal file
@@ -0,0 +1,92 @@
|
||||
/* 文件职责:订单大厅卡片样式。 */
|
||||
.ob-card {
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border-left: 4px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ob-card:hover {
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
/* 列颜色标识 */
|
||||
.ob-col-pending .ob-card {
|
||||
border-left-color: #fa8c16;
|
||||
}
|
||||
|
||||
.ob-col-making .ob-card {
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.ob-col-delivering .ob-card {
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
.ob-col-completed .ob-card {
|
||||
border-left-color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 已完成卡片半透明 */
|
||||
.ob-card--dimmed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ob-card__header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ob-card__order-no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ob-card__body {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.ob-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ob-card__items {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ob-card__amount {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ob-card__time {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.ob-card__elapsed {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.ob-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
5
apps/web-antd/src/views/order/board/styles/index.less
Normal file
5
apps/web-antd/src/views/order/board/styles/index.less
Normal file
@@ -0,0 +1,5 @@
|
||||
/* 文件职责:订单大厅样式聚合。 */
|
||||
@import './layout.less';
|
||||
@import './toolbar.less';
|
||||
@import './card.less';
|
||||
@import './animations.less';
|
||||
90
apps/web-antd/src/views/order/board/styles/layout.less
Normal file
90
apps/web-antd/src/views/order/board/styles/layout.less
Normal file
@@ -0,0 +1,90 @@
|
||||
/* 文件职责:订单大厅看板布局。 */
|
||||
.ob-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ob-board {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ob-column {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 280px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ob-column__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ob-column__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ob-column__body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ob-column__empty {
|
||||
padding: 40px 0;
|
||||
color: #bfbfbf;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 统计条 */
|
||||
.ob-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.ob-stats__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ob-stats__value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ob-stats__label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.ob-stats__item--pending .ob-stats__value {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.ob-stats__item--making .ob-stats__value {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.ob-stats__item--delivering .ob-stats__value {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.ob-stats__item--completed .ob-stats__value {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
47
apps/web-antd/src/views/order/board/styles/toolbar.less
Normal file
47
apps/web-antd/src/views/order/board/styles/toolbar.less
Normal file
@@ -0,0 +1,47 @@
|
||||
/* 文件职责:订单大厅工具栏样式。 */
|
||||
.ob-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.ob-toolbar__store {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ob-toolbar__label {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ob-toolbar__channel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ob-toolbar__right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ob-toolbar__status {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ob-toolbar__status--on {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.ob-toolbar__status--off {
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
64
apps/web-antd/src/views/order/board/types.ts
Normal file
64
apps/web-antd/src/views/order/board/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 文件职责:订单大厅本地类型定义。
|
||||
*/
|
||||
import type { OrderBoardCard } from '#/api/order-board';
|
||||
|
||||
/** 看板列键。 */
|
||||
export type BoardColumnKey = 'completed' | 'delivering' | 'making' | 'pending';
|
||||
|
||||
/** 渠道筛选值。 */
|
||||
export type BoardChannelFilter =
|
||||
| 'all'
|
||||
| 'miniprogram'
|
||||
| 'phone'
|
||||
| 'scan'
|
||||
| 'staff'
|
||||
| 'thirdparty';
|
||||
|
||||
/** SignalR 新订单推送载荷。 */
|
||||
export interface NewOrderPayload {
|
||||
amount: number;
|
||||
channel: number;
|
||||
createdAt: string;
|
||||
customerName?: null | string;
|
||||
deliveryType: number;
|
||||
itemsSummary?: null | string;
|
||||
orderId: number;
|
||||
orderNo: string;
|
||||
storeId: number;
|
||||
tableNo?: null | string;
|
||||
}
|
||||
|
||||
/** SignalR 状态变更推送载荷。 */
|
||||
export interface OrderStatusChangedPayload {
|
||||
channel: number;
|
||||
customerName?: null | string;
|
||||
deliveryType: number;
|
||||
itemsSummary?: null | string;
|
||||
newStatus: number;
|
||||
occurredAt: string;
|
||||
oldStatus: number;
|
||||
orderId: number;
|
||||
orderNo: string;
|
||||
paidAmount: number;
|
||||
}
|
||||
|
||||
/** SignalR 催单推送载荷。 */
|
||||
export interface OrderUrgedPayload {
|
||||
occurredAt: string;
|
||||
orderId: number;
|
||||
orderNo: string;
|
||||
urgeCount: number;
|
||||
}
|
||||
|
||||
/** 看板页面状态。 */
|
||||
export interface OrderBoardPageState {
|
||||
channel: BoardChannelFilter;
|
||||
completedCards: OrderBoardCard[];
|
||||
deliveringCards: OrderBoardCard[];
|
||||
isLoading: boolean;
|
||||
isSoundEnabled: boolean;
|
||||
makingCards: OrderBoardCard[];
|
||||
pendingCards: OrderBoardCard[];
|
||||
selectedStoreId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user