feat: 重构订单大厅看板UI对齐设计稿
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 51s
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 51s
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
]"
|
||||
<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)]"
|
||||
>
|
||||
<!-- 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>
|
||||
{{ 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>
|
||||
|
||||
<!-- 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 class="ob-time-chip">
|
||||
<IconifyIcon icon="lucide:clock-3" />
|
||||
<span>{{ resolveElapsedClock(card.createdAt) }}</span>
|
||||
</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)">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else-if="columnKey === 'making'">
|
||||
<a-button
|
||||
type="primary"
|
||||
<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)"
|
||||
>
|
||||
出餐完成
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else-if="columnKey === 'delivering'">
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="ob-btn ob-btn-green"
|
||||
@click="emit('confirmDelivery', card.id)"
|
||||
>
|
||||
{{ card.deliveryType === 2 ? '确认送达' : '确认取餐' }}
|
||||
确认
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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-stats__item ob-stats__item--pending">
|
||||
<span class="ob-stats__value">{{ pendingCount }}</span>
|
||||
<span class="ob-stats__label">待接单</span>
|
||||
<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-stats__item ob-stats__item--making">
|
||||
<span class="ob-stats__value">{{ makingCount }}</span>
|
||||
<span class="ob-stats__label">制作中</span>
|
||||
<div class="ob-summary-item">
|
||||
<span class="ob-summary-label">制作中</span>
|
||||
<span class="ob-summary-value is-making">{{ makingCount }}</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 class="ob-summary-item">
|
||||
<span class="ob-summary-label">配送/待取</span>
|
||||
<span class="ob-summary-value is-delivering">{{
|
||||
deliveringCount
|
||||
}}</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-summary-item">
|
||||
<span class="ob-summary-label">已完成</span>
|
||||
<span class="ob-summary-value is-muted">{{ completedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ob-stats-actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 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)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</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 ? '已连接' : '未连接' }}
|
||||
<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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
const { open = false } = defineProps<{
|
||||
open: boolean;
|
||||
open?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@media (max-width: 1400px) {
|
||||
.ob-stats {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ob-stats-actions {
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ob-column {
|
||||
min-width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-stats__value {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ob-stats {
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ob-stats-main {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.ob-summary-item {
|
||||
min-width: 62px;
|
||||
}
|
||||
|
||||
.ob-summary-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ob-board {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ob-column {
|
||||
min-width: 236px;
|
||||
}
|
||||
|
||||
.ob-col-hd {
|
||||
min-height: 40px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-stats__label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.ob-col-count.is-pending {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ob-stats__item--pending .ob-stats__value {
|
||||
color: #fa8c16;
|
||||
}
|
||||
.ob-col-count.is-making {
|
||||
font-size: 27px;
|
||||
}
|
||||
|
||||
.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;
|
||||
.ob-col-count.is-delivering {
|
||||
font-size: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,157 @@
|
||||
/* 文件职责:订单大厅工具栏样式。 */
|
||||
.ob-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
.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__store {
|
||||
.ob-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
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-toolbar__label {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ob-toolbar__channel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ob-toolbar__right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
.ob-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
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;
|
||||
|
||||
.ob-toolbar__status {
|
||||
padding: 2px 8px;
|
||||
.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;
|
||||
border-radius: 10px;
|
||||
color: #23b26b;
|
||||
|
||||
.ob-connection-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: currentcolor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.offline {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ob-toolbar__status--on {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
@media (max-width: 1200px) {
|
||||
.ob-toolbar-shell {
|
||||
max-width: 100%;
|
||||
|
||||
.ob-toolbar-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ob-toolbar__status--off {
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
@media (max-width: 768px) {
|
||||
.ob-toolbar-shell {
|
||||
.ob-store-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ob-toolbar-actions {
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ob-channels {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ob-toolbar-right {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user