feat: 添加订单大厅看板 UI(四列看板 + 声音提醒 + 桌面通知)
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 56s

This commit is contained in:
2026-02-27 13:18:54 +08:00
parent 42bf54a52c
commit 23e696719b
21 changed files with 1298 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
Sound placeholder files:
- new-order.mp3: 新订单提示音(需替换为实际音频文件)
- urge.mp3: 催单提示音(需替换为实际音频文件)
请将实际的 MP3 音频文件放置到此目录下。

View File

@@ -11,6 +11,15 @@ const routes: RouteRecordRaw[] = [
name: 'Order',
path: '/order',
children: [
{
name: 'OrderBoard',
path: '/order/board',
component: () => import('#/views/order/board/index.vue'),
meta: {
icon: 'lucide:layout-dashboard',
title: '订单大厅',
},
},
{
name: 'OrderAll',
path: '/order/list',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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',
};

View File

@@ -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 };
}

View File

@@ -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] ?? '未知';
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View 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>

View 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;
}
}

View 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;
}

View File

@@ -0,0 +1,5 @@
/* 文件职责:订单大厅样式聚合。 */
@import './layout.less';
@import './toolbar.less';
@import './card.less';
@import './animations.less';

View 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;
}

View 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;
}

View 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;
}