feat: 重构订单大厅看板UI对齐设计稿
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 51s

This commit is contained in:
2026-02-27 17:15:24 +08:00
parent e1512bcfc1
commit 01ac80d67d
14 changed files with 1159 additions and 415 deletions

View File

@@ -4,6 +4,8 @@ import type { BoardColumnKey } from '../types';
import type { OrderBoardCard } from '#/api/order-hall';
import { computed } from 'vue';
import BoardOrderCard from './BoardOrderCard.vue';
const {
@@ -13,11 +15,11 @@ const {
columnKey,
title = '',
} = defineProps<{
cards: OrderBoardCard[];
colorClass: string;
cards?: OrderBoardCard[];
colorClass?: string;
columnKey: BoardColumnKey;
isLoading: boolean;
title: string;
isLoading?: boolean;
title?: string;
}>();
const emit = defineEmits<{
@@ -26,20 +28,49 @@ const emit = defineEmits<{
confirmDelivery: [orderId: string];
reject: [orderId: string];
}>();
const deliveryChannelCount = computed(
() => cards.filter((item) => item.deliveryType === 2).length,
);
const shouldShowCount = computed(() => columnKey !== 'completed');
const countText = computed(() => {
if (columnKey === 'delivering') {
return `(${deliveryChannelCount.value}/${cards.length})`;
}
return String(cards.length);
});
</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 class="ob-col-hd" :class="[colorClass]">
<span>{{ title }}</span>
<span
v-if="shouldShowCount"
class="ob-col-count"
:class="`is-${columnKey}`"
>
{{ countText }}
</span>
</div>
<!-- 2. 滚动区 -->
<div class="ob-column__body">
<div class="ob-col-body">
<a-spin :spinning="isLoading">
<div v-if="cards.length === 0" class="ob-column__empty">暂无订单</div>
<div
v-if="cards.length === 0 && columnKey !== 'completed'"
class="ob-column__empty"
>
暂无订单
</div>
<div
v-if="cards.length === 0 && columnKey === 'completed'"
class="ob-column__placeholder"
></div>
<BoardOrderCard
v-for="card in cards"
:key="card.id"

View File

@@ -1,17 +1,14 @@
<!-- 文件职责订单看板卡片信息 + 操作按钮 -->
<!-- 文件职责订单看板卡片视觉对齐新版原型 -->
<script setup lang="ts">
import type { BoardColumnKey } from '../types';
import type { OrderBoardCard } from '#/api/order-hall';
import { CHANNEL_TAG_COLORS } from '../composables/order-hall-page/constants';
import {
channelText,
deliveryTypeText,
elapsedMinutes,
formatAmount,
formatTime,
} from '../composables/order-hall-page/helpers';
import { IconifyIcon } from '@vben/icons';
import dayjs from 'dayjs';
import { formatAmount } from '../composables/order-hall-page/helpers';
const { card, columnKey } = defineProps<{
card: OrderBoardCard;
@@ -24,81 +21,175 @@ const emit = defineEmits<{
confirmDelivery: [orderId: string];
reject: [orderId: string];
}>();
function resolveDeliveryLabel(deliveryType: number): string {
switch (deliveryType) {
case 0: {
return '堂食';
}
case 1: {
return '自提';
}
case 2: {
return '外卖';
}
default: {
return '未知';
}
}
}
function resolveDeliveryClass(deliveryType: number): string {
switch (deliveryType) {
case 0: {
return 'is-dine-in';
}
case 1: {
return 'is-pickup';
}
case 2: {
return 'is-delivery';
}
default: {
return 'is-default';
}
}
}
function resolveShortOrderNo(orderNo: string): string {
const digits = orderNo.match(/\d+/g)?.join('') ?? '';
if (!digits) {
return orderNo;
}
const value = Number.parseInt(digits, 10);
if (Number.isNaN(value)) {
return orderNo;
}
return `#${value}`;
}
function resolveElapsedClock(value?: null | string): string {
if (!value) return '--:--';
const minutes = Math.max(dayjs().diff(dayjs(value), 'minute'), 0);
if (minutes > 99) {
return dayjs(value).format('HH:mm');
}
const seconds = Math.max(dayjs().diff(dayjs(value), 'second'), 0);
const minutesPart = Math.floor(seconds / 60);
const secondPart = String(seconds % 60).padStart(2, '0');
return `${minutesPart}:${secondPart}`;
}
function resolveMakingProgress(order: OrderBoardCard): number {
const since = order.acceptedAt ?? order.createdAt;
if (!since) return 0;
const minutes = Math.max(dayjs().diff(dayjs(since), 'minute'), 0);
if (minutes > 120) {
const fallbackSource = Number.parseInt(
(order.orderNo.match(/\d+/g)?.join('') ?? '').slice(-2),
10,
);
const fallback = Number.isNaN(fallbackSource) ? 35 : fallbackSource;
return 25 + (fallback % 55);
}
return Math.min(95, Math.max(8, minutes * 6));
}
function resolveDeliverInfoText(order: OrderBoardCard): string {
if (order.deliveryType === 1) {
return '等待取餐';
}
return order.customerName || '配送中';
}
function resolveDeliverInfoIcon(order: OrderBoardCard): string {
if (order.deliveryType === 1) {
return 'lucide:user-round';
}
return 'lucide:bike';
}
</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'"
<div class="ob-order" :class="[columnKey, { 'is-urged': card.isUrged }]">
<div class="ob-order-top">
<div class="ob-order-top-left">
<span
class="ob-type-tag"
:class="[resolveDeliveryClass(card.deliveryType)]"
>
{{ resolveDeliveryLabel(card.deliveryType) }}
</span>
<span class="ob-short-no">{{ resolveShortOrderNo(card.orderNo) }}</span>
<span v-if="card.tableNo" class="ob-table-chip">
桌台: {{ card.tableNo }}
</span>
</div>
<div class="ob-time-chip">
<IconifyIcon icon="lucide:clock-3" />
<span>{{ resolveElapsedClock(card.createdAt) }}</span>
</div>
</div>
<div class="ob-main-line">
<IconifyIcon icon="lucide:utensils-crossed" />
<span>{{ card.itemsSummary || '--' }}</span>
</div>
<div class="ob-order-mid">
<span class="ob-full-no">{{ card.orderNo }}</span>
<span class="ob-amount">{{ formatAmount(card.paidAmount) }}</span>
</div>
<div v-if="columnKey === 'pending'" class="ob-actions ob-actions-pending">
<a-button class="ob-btn ob-btn-ghost" @click="emit('reject', card.id)">
拒单
</a-button>
<a-button class="ob-btn ob-btn-orange" @click="emit('accept', card.id)">
接单
</a-button>
</div>
<div
v-else-if="columnKey === 'making'"
class="ob-actions ob-actions-making"
>
<a-progress
:percent="resolveMakingProgress(card)"
:show-info="false"
size="small"
stroke-color="#3f7fe8"
trail-color="#eceff3"
/>
<a-button
class="ob-btn ob-btn-blue"
@click="emit('completePreparation', card.id)"
>
{{ channelText(card.channel) }}
</a-tag>
<a-tag v-if="card.isUrged" color="red" size="small">
{{ card.urgeCount }}
</a-tag>
出餐完成
</a-button>
</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
v-else-if="columnKey === 'delivering'"
class="ob-actions ob-actions-delivering"
>
<div class="ob-deliver-info">
<IconifyIcon :icon="resolveDeliverInfoIcon(card)" />
<span>{{ resolveDeliverInfoText(card) }}</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>
<a-button
class="ob-btn ob-btn-green"
@click="emit('confirmDelivery', card.id)"
>
确认
</a-button>
</div>
</div>
</template>

View File

@@ -7,35 +7,42 @@ const {
pendingCount = 0,
todayTotal = 0,
} = defineProps<{
completedCount: number;
deliveringCount: number;
makingCount: number;
pendingCount: number;
todayTotal: number;
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 class="ob-stats-main">
<div class="ob-summary-item">
<span class="ob-summary-label">今日订单</span>
<span class="ob-summary-value is-default">{{ todayTotal }}</span>
</div>
<div class="ob-summary-item is-active">
<span class="ob-summary-label">待接单</span>
<span class="ob-summary-value is-pending">{{ pendingCount }}</span>
</div>
<div class="ob-summary-item">
<span class="ob-summary-label">制作中</span>
<span class="ob-summary-value is-making">{{ makingCount }}</span>
</div>
<div class="ob-summary-item">
<span class="ob-summary-label">配送/待取</span>
<span class="ob-summary-value is-delivering">{{
deliveringCount
}}</span>
</div>
<div class="ob-summary-item">
<span class="ob-summary-label">已完成</span>
<span class="ob-summary-value is-muted">{{ completedCount }}</span>
</div>
</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 class="ob-stats-actions">
<slot name="actions"></slot>
</div>
</div>
</template>

View File

@@ -1,75 +1,98 @@
<!-- 文件职责订单大厅工具栏门店选择 + 渠道筛选 + 声音开关 + 连接状态 -->
<!-- 文件职责订单大厅顶部控制区门店 + 履约筛选 + 声音开关 + 刷新 -->
<script setup lang="ts">
import type { BoardChannelFilter } from '../types';
import type { BoardDeliveryFilter, StoreOptionItem } from '../types';
import { CHANNEL_OPTIONS } from '../composables/order-hall-page/constants';
import { IconifyIcon } from '@vben/icons';
import { DELIVERY_FILTER_OPTIONS } from '../composables/order-hall-page/constants';
const {
channel = 'all',
deliveryFilter = 'all',
isConnected = false,
isSoundEnabled = true,
isStoreLoading = false,
storeId = '',
storeOptions = [],
} = defineProps<{
channel: BoardChannelFilter;
isConnected: boolean;
isSoundEnabled: boolean;
storeId: string;
deliveryFilter?: BoardDeliveryFilter;
isConnected?: boolean;
isSoundEnabled?: boolean;
isStoreLoading?: boolean;
storeId?: string;
storeOptions?: StoreOptionItem[];
}>();
const emit = defineEmits<{
'update:channel': [value: BoardChannelFilter];
refresh: [];
'update:deliveryFilter': [value: BoardDeliveryFilter];
'update:isSoundEnabled': [value: boolean];
'update:storeId': [value: string];
}>();
function handleStoreChange(value: unknown) {
if (typeof value === 'number' || typeof value === 'string') {
emit('update:storeId', String(value));
return;
}
emit('update:storeId', '');
}
</script>
<template>
<div class="ob-toolbar">
<!-- 1. 门店 ID 输入 -->
<div class="ob-toolbar__store">
<span class="ob-toolbar__label">门店ID:</span>
<a-input
<div class="ob-toolbar-shell">
<div class="ob-toolbar-actions">
<a-select
:value="storeId"
placeholder="输入门店 ID"
style="width: 200px"
@change="(e: any) => emit('update:storeId', e.target.value)"
class="ob-store-select"
placeholder="请选择门店"
:loading="isStoreLoading"
:options="storeOptions"
:disabled="isStoreLoading || storeOptions.length === 0"
@update:value="handleStoreChange"
/>
</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"
<div class="ob-channels">
<button
v-for="option in DELIVERY_FILTER_OPTIONS"
:key="option.value"
type="button"
class="ob-channel"
:class="{ active: deliveryFilter === option.value }"
@click="emit('update:deliveryFilter', option.value)"
>
{{ opt.label }}
</a-radio-button>
</a-radio-group>
</div>
{{ option.label }}
</button>
</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 class="ob-toolbar-right">
<span class="ob-connection" :class="{ offline: !isConnected }">
<span class="ob-connection-dot"></span>
{{ isConnected ? '已连接' : '连接中' }}
</span>
<button
type="button"
class="ob-icon-btn"
:class="{ active: isSoundEnabled }"
:title="isSoundEnabled ? '提示音开启' : '提示音关闭'"
@click="emit('update:isSoundEnabled', !isSoundEnabled)"
>
<IconifyIcon
:icon="isSoundEnabled ? 'lucide:volume-2' : 'lucide:volume-x'"
/>
</button>
<button
type="button"
class="ob-icon-btn"
:class="{ 'ob-icon-btn--offline': !isConnected }"
:title="isConnected ? '刷新' : '连接中,点击重试刷新'"
@click="emit('refresh')"
>
<IconifyIcon icon="lucide:refresh-cw" />
</button>
</div>
</div>
</div>
</template>

View File

@@ -3,7 +3,7 @@
import { ref } from 'vue';
const { open = false } = defineProps<{
open: boolean;
open?: boolean;
}>();
const emit = defineEmits<{

View File

@@ -1,7 +1,7 @@
/**
* 文件职责:订单大厅常量与配置。
*/
import type { BoardChannelFilter, BoardColumnKey } from '../../types';
import type { BoardColumnKey, BoardDeliveryFilter } from '../../types';
/** 看板列定义。 */
export const HALL_COLUMNS: {
@@ -9,20 +9,21 @@ export const HALL_COLUMNS: {
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' },
{ key: 'pending', title: '待接单', colorClass: 'pending' },
{ key: 'making', title: '制作中', colorClass: 'making' },
{ key: 'delivering', title: '配送/待取', colorClass: 'delivering' },
{ key: 'completed', title: '已完成', colorClass: 'done' },
];
/** 渠道筛选选项。 */
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 DELIVERY_FILTER_OPTIONS: {
label: string;
value: BoardDeliveryFilter;
}[] = [
{ value: 'all', label: '全部' },
{ value: 'delivery', label: '外卖' },
{ value: 'pickup', label: '自提' },
{ value: 'dineIn', label: '堂食' },
];
/** 订单状态映射。 */
@@ -60,3 +61,18 @@ export const CHANNEL_TAG_COLORS: Record<number, string> = {
4: 'purple',
5: 'red',
};
/** 履约类型标签颜色(原型:外卖=blue, 自提=green, 堂食=orange。 */
export const DELIVERY_TYPE_TAG_COLORS: Record<number, string> = {
0: 'orange',
1: 'green',
2: 'blue',
};
/** 履约类型筛选值到 API 参数的映射。 */
export const DELIVERY_FILTER_VALUE_MAP: Record<string, number | undefined> = {
all: undefined,
delivery: 2,
dineIn: 0,
pickup: 1,
};

View File

@@ -3,41 +3,70 @@
*/
import type { Ref } from 'vue';
import type { OrderHallPageState } from '../../types';
import type { OrderHallPageState, OrderHallStatsState } from '../../types';
import type { OrderBoardCard } from '#/api/order-hall';
import type { StoreListItemDto } from '#/api/store';
import {
getOrderBoardApi,
getOrderBoardStatsApi,
getPendingSinceApi,
} from '#/api/order-hall';
import { getStoreListApi } from '#/api/store';
import { mapStoreOptions } from '../../types';
/** 数据加载动作选项。 */
interface DataActionsOptions {
state: OrderHallPageState;
statsRef: Ref<{
completedCount: number;
deliveringCount: number;
makingCount: number;
pendingCount: number;
todayTotal: number;
}>;
statsRef: Ref<OrderHallStatsState>;
}
/** 创建数据加载动作。 */
export function createDataActions(options: DataActionsOptions) {
const { state, statsRef } = options;
// 1. 加载完整看板数据
// 1. 加载门店并初始化默认门店
async function loadStores(): Promise<void> {
state.isStoreLoading = true;
try {
const result = await getStoreListApi({ page: 1, pageSize: 200 });
const stores = result.items ?? ([] as StoreListItemDto[]);
state.storeOptions = mapStoreOptions(stores);
if (stores.length === 0) {
state.selectedStoreId = '';
state.pendingCards = [];
state.makingCards = [];
state.deliveringCards = [];
state.completedCards = [];
statsRef.value = {
todayTotal: 0,
pendingCount: 0,
makingCount: 0,
deliveringCount: 0,
completedCount: 0,
};
return;
}
const matched = stores.some((item) => item.id === state.selectedStoreId);
if (!matched) {
state.selectedStoreId = stores[0]?.id ?? '';
}
} finally {
state.isStoreLoading = false;
}
}
// 2. 加载完整看板数据
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;
@@ -48,7 +77,7 @@ export function createDataActions(options: DataActionsOptions) {
}
}
// 2. 加载统计数据
// 3. 加载统计数据
async function loadStats(): Promise<void> {
if (!state.selectedStoreId) return;
const result = await getOrderBoardStatsApi({
@@ -57,7 +86,7 @@ export function createDataActions(options: DataActionsOptions) {
statsRef.value = result;
}
// 3. 重连补偿拉取
// 4. 重连补偿拉取
async function catchUpSince(since: Date): Promise<OrderBoardCard[]> {
if (!state.selectedStoreId) return [];
return getPendingSinceApi({
@@ -66,5 +95,5 @@ export function createDataActions(options: DataActionsOptions) {
});
}
return { loadFullBoard, loadStats, catchUpSince };
return { loadStores, loadFullBoard, loadStats, catchUpSince };
}

View File

@@ -1,4 +1,4 @@
import type { OrderHallPageState } from '../types';
import type { OrderHallPageState, OrderHallStatsState } from '../types';
/**
* 文件职责:订单大厅页面编排器,组合所有子动作模块。
@@ -15,16 +15,18 @@ export function useOrderHallPage() {
// 1. 页面状态
const state = reactive<OrderHallPageState>({
selectedStoreId: '',
channel: 'all',
deliveryFilter: 'all',
isStoreLoading: false,
isLoading: false,
isSoundEnabled: true,
storeOptions: [],
pendingCards: [],
makingCards: [],
deliveringCards: [],
completedCards: [],
});
const stats = ref({
const stats = ref<OrderHallStatsState>({
todayTotal: 0,
pendingCount: 0,
makingCount: 0,
@@ -35,6 +37,7 @@ export function useOrderHallPage() {
// 2. 拒单弹窗状态
const isRejectModalOpen = ref(false);
const rejectingOrderId = ref('');
const hasSubscribed = ref(false);
// 3. 创建通知动作
const notificationActions = createNotificationActions({
@@ -73,7 +76,6 @@ export function useOrderHallPage() {
// 7. 创建卡片动作
const cardActions = createCardActions({
state,
onRefresh: refresh,
onAccepted: () => {
// 接单后如果待接单列为空则停止声音
@@ -104,21 +106,35 @@ export function useOrderHallPage() {
watch(
() => state.selectedStoreId,
async (newStoreId) => {
if (!newStoreId) return;
if (!newStoreId) {
return;
}
await refresh();
if (!hasSubscribed.value) {
await signalRActions.connectAndSubscribe();
hasSubscribed.value = true;
return;
}
await signalRActions.switchStore(newStoreId);
},
);
// 10. 监听渠道切换
// 10. 监听提示音开关(关闭时立即停掉循环音)
watch(
() => state.channel,
() => dataActions.loadFullBoard(),
() => state.isSoundEnabled,
(enabled) => {
if (!enabled) {
notificationActions.stopNewOrderSound();
}
},
);
// 11. 生命周期
onMounted(() => {
onMounted(async () => {
notificationActions.requestNotificationPermission();
await dataActions.loadStores();
});
onUnmounted(() => {
@@ -133,6 +149,7 @@ export function useOrderHallPage() {
isConnected: signalRActions.isConnected,
// 数据
loadFullBoard: dataActions.loadFullBoard,
loadStores: dataActions.loadStores,
loadStats: dataActions.loadStats,
// 卡片操作
handleAccept: cardActions.handleAccept,

View File

@@ -1,5 +1,7 @@
<!-- 文件职责订单大厅页面入口 -->
<script setup lang="ts">
import type { BoardColumnKey } from './types';
import BoardColumn from './components/BoardColumn.vue';
import BoardStatsBar from './components/BoardStatsBar.vue';
import BoardToolbar from './components/BoardToolbar.vue';
@@ -14,6 +16,8 @@ const {
stats,
isRejectModalOpen,
isConnected,
loadFullBoard,
loadStats,
handleAccept,
handleCompletePreparation,
handleConfirmDelivery,
@@ -22,48 +26,82 @@ const {
cancelReject,
} = useOrderHallPage();
/** 根据列键获取对应卡片列表。 */
function getCards(key: string) {
switch (key) {
case 'completed': {
return state.completedCards;
/** 判断卡片是否符合当前履约筛选。 */
function matchesDeliveryFilter(deliveryType: number) {
switch (state.deliveryFilter) {
case 'delivery': {
return deliveryType === 2;
}
case 'delivering': {
return state.deliveringCards;
case 'dineIn': {
return deliveryType === 0;
}
case 'making': {
return state.makingCards;
}
case 'pending': {
return state.pendingCards;
case 'pickup': {
return deliveryType === 1;
}
default: {
return [];
return true;
}
}
}
/** 根据列键获取对应卡片列表。 */
function getCards(key: BoardColumnKey) {
let cards = [] as typeof state.pendingCards;
switch (key) {
case 'completed': {
cards = state.completedCards;
break;
}
case 'delivering': {
cards = state.deliveringCards;
break;
}
case 'making': {
cards = state.makingCards;
break;
}
case 'pending': {
cards = state.pendingCards;
break;
}
default: {
cards = [];
}
}
return cards.filter((card) => matchesDeliveryFilter(card.deliveryType));
}
/** 手动刷新看板。 */
async function handleRefresh() {
await Promise.all([loadFullBoard(), loadStats()]);
}
</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. 统计条 -->
<!-- 1. 统计条 + 控制区 -->
<BoardStatsBar
:today-total="stats.todayTotal"
:pending-count="stats.pendingCount"
:making-count="stats.makingCount"
:delivering-count="stats.deliveringCount"
:completed-count="stats.completedCount"
/>
>
<template #actions>
<BoardToolbar
v-model:store-id="state.selectedStoreId"
v-model:delivery-filter="state.deliveryFilter"
v-model:is-sound-enabled="state.isSoundEnabled"
:store-options="state.storeOptions"
:is-store-loading="state.isStoreLoading"
:is-connected="isConnected"
@refresh="handleRefresh"
/>
</template>
</BoardStatsBar>
<!-- 3. 看板四列 -->
<!-- 2. 看板四列 -->
<div class="ob-board">
<BoardColumn
v-for="col in HALL_COLUMNS"
@@ -80,7 +118,7 @@ function getCards(key: string) {
/>
</div>
<!-- 4. 拒单弹窗 -->
<!-- 3. 拒单弹窗 -->
<RejectOrderModal
:open="isRejectModalOpen"
@confirm="confirmReject"

View File

@@ -1,39 +1,18 @@
/* 文件职责:订单大厅动画效果。 */
/* 待接单卡片脉冲边框 */
.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%);
border-left-color: #fa8c16;
}
50% {
box-shadow:
0 0 0 3px rgb(250 140 22 / 30%),
0 2px 8px rgb(0 0 0 / 12%);
border-left-color: #ffd591;
}
}
/* 催单标记闪烁 */
.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;
.ob-order.is-urged {
animation: ob-pulse-border 2s ease-in-out infinite;
}
@keyframes ob-urge-blink {
@@ -43,6 +22,10 @@
}
50% {
opacity: 0.3;
opacity: 0.5;
}
}
.ob-order.is-urged .ob-time-chip {
animation: ob-urge-blink 1.3s ease-in-out infinite;
}

View File

@@ -1,92 +1,239 @@
/* 文件职责:订单大厅卡片样式。 */
.ob-card {
/* 文件职责:订单大厅卡片样式(对齐新版原型)。 */
.ob-order {
position: relative;
padding: 12px;
margin-bottom: 8px;
padding: 14px 14px 12px;
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;
border: 1px solid #e7ebf1;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
}
.ob-card:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
.ob-order.is-urged {
border-left: 4px solid #f04438;
}
/* 列颜色标识 */
.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 {
.ob-order-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
margin-bottom: 14px;
}
.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 {
.ob-order-top-left {
display: flex;
gap: 8px;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid #f0f0f0;
gap: 10px;
align-items: center;
}
.ob-type-tag {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
font-size: 13px;
font-weight: 700;
border-radius: 8px;
&.is-delivery {
color: #3f7fe8;
background: #e5edff;
}
&.is-pickup {
color: #1c9f62;
background: #dff5ea;
}
&.is-dine-in {
color: #8246af;
background: #f0e2fd;
}
&.is-default {
color: #667085;
background: #f2f4f7;
}
}
.ob-short-no {
font-size: 34px;
font-weight: 800;
line-height: 1.05;
color: #1d2939;
}
.ob-table-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
font-size: 13px;
color: #667085;
background: #f2f4f7;
border-radius: 8px;
}
.ob-time-chip {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 14px;
color: #98a2b3;
.vben-iconify {
width: 14px;
height: 14px;
}
}
.ob-main-line {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 10px;
font-size: 15px;
font-weight: 700;
line-height: 1.25;
color: #1d2939;
.vben-iconify {
width: 15px;
height: 15px;
color: #98a2b3;
}
}
.ob-order-mid {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.ob-full-no {
font-size: 11px;
color: #8a94a6;
}
.ob-amount {
font-size: 34px;
font-weight: 900;
line-height: 1;
color: #e55a0a;
}
.ob-actions {
.ant-btn {
height: 38px;
font-size: 15px;
font-weight: 700;
border-radius: 10px;
}
}
.ob-actions-pending {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.ob-actions-making {
display: flex;
flex-direction: column;
gap: 12px;
.ant-progress {
margin-bottom: 0;
}
.ant-progress-inner {
height: 9px !important;
border-radius: 999px;
}
}
.ob-actions-delivering {
display: flex;
align-items: center;
justify-content: space-between;
}
.ob-deliver-info {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 13px;
color: #667085;
.vben-iconify {
width: 14px;
height: 14px;
color: #98a2b3;
}
}
.ob-btn {
&.ob-btn-ghost {
color: #b03d4b;
background: #fff;
border: 1px solid #e9d4d8;
}
&.ob-btn-orange {
color: #fff;
background: #ff7a14;
border-color: #ff7a14;
}
&.ob-btn-blue {
color: #fff;
background: #3f7fe8;
border-color: #3f7fe8;
}
&.ob-btn-green {
min-width: 102px;
color: #fff;
background: #2fbf5f;
border-color: #2fbf5f;
}
}
.ob-column.done .ob-order {
opacity: 0.52;
}
@media (max-width: 768px) {
.ob-order {
padding: 12px;
}
.ob-order-top-left {
gap: 8px;
}
.ob-type-tag {
height: 22px;
padding: 0 8px;
font-size: 12px;
}
.ob-short-no {
font-size: 27px;
}
.ob-main-line {
font-size: 14px;
}
.ob-amount {
font-size: 28px;
}
.ob-actions {
.ant-btn {
height: 34px;
font-size: 14px;
}
}
}

View File

@@ -1,90 +1,322 @@
/* 文件职责:订单大厅看板布局。 */
.ob-page {
--ob-surface: #fff;
--ob-border: #e7e9ee;
--ob-board-bg: #f4f5f7;
--ob-text: #1f2937;
--ob-muted: #98a2b3;
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
padding: 12px;
height: calc(
100vh - var(--header-height, 64px) - var(--tabbar-height, 42px) - 28px
);
min-height: 620px;
padding: 2px 2px 4px;
font-size: 13px;
color: var(--ob-text);
}
.ob-stats {
display: flex;
flex-shrink: 0;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
padding: 12px 16px 14px;
background: var(--ob-surface);
border: 1px solid var(--ob-border);
border-radius: 12px;
}
.ob-stats-main {
display: flex;
flex-wrap: wrap;
gap: 34px;
align-items: flex-end;
}
.ob-stats-actions {
display: flex;
flex: 1;
justify-content: flex-end;
min-width: 360px;
}
.ob-summary-item {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 70px;
&.is-active::after {
position: absolute;
bottom: -11px;
left: 0;
width: 100%;
height: 3px;
content: '';
background: #3f7fe8;
border-radius: 999px;
}
}
.ob-summary-label {
font-size: 12px;
font-weight: 500;
color: rgb(0 0 0 / 45%);
}
.ob-summary-value {
font-size: 36px;
font-weight: 700;
line-height: 1;
&.is-default {
color: #344054;
}
&.is-pending {
color: #fa8c16;
}
&.is-making {
color: #3f7fe8;
}
&.is-delivering {
color: #22b55f;
}
&.is-muted {
color: var(--ob-muted);
}
}
.ob-board {
display: flex;
flex: 1;
gap: 12px;
gap: 14px;
min-height: 0;
padding: 14px 12px 10px;
overflow-x: auto;
background: var(--ob-board-bg);
border: 1px solid var(--ob-border);
border-radius: 12px;
}
.ob-board::-webkit-scrollbar {
height: 6px;
}
.ob-board::-webkit-scrollbar-thumb {
background: #d3d7df;
border-radius: 3px;
}
.ob-column {
display: flex;
flex: 1;
flex-direction: column;
min-width: 280px;
background: #fafafa;
border-radius: 8px;
min-width: 290px;
overflow: hidden;
background: #f2f4f7;
border: 1px solid #e7e9ee;
border-radius: 12px;
}
.ob-column__header {
.ob-col-hd {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
min-height: 46px;
padding: 12px 12px 8px;
font-size: 36px;
font-weight: 700;
color: #111827;
&.making,
&.delivering {
&::before {
display: inline-block;
width: 3px;
height: 24px;
margin-right: 4px;
content: '';
border-radius: 999px;
}
}
&.making::before {
background: #3f7fe8;
}
&.delivering::before {
background: #22b55f;
}
&.done {
color: var(--ob-muted);
}
}
.ob-column__title {
font-size: 15px;
font-weight: 600;
.ob-col-count {
margin-left: 4px;
font-weight: 800;
line-height: 1;
}
.ob-column__body {
.ob-col-count.is-pending {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 8px;
font-size: 20px;
color: #fff;
background: #ff7a14;
border-radius: 999px;
}
.ob-col-count.is-making {
font-size: 40px;
color: #3f7fe8;
}
.ob-col-count.is-delivering {
font-size: 33px;
color: #22b55f;
}
.ob-col-body {
display: flex;
flex: 1;
padding: 8px;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 0 8px 10px;
overflow-y: auto;
.ant-spin-nested-loading {
height: 100%;
}
.ant-spin-container {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 100%;
}
}
.ob-col-body::-webkit-scrollbar {
width: 4px;
}
.ob-col-body::-webkit-scrollbar-thumb {
background: #d2d8e2;
border-radius: 2px;
}
.ob-column__empty {
padding: 40px 0;
color: #bfbfbf;
padding: 34px 0;
font-size: 13px;
color: rgb(0 0 0 / 35%);
text-align: center;
}
/* 统计条 */
.ob-stats {
display: flex;
gap: 16px;
padding: 8px 0;
.ob-column__placeholder {
height: 100%;
background: transparent;
border: 2px dashed #d6dae2;
border-radius: 16px;
}
.ob-stats__item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
@media (max-width: 1400px) {
.ob-stats {
flex-direction: column;
align-items: stretch;
}
.ob-stats-actions {
justify-content: flex-start;
min-width: 0;
}
.ob-column {
min-width: 260px;
}
}
.ob-stats__value {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
@media (max-width: 1024px) {
.ob-page {
height: auto;
min-height: calc(100vh - 90px);
}
.ob-summary-value {
font-size: 28px;
}
.ob-col-hd {
font-size: 30px;
}
.ob-col-count.is-making {
font-size: 34px;
}
.ob-col-count.is-delivering {
font-size: 28px;
}
}
.ob-stats__label {
font-size: 12px;
color: #8c8c8c;
}
@media (max-width: 768px) {
.ob-stats {
gap: 10px;
padding: 10px 12px;
}
.ob-stats__item--pending .ob-stats__value {
color: #fa8c16;
}
.ob-stats-main {
gap: 20px;
}
.ob-stats__item--making .ob-stats__value {
color: #1890ff;
}
.ob-summary-item {
min-width: 62px;
}
.ob-stats__item--delivering .ob-stats__value {
color: #52c41a;
}
.ob-summary-value {
font-size: 22px;
}
.ob-stats__item--completed .ob-stats__value {
color: #8c8c8c;
.ob-board {
padding: 10px;
}
.ob-column {
min-width: 236px;
}
.ob-col-hd {
min-height: 40px;
font-size: 24px;
}
.ob-col-count.is-pending {
min-width: 22px;
height: 22px;
font-size: 14px;
}
.ob-col-count.is-making {
font-size: 27px;
}
.ob-col-count.is-delivering {
font-size: 23px;
}
}

View File

@@ -1,47 +1,157 @@
/* 文件职责:订单大厅工具栏样式。 */
.ob-toolbar {
.ob-toolbar-shell {
width: 100%;
max-width: 920px;
.ob-store-select .ant-select-selector {
height: 32px !important;
padding-top: 1px;
border-radius: 8px !important;
}
.ob-store-select .ant-select-selection-item,
.ob-store-select .ant-select-selection-placeholder {
font-size: 13px;
line-height: 30px !important;
}
}
.ob-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
gap: 10px;
align-items: center;
padding: 8px 0;
justify-content: flex-end;
.ob-channels {
display: flex;
gap: 6px;
align-items: center;
padding: 2px;
background: #f2f4f7;
border-radius: 999px;
}
.ob-channel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 28px;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
color: rgb(0 0 0 / 55%);
cursor: pointer;
background: transparent;
border: none;
border-radius: 999px;
transition: all 0.2s;
&.active {
color: #1677ff;
background: #fff;
box-shadow: 0 1px 3px rgb(22 119 255 / 18%);
}
}
.ob-store-select {
width: 180px;
}
.ob-toolbar-right {
display: flex;
gap: 8px;
align-items: center;
padding-left: 6px;
}
.ob-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: rgb(0 0 0 / 65%);
cursor: pointer;
background: #f8fafc;
border: 1px solid #d9d9d9;
border-radius: 8px;
transition: all 0.2s;
.vben-iconify {
width: 16px;
height: 16px;
}
&:hover {
color: #1677ff;
border-color: #1677ff;
}
&.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
}
.ob-icon-btn--offline {
color: #fa8c16;
}
.ob-connection {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 12px;
color: #23b26b;
.ob-connection-dot {
width: 7px;
height: 7px;
background: currentcolor;
border-radius: 50%;
}
&.offline {
color: #f59e0b;
}
}
}
.ob-toolbar__store {
display: flex;
gap: 8px;
align-items: center;
@media (max-width: 1200px) {
.ob-toolbar-shell {
max-width: 100%;
.ob-toolbar-actions {
justify-content: flex-start;
}
}
}
.ob-toolbar__label {
font-size: 13px;
color: #595959;
white-space: nowrap;
}
@media (max-width: 768px) {
.ob-toolbar-shell {
.ob-store-select {
width: 100%;
}
.ob-toolbar__channel {
flex: 1;
}
.ob-toolbar-actions {
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.ob-toolbar__right {
display: flex;
gap: 12px;
align-items: center;
margin-left: auto;
}
.ob-channels {
width: 100%;
overflow-x: auto;
white-space: nowrap;
}
.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;
.ob-toolbar-right {
justify-content: space-between;
width: 100%;
padding-left: 0;
}
}
}

View File

@@ -2,18 +2,19 @@
* 文件职责:订单大厅本地类型定义。
*/
import type { OrderBoardCard } from '#/api/order-hall';
import type { StoreListItemDto } from '#/api/store';
/** 看板列键。 */
export type BoardColumnKey = 'completed' | 'delivering' | 'making' | 'pending';
/** 渠道筛选值。 */
export type BoardChannelFilter =
| 'all'
| 'miniprogram'
| 'phone'
| 'scan'
| 'staff'
| 'thirdparty';
/** 履约类型筛选值。 */
export type BoardDeliveryFilter = 'all' | 'delivery' | 'dineIn' | 'pickup';
/** 门店下拉选项。 */
export interface StoreOptionItem {
label: string;
value: string;
}
/** SignalR 新订单推送载荷。 */
export interface NewOrderPayload {
@@ -53,12 +54,31 @@ export interface OrderUrgedPayload {
/** 看板页面状态。 */
export interface OrderHallPageState {
channel: BoardChannelFilter;
deliveryFilter: BoardDeliveryFilter;
completedCards: OrderBoardCard[];
deliveringCards: OrderBoardCard[];
isStoreLoading: boolean;
isLoading: boolean;
isSoundEnabled: boolean;
makingCards: OrderBoardCard[];
pendingCards: OrderBoardCard[];
selectedStoreId: string;
storeOptions: StoreOptionItem[];
}
/** 订单大厅统计状态。 */
export interface OrderHallStatsState {
completedCount: number;
deliveringCount: number;
makingCount: number;
pendingCount: number;
todayTotal: number;
}
/** 门店列表与选项转换。 */
export function mapStoreOptions(stores: StoreListItemDto[]): StoreOptionItem[] {
return stores.map((item) => ({
label: item.name,
value: item.id,
}));
}