feat: 新增堂食管理页面并对齐后端dine-in路由

This commit is contained in:
2026-02-16 15:29:52 +08:00
parent 8d1325edf0
commit 2aceb8b662
26 changed files with 3516 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* 文件职责:堂食区域新增/编辑抽屉。
* 1. 展示区域基础字段。
* 2. 通过回调更新父级状态并提交。
*/
import type { DineInAreaFormState } from '#/views/store/dine-in/types';
import { Button, Drawer, Input, InputNumber } from 'ant-design-vue';
interface Props {
form: DineInAreaFormState;
isSaving: boolean;
onSetDescription: (value: string) => void;
onSetName: (value: string) => void;
onSetSort: (value: number) => void;
open: boolean;
submitText: string;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
return target?.value ?? '';
}
function toNumber(value: null | number | string, fallback = 1) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
</script>
<template>
<Drawer
class="dinein-area-drawer-wrap"
:open="props.open"
:title="props.title"
:width="460"
:mask-closable="true"
@update:open="(value) => emit('update:open', value)"
>
<div class="drawer-form-block">
<label class="drawer-form-label required">区域名称</label>
<Input
:value="props.form.name"
:maxlength="20"
placeholder="如:大厅、包间、露台"
@input="(event) => props.onSetName(readInputValue(event))"
/>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">区域描述</label>
<Input.TextArea
:value="props.form.description"
:maxlength="120"
:rows="3"
placeholder="可选主要用餐区域可容纳约48人"
@input="(event) => props.onSetDescription(readInputValue(event))"
/>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">排序</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.sort"
:min="1"
:precision="0"
:controls="false"
class="drawer-input"
@update:value="(value) => props.onSetSort(toNumber(value, 1))"
/>
<span class="drawer-form-hint">数字越小越靠前</span>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.submitText }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
/**
* 文件职责:堂食区域管理区块。
* 1. 展示区域 pills 与当前区域说明。
* 2. 抛出区域新增、编辑、删除与切换事件。
*/
import type { DineInAreaDto } from '#/api/store-dinein';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
interface Props {
getAreaTableCount: (areaId: string) => number;
isSaving: boolean;
selectedArea?: DineInAreaDto;
selectedAreaId: string;
areas: DineInAreaDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', area: DineInAreaDto): void;
(event: 'edit', area: DineInAreaDto): void;
(event: 'selectArea', areaId: string): void;
}>();
</script>
<template>
<Card :bordered="false" class="dinein-card">
<template #title>
<span class="section-title">区域管理</span>
</template>
<template #extra>
<Button type="primary" size="small" @click="emit('add')">添加区域</Button>
</template>
<template v-if="props.areas.length > 0">
<div class="dinein-area-pills">
<button
v-for="area in props.areas"
:key="area.id"
type="button"
class="dinein-area-pill"
:class="{ active: props.selectedAreaId === area.id }"
@click="emit('selectArea', area.id)"
>
{{ area.name }} ({{ props.getAreaTableCount(area.id) }})
</button>
</div>
<div v-if="props.selectedArea" class="dinein-area-info">
<span class="dinein-area-description">
{{ props.selectedArea.name }} -
{{ props.selectedArea.description || '暂无描述' }}
</span>
<div class="dinein-area-actions">
<Button
type="link"
size="small"
@click="emit('edit', props.selectedArea)"
>
编辑
</Button>
<Popconfirm
title="确认删除该区域吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', props.selectedArea)"
>
<Button type="link" danger size="small" :loading="props.isSaving">
删除
</Button>
</Popconfirm>
</div>
</div>
</template>
<Empty v-else description="暂无区域,请先添加" />
</Card>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
/**
* 文件职责:堂食基础设置区块。
* 1. 展示堂食开关、默认时长、超时提醒配置。
* 2. 抛出保存与重置事件。
*/
import type { DineInBasicSettingsDto } from '#/api/store-dinein';
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
interface Props {
isSaving: boolean;
onSetDefaultDiningMinutes: (value: number) => void;
onSetEnabled: (value: boolean) => void;
onSetOvertimeReminderMinutes: (value: number) => void;
settings: DineInBasicSettingsDto;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'reset'): void;
(event: 'save'): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
</script>
<template>
<Card :bordered="false" class="dinein-card">
<template #title>
<span class="section-title">堂食设置</span>
</template>
<div class="dinein-form-row">
<label class="dinein-form-label">是否开启堂食</label>
<div class="dinein-form-control">
<Switch
:checked="props.settings.enabled"
@update:checked="(value) => props.onSetEnabled(Boolean(value))"
/>
</div>
</div>
<div class="dinein-form-row">
<label class="dinein-form-label">默认用餐时长</label>
<div class="dinein-form-control">
<InputNumber
:value="props.settings.defaultDiningMinutes"
:min="1"
:precision="0"
:controls="false"
class="dinein-number-input"
@update:value="
(value) => props.onSetDefaultDiningMinutes(toNumber(value, 90))
"
/>
<span class="dinein-form-unit">分钟</span>
</div>
</div>
<div class="dinein-form-row">
<label class="dinein-form-label">超时提醒</label>
<div class="dinein-form-control">
<InputNumber
:value="props.settings.overtimeReminderMinutes"
:min="0"
:precision="0"
:controls="false"
class="dinein-number-input"
@update:value="
(value) => props.onSetOvertimeReminderMinutes(toNumber(value, 10))
"
/>
<span class="dinein-form-unit">分钟</span>
<span class="dinein-form-hint">超过默认用餐时长后提醒</span>
</div>
</div>
<div class="dinein-form-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
保存设置
</Button>
</div>
</Card>
</template>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
/**
* 文件职责:堂食桌位批量生成弹窗。
* 1. 展示批量参数并实时预览编号。
* 2. 通过回调更新父级状态并提交。
*/
import type { DineInSeatsOption } from '#/views/store/dine-in/types';
import { Input, InputNumber, Modal, Select, Tag } from 'ant-design-vue';
interface Props {
areaOptions: Array<{ label: string; value: string }>;
form: {
areaId: string;
codePrefix: string;
count: number;
seats: number;
startNumber: number;
};
isSaving: boolean;
onSetAreaId: (value: string) => void;
onSetCodePrefix: (value: string) => void;
onSetCount: (value: number) => void;
onSetSeats: (value: number) => void;
onSetStartNumber: (value: number) => void;
open: boolean;
previewCodes: string[];
seatsOptions: DineInSeatsOption[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
function readString(value: unknown) {
if (typeof value === 'number' || typeof value === 'string')
return String(value);
return '';
}
function readNumber(value: unknown, fallback = 1) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
</script>
<template>
<Modal
:open="props.open"
title="批量生成桌位"
:width="520"
:confirm-loading="props.isSaving"
ok-text="确认生成"
cancel-text="取消"
:mask-closable="true"
wrap-class-name="dinein-batch-modal-wrap"
@update:open="(value) => emit('update:open', value)"
@ok="emit('submit')"
@cancel="emit('update:open', false)"
>
<div class="batch-form-grid">
<div class="batch-form-item full">
<label>所属区域</label>
<Select
:value="props.form.areaId"
:options="props.areaOptions"
placeholder="请选择区域"
@update:value="(value) => props.onSetAreaId(readString(value))"
/>
</div>
<div class="batch-form-item">
<label>编号前缀</label>
<Input
:value="props.form.codePrefix"
:maxlength="6"
placeholder="如A"
@input="(event) => props.onSetCodePrefix(readInputValue(event))"
/>
</div>
<div class="batch-form-item">
<label>起始编号</label>
<InputNumber
:value="props.form.startNumber"
:min="1"
:precision="0"
:controls="false"
class="batch-input-number"
@update:value="
(value) => props.onSetStartNumber(readNumber(value, 1))
"
/>
</div>
<div class="batch-form-item">
<label>生成数量</label>
<InputNumber
:value="props.form.count"
:min="1"
:max="50"
:precision="0"
:controls="false"
class="batch-input-number"
@update:value="(value) => props.onSetCount(readNumber(value, 1))"
/>
</div>
<div class="batch-form-item">
<label>座位数</label>
<Select
:value="props.form.seats"
:options="props.seatsOptions"
@update:value="(value) => props.onSetSeats(readNumber(value, 4))"
/>
</div>
</div>
<div class="batch-preview-wrap">
<div class="batch-preview-title">预览将生成以下桌位</div>
<div class="batch-preview-tags">
<Tag v-for="code in props.previewCodes" :key="code" color="processing">
{{ code }}
</Tag>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* 文件职责:堂食桌位新增/编辑抽屉。
* 1. 展示桌位编号、区域、座位、停用状态、标签。
* 2. 通过回调更新父级状态并提交。
*/
import type { DineInTableStatus } from '#/api/store-dinein';
import type {
DineInSeatsOption,
DineInTableFormState,
} from '#/views/store/dine-in/types';
import { Button, Drawer, Input, Select, Switch, Tag } from 'ant-design-vue';
interface Props {
areaOptions: Array<{ label: string; value: string }>;
form: DineInTableFormState;
isSaving: boolean;
onSetAreaId: (value: string) => void;
onSetCode: (value: string) => void;
onSetDisabled: (value: boolean) => void;
onSetSeats: (value: number) => void;
onSetTags: (value: string[]) => void;
open: boolean;
seatsOptions: DineInSeatsOption[];
statusLabelMap: Record<DineInTableStatus, string>;
submitText: string;
tagSuggestions: string[];
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
const statusHintText = '就餐中/已预约由业务驱动,管理端仅控制是否停用';
function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
function readString(value: unknown) {
if (typeof value === 'number' || typeof value === 'string')
return String(value);
return '';
}
function readNumber(value: unknown, fallback = 4) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readTagValues(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item) => (typeof item === 'string' ? item : String(item)))
.map((item) => item.trim())
.filter(Boolean);
}
</script>
<template>
<Drawer
class="dinein-table-drawer-wrap"
:open="props.open"
:title="props.title"
:width="460"
:mask-closable="true"
@update:open="(value) => emit('update:open', value)"
>
<div class="drawer-form-block">
<label class="drawer-form-label required">桌位编号</label>
<Input
:value="props.form.code"
:maxlength="20"
placeholder="如A09"
@input="(event) => props.onSetCode(readInputValue(event))"
/>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">所属区域</label>
<Select
:value="props.form.areaId"
:options="props.areaOptions"
placeholder="请选择区域"
@update:value="(value) => props.onSetAreaId(readString(value))"
/>
</div>
<div class="drawer-form-grid">
<div class="drawer-form-block">
<label class="drawer-form-label required">座位数</label>
<Select
:value="props.form.seats"
:options="props.seatsOptions"
@update:value="(value) => props.onSetSeats(readNumber(value, 4))"
/>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">停用状态</label>
<div class="drawer-switch-row">
<Switch
:checked="props.form.isDisabled"
@update:checked="(value) => props.onSetDisabled(Boolean(value))"
/>
<span class="drawer-form-hint">打开后桌位不可被分配</span>
</div>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">业务状态</label>
<div class="drawer-status-preview">
<Tag color="processing">
{{ props.statusLabelMap[props.form.sourceStatus] ?? '--' }}
</Tag>
<span class="drawer-form-hint">{{ statusHintText }}</span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">标签</label>
<Select
mode="tags"
:value="props.form.tags"
:options="
props.tagSuggestions.map((item) => ({ label: item, value: item }))
"
:max-tag-count="4"
placeholder="输入后回车添加靠窗、VIP"
@update:value="(value) => props.onSetTags(readTagValues(value))"
/>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.submitText }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
/**
* 文件职责:堂食桌位列表区块。
* 1. 展示当前区域桌位卡片。
* 2. 抛出二维码、编辑、删除、批量生成、新增事件。
*/
import type { DineInTableDto } from '#/api/store-dinein';
import type { DineInStatusOption } from '#/views/store/dine-in/types';
import { Button, Card, Empty, Popconfirm, Tag } from 'ant-design-vue';
interface Props {
isSaving: boolean;
resolveStatusClassName: (status: DineInTableDto['status']) => string;
statusMap: Record<DineInTableDto['status'], DineInStatusOption>;
tables: DineInTableDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'batch'): void;
(event: 'delete', tableId: string): void;
(event: 'edit', table: DineInTableDto): void;
(event: 'qrcode', table: DineInTableDto): void;
}>();
</script>
<template>
<Card :bordered="false" class="dinein-card">
<template #title>
<span class="section-title">桌位列表</span>
</template>
<template #extra>
<div class="dinein-table-header-actions">
<Button size="small" @click="emit('batch')">批量生成</Button>
<Button type="primary" size="small" @click="emit('add')">
添加桌位
</Button>
</div>
</template>
<template v-if="props.tables.length > 0">
<div class="dinein-table-grid">
<div
v-for="table in props.tables"
:key="table.id"
class="dinein-table-card"
:class="{
disabled: table.status === 'disabled',
}"
>
<div class="dinein-table-code">{{ table.code }}</div>
<div class="dinein-table-seat">{{ table.seats }}人桌</div>
<div
class="dinein-table-status"
:class="props.resolveStatusClassName(table.status)"
>
<span class="status-dot"></span>
{{ props.statusMap[table.status]?.label ?? '--' }}
</div>
<div class="dinein-table-tags">
<Tag v-for="tag in table.tags" :key="`${table.id}-${tag}`">
{{ tag }}
</Tag>
</div>
<div class="dinein-table-footer">
<Button size="small" type="text" @click="emit('qrcode', table)">
二维码
</Button>
<Button size="small" type="text" @click="emit('edit', table)">
编辑
</Button>
<Popconfirm
title="确认删除该桌位吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', table.id)"
>
<Button size="small" type="text" danger :loading="props.isSaving">
删除
</Button>
</Popconfirm>
</div>
</div>
</div>
</template>
<Empty v-else description="当前区域暂无桌位" />
</Card>
</template>

View File

@@ -0,0 +1,170 @@
import type { Ref } from 'vue';
/**
* 文件职责:堂食区域动作。
* 1. 管理区域新增/编辑抽屉状态与字段。
* 2. 处理区域新增、编辑、删除流程。
*/
import type { DineInAreaDto, DineInTableDto } from '#/api/store-dinein';
import type {
DineInAreaDrawerMode,
DineInAreaFormState,
} from '#/views/store/dine-in/types';
import { message } from 'ant-design-vue';
import { deleteDineInAreaApi, saveDineInAreaApi } from '#/api/store-dinein';
import {
countAreaTables,
createDineInId,
sortAreas,
validateAreaForm,
} from './helpers';
interface CreateAreaActionsOptions {
areaDrawerMode: Ref<DineInAreaDrawerMode>;
areaForm: DineInAreaFormState;
areas: Ref<DineInAreaDto[]>;
fixSelectedArea: () => void;
isAreaDrawerOpen: Ref<boolean>;
isSavingArea: Ref<boolean>;
selectedAreaId: Ref<string>;
selectedStoreId: Ref<string>;
tables: Ref<DineInTableDto[]>;
updateSnapshot: () => void;
}
export function createAreaActions(options: CreateAreaActionsOptions) {
/** 打开区域抽屉并初始化表单。 */
function openAreaDrawer(mode: DineInAreaDrawerMode, area?: DineInAreaDto) {
options.areaDrawerMode.value = mode;
if (mode === 'edit' && area) {
options.areaForm.id = area.id;
options.areaForm.name = area.name;
options.areaForm.description = area.description;
options.areaForm.sort = area.sort;
options.isAreaDrawerOpen.value = true;
return;
}
const nextSort =
options.areas.value.length === 0
? 1
: Math.max(...options.areas.value.map((item) => item.sort)) + 1;
options.areaForm.id = '';
options.areaForm.name = '';
options.areaForm.description = '';
options.areaForm.sort = nextSort;
options.isAreaDrawerOpen.value = true;
}
/** 控制区域抽屉显隐。 */
function setAreaDrawerOpen(value: boolean) {
options.isAreaDrawerOpen.value = value;
}
function setAreaName(value: string) {
options.areaForm.name = value;
}
function setAreaDescription(value: string) {
options.areaForm.description = value;
}
function setAreaSort(value: number) {
options.areaForm.sort = Math.max(1, Math.floor(Number(value || 1)));
}
/** 提交区域表单。 */
async function handleSubmitArea() {
const validateMessage = validateAreaForm({
areaId: options.areaForm.id,
areas: options.areas.value,
form: options.areaForm,
});
if (validateMessage) {
message.error(validateMessage);
return;
}
if (!options.selectedStoreId.value) return;
options.isSavingArea.value = true;
try {
const areaId = options.areaForm.id || createDineInId('area');
const areaPayload: DineInAreaDto = {
id: areaId,
name: options.areaForm.name.trim(),
description: options.areaForm.description.trim(),
sort: Math.max(1, Math.floor(options.areaForm.sort)),
};
await saveDineInAreaApi({
storeId: options.selectedStoreId.value,
area: areaPayload,
});
options.areas.value =
options.areaDrawerMode.value === 'edit' && options.areaForm.id
? sortAreas(
options.areas.value.map((item) =>
item.id === options.areaForm.id ? areaPayload : item,
),
)
: sortAreas([...options.areas.value, areaPayload]);
if (!options.selectedAreaId.value) {
options.selectedAreaId.value = areaPayload.id;
}
options.fixSelectedArea();
options.updateSnapshot();
options.isAreaDrawerOpen.value = false;
message.success(
options.areaDrawerMode.value === 'edit' ? '区域已保存' : '区域已添加',
);
} catch (error) {
console.error(error);
} finally {
options.isSavingArea.value = false;
}
}
/** 删除区域。 */
async function handleDeleteArea(area: DineInAreaDto) {
if (!options.selectedStoreId.value) return;
const tableCount = countAreaTables(area.id, options.tables.value);
if (tableCount > 0) {
message.error('该区域仍有桌位,请先迁移或删除桌位');
return;
}
options.isSavingArea.value = true;
try {
await deleteDineInAreaApi({
storeId: options.selectedStoreId.value,
areaId: area.id,
});
options.areas.value = options.areas.value.filter(
(item) => item.id !== area.id,
);
options.fixSelectedArea();
options.updateSnapshot();
message.success('区域已删除');
} catch (error) {
console.error(error);
} finally {
options.isSavingArea.value = false;
}
}
return {
handleDeleteArea,
handleSubmitArea,
openAreaDrawer,
setAreaDescription,
setAreaDrawerOpen,
setAreaName,
setAreaSort,
};
}

View File

@@ -0,0 +1,159 @@
/**
* 文件职责:堂食管理页面静态常量。
* 1. 维护默认区域、桌位、基础设置。
* 2. 提供状态、座位数等选项映射。
*/
import type {
DineInAreaDto,
DineInBasicSettingsDto,
DineInEditableStatus,
DineInTableDto,
DineInTableStatus,
} from '#/api/store-dinein';
import type {
DineInSeatsOption,
DineInStatusOption,
} from '#/views/store/dine-in/types';
export const DINE_IN_SEATS_OPTIONS: DineInSeatsOption[] = [
{ label: '2人桌', value: 2 },
{ label: '4人桌', value: 4 },
{ label: '6人桌', value: 6 },
{ label: '8人桌', value: 8 },
{ label: '10人桌', value: 10 },
{ label: '12人桌', value: 12 },
];
export const DINE_IN_STATUS_MAP: Record<DineInTableStatus, DineInStatusOption> =
{
free: { value: 'free', label: '空闲', color: '#22c55e', className: 'free' },
dining: {
value: 'dining',
label: '就餐中',
color: '#f59e0b',
className: 'dining',
},
reserved: {
value: 'reserved',
label: '已预约',
color: '#1677ff',
className: 'reserved',
},
disabled: {
value: 'disabled',
label: '停用',
color: '#9ca3af',
className: 'disabled',
},
};
export const DINE_IN_EDITABLE_STATUS_OPTIONS: Array<{
label: string;
value: DineInEditableStatus;
}> = [
{ label: '空闲', value: 'free' },
{ label: '停用', value: 'disabled' },
];
export const DEFAULT_DINE_IN_BASIC_SETTINGS: DineInBasicSettingsDto = {
enabled: true,
defaultDiningMinutes: 90,
overtimeReminderMinutes: 10,
};
export const DEFAULT_DINE_IN_AREAS: DineInAreaDto[] = [
{
id: 'dinein-area-hall',
name: '大厅',
description: '主要用餐区域共12张桌位可容纳约48人同时用餐',
sort: 1,
},
{
id: 'dinein-area-private-room',
name: '包间',
description: '安静独立区域,适合聚餐与商务接待',
sort: 2,
},
{
id: 'dinein-area-terrace',
name: '露台',
description: '开放式外摆区域,适合休闲场景',
sort: 3,
},
];
export const DEFAULT_DINE_IN_TABLES: DineInTableDto[] = [
{
id: 'dinein-table-a01',
code: 'A01',
areaId: 'dinein-area-hall',
seats: 4,
status: 'free',
tags: ['靠窗'],
},
{
id: 'dinein-table-a02',
code: 'A02',
areaId: 'dinein-area-hall',
seats: 2,
status: 'dining',
tags: [],
},
{
id: 'dinein-table-a03',
code: 'A03',
areaId: 'dinein-area-hall',
seats: 6,
status: 'free',
tags: ['VIP', '靠窗'],
},
{
id: 'dinein-table-a04',
code: 'A04',
areaId: 'dinein-area-hall',
seats: 4,
status: 'reserved',
tags: [],
},
{
id: 'dinein-table-a07',
code: 'A07',
areaId: 'dinein-area-hall',
seats: 4,
status: 'disabled',
tags: [],
},
{
id: 'dinein-table-v01',
code: 'V01',
areaId: 'dinein-area-private-room',
seats: 8,
status: 'dining',
tags: ['包厢'],
},
{
id: 'dinein-table-v02',
code: 'V02',
areaId: 'dinein-area-private-room',
seats: 6,
status: 'free',
tags: ['VIP'],
},
{
id: 'dinein-table-t01',
code: 'T01',
areaId: 'dinein-area-terrace',
seats: 4,
status: 'free',
tags: ['露台'],
},
];
export const TABLE_TAG_SUGGESTIONS = [
'VIP',
'包厢',
'吧台',
'安静区',
'家庭位',
'靠窗',
];

View File

@@ -0,0 +1,76 @@
import type { ComputedRef, Ref } from 'vue';
/**
* 文件职责:堂食管理复制动作。
* 1. 管理复制弹窗状态与目标门店勾选。
* 2. 提交复制请求并反馈结果。
*/
import type { StoreListItemDto } from '#/api/store';
import { message } from 'ant-design-vue';
import { copyStoreDineInSettingsApi } from '#/api/store-dinein';
interface CreateCopyActionsOptions {
copyCandidates: ComputedRef<StoreListItemDto[]>;
copyTargetStoreIds: Ref<string[]>;
isCopyModalOpen: Ref<boolean>;
isCopySubmitting: Ref<boolean>;
selectedStoreId: Ref<string>;
}
export function createCopyActions(options: CreateCopyActionsOptions) {
/** 打开复制弹窗。 */
function openCopyModal() {
if (!options.selectedStoreId.value) return;
options.copyTargetStoreIds.value = [];
options.isCopyModalOpen.value = true;
}
/** 切换单个目标门店。 */
function toggleCopyStore(storeId: string, checked: boolean) {
options.copyTargetStoreIds.value = checked
? [...new Set([storeId, ...options.copyTargetStoreIds.value])]
: options.copyTargetStoreIds.value.filter((id) => id !== storeId);
}
/** 全选/取消全选。 */
function handleCopyCheckAll(checked: boolean) {
options.copyTargetStoreIds.value = checked
? options.copyCandidates.value.map((item) => item.id)
: [];
}
/** 提交复制。 */
async function handleCopySubmit() {
if (!options.selectedStoreId.value) return;
if (options.copyTargetStoreIds.value.length === 0) {
message.error('请至少选择一个目标门店');
return;
}
options.isCopySubmitting.value = true;
try {
await copyStoreDineInSettingsApi({
sourceStoreId: options.selectedStoreId.value,
targetStoreIds: options.copyTargetStoreIds.value,
});
message.success(
`已复制到 ${options.copyTargetStoreIds.value.length} 家门店`,
);
options.isCopyModalOpen.value = false;
options.copyTargetStoreIds.value = [];
} catch (error) {
console.error(error);
} finally {
options.isCopySubmitting.value = false;
}
}
return {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
};
}

View File

@@ -0,0 +1,203 @@
import type { Ref } from 'vue';
/**
* 文件职责:堂食管理数据动作。
* 1. 加载门店列表与门店堂食设置。
* 2. 保存基础设置并维护快照。
*/
import type { StoreListItemDto } from '#/api/store';
import type {
DineInAreaDto,
DineInBasicSettingsDto,
DineInTableDto,
} from '#/api/store-dinein';
import type { DineInSettingsSnapshot } from '#/views/store/dine-in/types';
import { message } from 'ant-design-vue';
import { getStoreListApi } from '#/api/store';
import {
getStoreDineInSettingsApi,
saveStoreDineInBasicSettingsApi,
} from '#/api/store-dinein';
import {
DEFAULT_DINE_IN_AREAS,
DEFAULT_DINE_IN_BASIC_SETTINGS,
DEFAULT_DINE_IN_TABLES,
} from './constants';
import {
cloneAreas,
cloneBasicSettings,
cloneTables,
createSettingsSnapshot,
sortAreas,
sortTables,
} from './helpers';
interface CreateDataActionsOptions {
areas: Ref<DineInAreaDto[]>;
basicSettings: DineInBasicSettingsDto;
isPageLoading: Ref<boolean>;
isSavingBasic: Ref<boolean>;
isStoreLoading: Ref<boolean>;
selectedAreaId: Ref<string>;
selectedStoreId: Ref<string>;
snapshot: Ref<DineInSettingsSnapshot | null>;
stores: Ref<StoreListItemDto[]>;
tables: Ref<DineInTableDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
/** 同步基础设置,保持 reactive 引用不变。 */
function syncBasicSettings(next: DineInBasicSettingsDto) {
options.basicSettings.enabled = next.enabled;
options.basicSettings.defaultDiningMinutes = next.defaultDiningMinutes;
options.basicSettings.overtimeReminderMinutes =
next.overtimeReminderMinutes;
}
/** 应用默认配置(接口异常兜底)。 */
function applyDefaultSettings() {
options.areas.value = sortAreas(cloneAreas(DEFAULT_DINE_IN_AREAS));
options.tables.value = sortTables(cloneTables(DEFAULT_DINE_IN_TABLES));
syncBasicSettings(cloneBasicSettings(DEFAULT_DINE_IN_BASIC_SETTINGS));
options.selectedAreaId.value = options.areas.value[0]?.id ?? '';
}
/** 构建当前快照。 */
function buildCurrentSnapshot() {
return createSettingsSnapshot({
areas: options.areas.value,
tables: options.tables.value,
basicSettings: options.basicSettings,
});
}
/** 根据当前区域合法性回填选中值。 */
function fixSelectedArea() {
if (options.areas.value.length === 0) {
options.selectedAreaId.value = '';
return;
}
const hasSelected = options.areas.value.some(
(area) => area.id === options.selectedAreaId.value,
);
if (!hasSelected) {
options.selectedAreaId.value = options.areas.value[0]?.id ?? '';
}
}
/** 加载门店堂食设置。 */
async function loadStoreSettings(storeId: string) {
options.isPageLoading.value = true;
try {
const currentStoreId = storeId;
const result = await getStoreDineInSettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return;
options.areas.value = sortAreas(
result.areas?.length > 0
? result.areas
: cloneAreas(DEFAULT_DINE_IN_AREAS),
);
options.tables.value = sortTables(
result.tables?.length > 0
? result.tables
: cloneTables(DEFAULT_DINE_IN_TABLES),
);
syncBasicSettings({
...DEFAULT_DINE_IN_BASIC_SETTINGS,
...result.basicSettings,
});
fixSelectedArea();
options.snapshot.value = buildCurrentSnapshot();
} catch (error) {
console.error(error);
applyDefaultSettings();
options.snapshot.value = buildCurrentSnapshot();
} finally {
options.isPageLoading.value = false;
}
}
/** 加载门店列表。 */
async function loadStores() {
options.isStoreLoading.value = true;
try {
const result = await getStoreListApi({
keyword: undefined,
businessStatus: undefined,
auditStatus: undefined,
serviceType: undefined,
page: 1,
pageSize: 200,
});
options.stores.value = result.items ?? [];
if (options.stores.value.length === 0) {
options.selectedStoreId.value = '';
applyDefaultSettings();
options.snapshot.value = null;
return;
}
const hasSelectedStore = options.stores.value.some(
(item) => item.id === options.selectedStoreId.value,
);
if (!hasSelectedStore) {
const firstStore = options.stores.value[0];
if (firstStore) options.selectedStoreId.value = firstStore.id;
return;
}
if (options.selectedStoreId.value) {
await loadStoreSettings(options.selectedStoreId.value);
}
} catch (error) {
console.error(error);
options.stores.value = [];
options.selectedStoreId.value = '';
applyDefaultSettings();
options.snapshot.value = null;
} finally {
options.isStoreLoading.value = false;
}
}
/** 保存基础设置。 */
async function saveBasicSettings() {
if (!options.selectedStoreId.value) return;
options.isSavingBasic.value = true;
try {
await saveStoreDineInBasicSettingsApi({
storeId: options.selectedStoreId.value,
basicSettings: cloneBasicSettings(options.basicSettings),
});
options.snapshot.value = buildCurrentSnapshot();
message.success('堂食设置已保存');
} catch (error) {
console.error(error);
} finally {
options.isSavingBasic.value = false;
}
}
/** 重置基础设置到最近快照。 */
function resetBasicSettings() {
const source =
options.snapshot.value?.basicSettings ??
cloneBasicSettings(DEFAULT_DINE_IN_BASIC_SETTINGS);
syncBasicSettings(source);
message.success('已恢复到最近一次保存状态');
}
return {
buildCurrentSnapshot,
fixSelectedArea,
loadStoreSettings,
loadStores,
resetBasicSettings,
saveBasicSettings,
};
}

View File

@@ -0,0 +1,156 @@
/**
* 文件职责:堂食管理页面纯函数工具。
* 1. 负责克隆、排序、格式化、校验等纯逻辑。
* 2. 负责批量编号预览与冲突检测。
*/
import type {
DineInAreaDto,
DineInBasicSettingsDto,
DineInTableDto,
DineInTableStatus,
} from '#/api/store-dinein';
import type {
DineInAreaFormState,
DineInBatchFormState,
DineInSettingsSnapshot,
DineInTableFormState,
} from '#/views/store/dine-in/types';
/** 深拷贝堂食设置。 */
export function cloneBasicSettings(source: DineInBasicSettingsDto) {
return { ...source };
}
/** 深拷贝区域列表。 */
export function cloneAreas(source: DineInAreaDto[]) {
return source.map((item) => ({ ...item }));
}
/** 深拷贝桌位列表。 */
export function cloneTables(source: DineInTableDto[]) {
return source.map((item) => ({ ...item, tags: [...item.tags] }));
}
/** 组装设置快照。 */
export function createSettingsSnapshot(payload: {
areas: DineInAreaDto[];
basicSettings: DineInBasicSettingsDto;
tables: DineInTableDto[];
}): DineInSettingsSnapshot {
return {
basicSettings: cloneBasicSettings(payload.basicSettings),
areas: cloneAreas(payload.areas),
tables: cloneTables(payload.tables),
};
}
/** 按排序字段与名称稳定排序区域。 */
export function sortAreas(source: DineInAreaDto[]) {
return cloneAreas(source).toSorted((a, b) => {
const sortDiff = a.sort - b.sort;
if (sortDiff !== 0) return sortDiff;
return a.name.localeCompare(b.name);
});
}
/** 按编号排序桌位。 */
export function sortTables(source: DineInTableDto[]) {
return cloneTables(source).toSorted((a, b) => a.code.localeCompare(b.code));
}
/** 生成唯一 ID。 */
export function createDineInId(prefix: 'area' | 'table') {
return `dinein-${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 规范化桌位编号(大写 + 去空格)。 */
export function normalizeTableCode(code: string) {
return code.trim().toUpperCase();
}
/** 统计区域下桌位数量。 */
export function countAreaTables(areaId: string, tables: DineInTableDto[]) {
return tables.filter((item) => item.areaId === areaId).length;
}
/** 根据状态计算样式 class。 */
export function resolveStatusClassName(status: DineInTableStatus) {
return `status-${status}`;
}
/** 批量生成编号预览。 */
export function generateBatchCodes(payload: {
codePrefix: string;
count: number;
startNumber: number;
}) {
const prefix = payload.codePrefix.trim().toUpperCase() || 'A';
const start = Math.max(1, Math.floor(payload.startNumber));
const count = Math.max(1, Math.min(50, Math.floor(payload.count)));
const width = Math.max(String(start + count - 1).length, 2);
return Array.from({ length: count }).map((_, index) => {
const codeNumber = String(start + index).padStart(width, '0');
return `${prefix}${codeNumber}`;
});
}
/** 校验区域表单。 */
export function validateAreaForm(payload: {
areaId?: string;
areas: DineInAreaDto[];
form: DineInAreaFormState;
}) {
if (!payload.form.name.trim()) return '请输入区域名称';
if (payload.form.sort <= 0) return '排序必须大于 0';
const duplicated = payload.areas.some((item) => {
if (payload.areaId && item.id === payload.areaId) return false;
return item.name.trim() === payload.form.name.trim();
});
if (duplicated) return '区域名称已存在,请更换';
return '';
}
/** 校验桌位表单。 */
export function validateTableForm(payload: {
form: DineInTableFormState;
tableId?: string;
tables: DineInTableDto[];
}) {
const code = normalizeTableCode(payload.form.code);
if (!code) return '请输入桌位编号';
if (!payload.form.areaId) return '请选择所属区域';
if (payload.form.seats <= 0) return '座位数必须大于 0';
const duplicated = payload.tables.some((item) => {
if (payload.tableId && item.id === payload.tableId) return false;
return item.code === code;
});
if (duplicated) return '桌位编号已存在,请更换';
return '';
}
/** 校验批量生成表单。 */
export function validateBatchForm(payload: {
existingCodes: string[];
form: DineInBatchFormState;
}) {
if (!payload.form.areaId) return '请选择所属区域';
if (!payload.form.codePrefix.trim()) return '请输入编号前缀';
if (payload.form.startNumber <= 0) return '起始编号必须大于 0';
if (payload.form.count <= 0 || payload.form.count > 50)
return '生成数量范围为 1-50';
if (payload.form.seats <= 0) return '座位数必须大于 0';
const generatedCodes = generateBatchCodes(payload.form);
const existingSet = new Set(
payload.existingCodes.map((item) => item.toUpperCase()),
);
const conflictCode = generatedCodes.find((code) => existingSet.has(code));
if (conflictCode) return `桌位编号 ${conflictCode} 已存在,请调整后重试`;
return '';
}

View File

@@ -0,0 +1,293 @@
import type { ComputedRef, Ref } from 'vue';
/**
* 文件职责:堂食桌位动作。
* 1. 管理桌位抽屉与批量生成弹窗状态。
* 2. 处理桌位新增、编辑、删除与批量生成流程。
*/
import type {
DineInAreaDto,
DineInTableDto,
DineInTableStatus,
} from '#/api/store-dinein';
import type {
DineInBatchFormState,
DineInTableDrawerMode,
DineInTableFormState,
} from '#/views/store/dine-in/types';
import { message } from 'ant-design-vue';
import {
batchCreateDineInTablesApi,
deleteDineInTableApi,
saveDineInTableApi,
} from '#/api/store-dinein';
import {
createDineInId,
generateBatchCodes,
normalizeTableCode,
sortTables,
validateBatchForm,
validateTableForm,
} from './helpers';
interface CreateTableActionsOptions {
areas: Ref<DineInAreaDto[]>;
batchForm: DineInBatchFormState;
batchPreviewCodes: ComputedRef<string[]>;
isBatchModalOpen: Ref<boolean>;
isSavingBatch: Ref<boolean>;
isSavingTable: Ref<boolean>;
isTableDrawerOpen: Ref<boolean>;
selectedStoreId: Ref<string>;
selectedTableAreaId: Ref<string>;
tableDrawerMode: Ref<DineInTableDrawerMode>;
tableForm: DineInTableFormState;
tables: Ref<DineInTableDto[]>;
updateSnapshot: () => void;
}
export function createTableActions(options: CreateTableActionsOptions) {
/** 打开桌位抽屉并初始化表单。 */
function openTableDrawer(
mode: DineInTableDrawerMode,
table?: DineInTableDto,
) {
options.tableDrawerMode.value = mode;
if (mode === 'edit' && table) {
options.tableForm.id = table.id;
options.tableForm.code = table.code;
options.tableForm.areaId = table.areaId;
options.tableForm.seats = table.seats;
options.tableForm.tags = [...table.tags];
options.tableForm.sourceStatus = table.status;
options.tableForm.isDisabled = table.status === 'disabled';
options.isTableDrawerOpen.value = true;
return;
}
options.tableForm.id = '';
options.tableForm.code = '';
options.tableForm.areaId = options.selectedTableAreaId.value;
options.tableForm.seats = 4;
options.tableForm.tags = [];
options.tableForm.sourceStatus = 'free';
options.tableForm.isDisabled = false;
options.isTableDrawerOpen.value = true;
}
/** 控制桌位抽屉显隐。 */
function setTableDrawerOpen(value: boolean) {
options.isTableDrawerOpen.value = value;
}
function setTableCode(value: string) {
options.tableForm.code = value;
}
function setTableAreaId(value: string) {
options.tableForm.areaId = value;
}
function setTableSeats(value: number) {
options.tableForm.seats = Math.max(1, Math.floor(Number(value || 1)));
}
function setTableDisabled(value: boolean) {
options.tableForm.isDisabled = Boolean(value);
}
function setTableTags(tags: string[]) {
options.tableForm.tags = [
...new Set(tags.map((item) => item.trim()).filter(Boolean)),
];
}
/** 提交桌位表单。 */
async function handleSubmitTable() {
if (!options.selectedStoreId.value) return;
const validateMessage = validateTableForm({
tableId: options.tableForm.id,
form: options.tableForm,
tables: options.tables.value,
});
if (validateMessage) {
message.error(validateMessage);
return;
}
options.isSavingTable.value = true;
try {
const tableId = options.tableForm.id || createDineInId('table');
let nextStatus: DineInTableStatus = options.tableForm.sourceStatus;
if (options.tableForm.isDisabled) {
nextStatus = 'disabled';
} else if (options.tableForm.sourceStatus === 'disabled') {
nextStatus = 'free';
}
const tablePayload: DineInTableDto = {
id: tableId,
code: normalizeTableCode(options.tableForm.code),
areaId: options.tableForm.areaId,
seats: options.tableForm.seats,
status: nextStatus,
tags: [...options.tableForm.tags],
};
await saveDineInTableApi({
storeId: options.selectedStoreId.value,
table: tablePayload,
});
options.tables.value =
options.tableDrawerMode.value === 'edit' && options.tableForm.id
? sortTables(
options.tables.value.map((item) =>
item.id === options.tableForm.id ? tablePayload : item,
),
)
: sortTables([...options.tables.value, tablePayload]);
options.updateSnapshot();
options.isTableDrawerOpen.value = false;
message.success(
options.tableDrawerMode.value === 'edit' ? '桌位已保存' : '桌位已添加',
);
} catch (error) {
console.error(error);
} finally {
options.isSavingTable.value = false;
}
}
/** 删除桌位。 */
async function handleDeleteTable(tableId: string) {
if (!options.selectedStoreId.value) return;
options.isSavingTable.value = true;
try {
await deleteDineInTableApi({
storeId: options.selectedStoreId.value,
tableId,
});
options.tables.value = options.tables.value.filter(
(item) => item.id !== tableId,
);
options.updateSnapshot();
message.success('桌位已删除');
} catch (error) {
console.error(error);
} finally {
options.isSavingTable.value = false;
}
}
/** 打开批量生成弹窗并初始化参数。 */
function openBatchModal() {
options.batchForm.areaId = options.selectedTableAreaId.value;
options.batchForm.codePrefix = 'A';
options.batchForm.startNumber = 1;
options.batchForm.count = 4;
options.batchForm.seats = 4;
options.isBatchModalOpen.value = true;
}
/** 控制批量弹窗显隐。 */
function setBatchModalOpen(value: boolean) {
options.isBatchModalOpen.value = value;
}
function setBatchAreaId(value: string) {
options.batchForm.areaId = value;
}
function setBatchCodePrefix(value: string) {
options.batchForm.codePrefix = value.trim().toUpperCase();
}
function setBatchStartNumber(value: number) {
options.batchForm.startNumber = Math.max(1, Math.floor(Number(value || 1)));
}
function setBatchCount(value: number) {
options.batchForm.count = Math.max(
1,
Math.min(50, Math.floor(Number(value || 1))),
);
}
function setBatchSeats(value: number) {
options.batchForm.seats = Math.max(1, Math.floor(Number(value || 1)));
}
/** 提交批量生成。 */
async function handleSubmitBatch() {
if (!options.selectedStoreId.value) return;
const validateMessage = validateBatchForm({
form: options.batchForm,
existingCodes: options.tables.value.map((item) => item.code),
});
if (validateMessage) {
message.error(validateMessage);
return;
}
options.isSavingBatch.value = true;
try {
await batchCreateDineInTablesApi({
storeId: options.selectedStoreId.value,
areaId: options.batchForm.areaId,
codePrefix: options.batchForm.codePrefix,
startNumber: options.batchForm.startNumber,
count: options.batchForm.count,
seats: options.batchForm.seats,
});
const createdTables: DineInTableDto[] =
options.batchPreviewCodes.value.map((code) => ({
id: createDineInId('table'),
areaId: options.batchForm.areaId,
code,
seats: options.batchForm.seats,
status: 'free',
tags: [],
}));
options.tables.value = sortTables([
...options.tables.value,
...createdTables,
]);
options.updateSnapshot();
options.isBatchModalOpen.value = false;
message.success(`已生成 ${createdTables.length} 张桌位`);
} catch (error) {
console.error(error);
} finally {
options.isSavingBatch.value = false;
}
}
return {
generateBatchCodes,
handleDeleteTable,
handleSubmitBatch,
handleSubmitTable,
openBatchModal,
openTableDrawer,
setBatchAreaId,
setBatchCodePrefix,
setBatchCount,
setBatchModalOpen,
setBatchSeats,
setBatchStartNumber,
setTableAreaId,
setTableCode,
setTableDisabled,
setTableDrawerOpen,
setTableSeats,
setTableTags,
};
}

View File

@@ -0,0 +1,399 @@
/**
* 文件职责:堂食管理页面主编排。
* 1. 维护页面级状态(门店、区域、桌位、设置、抽屉、弹窗)。
* 2. 组合数据加载、复制、区域/桌位动作。
* 3. 对外暴露视图层可直接消费的状态与方法。
*/
import type { StoreListItemDto } from '#/api/store';
import type {
DineInAreaDto,
DineInBasicSettingsDto,
DineInTableDto,
} from '#/api/store-dinein';
import type {
DineInAreaDrawerMode,
DineInAreaFormState,
DineInBatchFormState,
DineInSettingsSnapshot,
DineInTableDrawerMode,
DineInTableFormState,
} from '#/views/store/dine-in/types';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { createAreaActions } from './dinein-page/area-actions';
import {
DEFAULT_DINE_IN_AREAS,
DEFAULT_DINE_IN_BASIC_SETTINGS,
DEFAULT_DINE_IN_TABLES,
DINE_IN_SEATS_OPTIONS,
DINE_IN_STATUS_MAP,
TABLE_TAG_SUGGESTIONS,
} from './dinein-page/constants';
import { createCopyActions } from './dinein-page/copy-actions';
import { createDataActions } from './dinein-page/data-actions';
import {
cloneAreas,
cloneBasicSettings,
cloneTables,
countAreaTables,
createSettingsSnapshot,
generateBatchCodes,
resolveStatusClassName,
sortAreas,
sortTables,
} from './dinein-page/helpers';
import { createTableActions } from './dinein-page/table-actions';
export function useStoreDineInPage() {
// 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false);
const isPageLoading = ref(false);
const isSavingBasic = ref(false);
const isSavingArea = ref(false);
const isSavingTable = ref(false);
const isSavingBatch = ref(false);
const isCopySubmitting = ref(false);
// 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const areas = ref<DineInAreaDto[]>(
sortAreas(cloneAreas(DEFAULT_DINE_IN_AREAS)),
);
const tables = ref<DineInTableDto[]>(
sortTables(cloneTables(DEFAULT_DINE_IN_TABLES)),
);
const basicSettings = reactive<DineInBasicSettingsDto>(
cloneBasicSettings(DEFAULT_DINE_IN_BASIC_SETTINGS),
);
const selectedAreaId = ref(areas.value[0]?.id ?? '');
const snapshot = ref<DineInSettingsSnapshot | null>(
createSettingsSnapshot({
areas: areas.value,
tables: tables.value,
basicSettings,
}),
);
// 3. 复制弹窗状态。
const isCopyModalOpen = ref(false);
const copyTargetStoreIds = ref<string[]>([]);
// 4. 区域抽屉状态。
const isAreaDrawerOpen = ref(false);
const areaDrawerMode = ref<DineInAreaDrawerMode>('create');
const areaForm = reactive<DineInAreaFormState>({
id: '',
name: '',
description: '',
sort: 1,
});
// 5. 桌位抽屉与批量弹窗状态。
const isTableDrawerOpen = ref(false);
const tableDrawerMode = ref<DineInTableDrawerMode>('create');
const tableForm = reactive<DineInTableFormState>({
id: '',
code: '',
areaId: selectedAreaId.value,
seats: 4,
tags: [],
sourceStatus: 'free',
isDisabled: false,
});
const isBatchModalOpen = ref(false);
const batchForm = reactive<DineInBatchFormState>({
areaId: selectedAreaId.value,
codePrefix: 'A',
startNumber: 1,
count: 4,
seats: 4,
});
// 6. 页面衍生视图数据。
const storeOptions = computed(() =>
stores.value.map((store) => ({ label: store.name, value: store.id })),
);
const selectedStoreName = computed(
() =>
stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'',
);
const selectedArea = computed(
() =>
areas.value.find((area) => area.id === selectedAreaId.value) ??
areas.value[0],
);
const selectedAreaTableCount = computed(() => {
const area = selectedArea.value;
if (!area) return 0;
return countAreaTables(area.id, tables.value);
});
const filteredTables = computed(() => {
const area = selectedArea.value;
if (!area) return [];
return tables.value.filter((table) => table.areaId === area.id);
});
const areaOptions = computed(() =>
areas.value.map((area) => ({ label: area.name, value: area.id })),
);
const copyCandidates = computed(() =>
stores.value.filter((store) => store.id !== selectedStoreId.value),
);
const isCopyAllChecked = computed(
() =>
copyCandidates.value.length > 0 &&
copyTargetStoreIds.value.length === copyCandidates.value.length,
);
const isCopyIndeterminate = computed(
() =>
copyTargetStoreIds.value.length > 0 &&
copyTargetStoreIds.value.length < copyCandidates.value.length,
);
const batchPreviewCodes = computed(() => generateBatchCodes(batchForm));
const areaDrawerTitle = computed(() =>
areaDrawerMode.value === 'edit' ? '编辑区域' : '添加区域',
);
const areaSubmitText = computed(() =>
areaDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
);
const tableDrawerTitle = computed(() =>
tableDrawerMode.value === 'edit'
? `编辑桌位 - ${tableForm.code || '--'}`
: '添加桌位',
);
const tableSubmitText = computed(() =>
tableDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
);
// 7. 数据域动作装配。
const {
buildCurrentSnapshot,
fixSelectedArea,
loadStoreSettings,
loadStores,
resetBasicSettings,
saveBasicSettings,
} = createDataActions({
areas,
basicSettings,
isPageLoading,
isSavingBasic,
isStoreLoading,
selectedAreaId,
selectedStoreId,
snapshot,
stores,
tables,
});
const {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
/** 更新快照供重置使用。 */
function updateSnapshot() {
snapshot.value = buildCurrentSnapshot();
}
const {
handleDeleteArea,
handleSubmitArea,
openAreaDrawer,
setAreaDescription,
setAreaDrawerOpen,
setAreaName,
setAreaSort,
} = createAreaActions({
areaDrawerMode,
areaForm,
areas,
fixSelectedArea,
isAreaDrawerOpen,
isSavingArea,
selectedAreaId,
selectedStoreId,
tables,
updateSnapshot,
});
const {
handleDeleteTable,
handleSubmitBatch,
handleSubmitTable,
openBatchModal,
openTableDrawer,
setBatchAreaId,
setBatchCodePrefix,
setBatchCount,
setBatchModalOpen,
setBatchSeats,
setBatchStartNumber,
setTableAreaId,
setTableCode,
setTableDisabled,
setTableDrawerOpen,
setTableSeats,
setTableTags,
} = createTableActions({
areas,
batchForm,
batchPreviewCodes,
isBatchModalOpen,
isSavingBatch,
isSavingTable,
isTableDrawerOpen,
selectedStoreId,
selectedTableAreaId: selectedAreaId,
tableDrawerMode,
tableForm,
tables,
updateSnapshot,
});
// 8. 页面字段更新方法。
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setSelectedAreaId(value: string) {
selectedAreaId.value = value;
}
function setDineInEnabled(value: boolean) {
basicSettings.enabled = Boolean(value);
}
function setDefaultDiningMinutes(value: number) {
basicSettings.defaultDiningMinutes = Math.max(
1,
Math.floor(Number(value || 1)),
);
}
function setOvertimeReminderMinutes(value: number) {
basicSettings.overtimeReminderMinutes = Math.max(
0,
Math.floor(Number(value || 0)),
);
}
// 9. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
areas.value = sortAreas(cloneAreas(DEFAULT_DINE_IN_AREAS));
tables.value = sortTables(cloneTables(DEFAULT_DINE_IN_TABLES));
basicSettings.enabled = DEFAULT_DINE_IN_BASIC_SETTINGS.enabled;
basicSettings.defaultDiningMinutes =
DEFAULT_DINE_IN_BASIC_SETTINGS.defaultDiningMinutes;
basicSettings.overtimeReminderMinutes =
DEFAULT_DINE_IN_BASIC_SETTINGS.overtimeReminderMinutes;
selectedAreaId.value = areas.value[0]?.id ?? '';
snapshot.value = null;
return;
}
await loadStoreSettings(storeId);
});
// 10. 页面首屏初始化。
onMounted(loadStores);
return {
DINE_IN_SEATS_OPTIONS,
DINE_IN_STATUS_MAP,
TABLE_TAG_SUGGESTIONS,
areaDrawerTitle,
areaForm,
areaOptions,
areaSubmitText,
areas,
basicSettings,
batchForm,
batchPreviewCodes,
copyCandidates,
copyTargetStoreIds,
filteredTables,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteArea,
handleDeleteTable,
handleSubmitArea,
handleSubmitBatch,
handleSubmitTable,
isAreaDrawerOpen,
isBatchModalOpen,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isPageLoading,
isSavingArea,
isSavingBasic,
isSavingBatch,
isSavingTable,
isStoreLoading,
isTableDrawerOpen,
openAreaDrawer,
openBatchModal,
openCopyModal,
openTableDrawer,
resetBasicSettings,
resolveStatusClassName,
saveBasicSettings,
selectedArea,
selectedAreaId,
selectedAreaTableCount,
selectedStoreId,
selectedStoreName,
setAreaDescription,
setAreaDrawerOpen,
setAreaName,
setAreaSort,
setBatchAreaId,
setBatchCodePrefix,
setBatchCount,
setBatchModalOpen,
setBatchSeats,
setBatchStartNumber,
setDefaultDiningMinutes,
setDineInEnabled,
setOvertimeReminderMinutes,
setSelectedAreaId,
setSelectedStoreId,
setTableAreaId,
setTableCode,
setTableDisabled,
setTableDrawerOpen,
setTableSeats,
setTableTags,
storeOptions,
tableDrawerTitle,
tableForm,
tableSubmitText,
tables,
toggleCopyStore,
};
}

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import type { DineInAreaDto } from '#/api/store-dinein';
/**
* 文件职责:堂食管理页面主视图。
* 1. 组合区域、桌位、堂食设置与抽屉/弹窗子组件。
* 2. 承接门店维度切换与复制弹窗。
*/
import { Page } from '@vben/common-ui';
import { Card, Empty, message, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import DineInAreaDrawer from './components/DineInAreaDrawer.vue';
import DineInAreaSection from './components/DineInAreaSection.vue';
import DineInBasicSettingsCard from './components/DineInBasicSettingsCard.vue';
import DineInBatchModal from './components/DineInBatchModal.vue';
import DineInTableDrawer from './components/DineInTableDrawer.vue';
import DineInTableGridSection from './components/DineInTableGridSection.vue';
import { useStoreDineInPage } from './composables/useStoreDineInPage';
const {
DINE_IN_SEATS_OPTIONS,
DINE_IN_STATUS_MAP,
TABLE_TAG_SUGGESTIONS,
areaDrawerTitle,
areaForm,
areaOptions,
areaSubmitText,
areas,
basicSettings,
batchForm,
batchPreviewCodes,
copyCandidates,
copyTargetStoreIds,
filteredTables,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteArea,
handleDeleteTable,
handleSubmitArea,
handleSubmitBatch,
handleSubmitTable,
isAreaDrawerOpen,
isBatchModalOpen,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isPageLoading,
isSavingArea,
isSavingBasic,
isSavingBatch,
isSavingTable,
isStoreLoading,
isTableDrawerOpen,
openAreaDrawer,
openBatchModal,
openCopyModal,
openTableDrawer,
resetBasicSettings,
resolveStatusClassName,
saveBasicSettings,
selectedArea,
selectedAreaId,
selectedStoreId,
selectedStoreName,
setAreaDescription,
setAreaDrawerOpen,
setAreaName,
setAreaSort,
setBatchAreaId,
setBatchCodePrefix,
setBatchCount,
setBatchModalOpen,
setBatchSeats,
setBatchStartNumber,
setDefaultDiningMinutes,
setDineInEnabled,
setOvertimeReminderMinutes,
setSelectedAreaId,
setSelectedStoreId,
setTableAreaId,
setTableCode,
setTableDisabled,
setTableDrawerOpen,
setTableSeats,
setTableTags,
storeOptions,
tableDrawerTitle,
tableForm,
tableSubmitText,
tables,
toggleCopyStore,
} = useStoreDineInPage();
/** 统计指定区域桌位数。 */
function getAreaTableCount(areaId: string) {
return tables.value.filter((item) => item.areaId === areaId).length;
}
/** 桌位状态文案映射。 */
const tableStatusLabelMap = {
free: DINE_IN_STATUS_MAP.free.label,
dining: DINE_IN_STATUS_MAP.dining.label,
reserved: DINE_IN_STATUS_MAP.reserved.label,
disabled: DINE_IN_STATUS_MAP.disabled.label,
};
/** 当前区域删除处理。 */
function onDeleteSelectedArea(area: DineInAreaDto) {
handleDeleteArea(area);
}
/** 二维码按钮占位处理。 */
function onViewQrCode(tableCode: string) {
message.info(`桌位 ${tableCode} 二维码功能待接入`);
}
</script>
<template>
<Page title="堂食管理" content-class="space-y-4 page-store-dinein">
<StoreScopeToolbar
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0"
@update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal"
/>
<template v-if="storeOptions.length === 0">
<Card :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
</template>
<template v-else>
<Spin :spinning="isPageLoading">
<DineInAreaSection
:areas="areas"
:selected-area-id="selectedAreaId"
:selected-area="selectedArea"
:is-saving="isSavingArea"
:get-area-table-count="getAreaTableCount"
@add="openAreaDrawer('create')"
@edit="(area) => openAreaDrawer('edit', area)"
@delete="onDeleteSelectedArea"
@select-area="setSelectedAreaId"
/>
<DineInTableGridSection
:tables="filteredTables"
:is-saving="isSavingTable"
:status-map="DINE_IN_STATUS_MAP"
:resolve-status-class-name="resolveStatusClassName"
@add="openTableDrawer('create')"
@batch="openBatchModal"
@edit="(table) => openTableDrawer('edit', table)"
@delete="handleDeleteTable"
@qrcode="(table) => onViewQrCode(table.code)"
/>
<DineInBasicSettingsCard
:settings="basicSettings"
:is-saving="isSavingBasic"
:on-set-enabled="setDineInEnabled"
:on-set-default-dining-minutes="setDefaultDiningMinutes"
:on-set-overtime-reminder-minutes="setOvertimeReminderMinutes"
@save="saveBasicSettings"
@reset="resetBasicSettings"
/>
</Spin>
</template>
<DineInAreaDrawer
:open="isAreaDrawerOpen"
:title="areaDrawerTitle"
:submit-text="areaSubmitText"
:form="areaForm"
:is-saving="isSavingArea"
:on-set-name="setAreaName"
:on-set-description="setAreaDescription"
:on-set-sort="setAreaSort"
@update:open="setAreaDrawerOpen"
@submit="handleSubmitArea"
/>
<DineInTableDrawer
:open="isTableDrawerOpen"
:title="tableDrawerTitle"
:submit-text="tableSubmitText"
:form="tableForm"
:is-saving="isSavingTable"
:area-options="areaOptions"
:seats-options="DINE_IN_SEATS_OPTIONS"
:status-label-map="tableStatusLabelMap"
:tag-suggestions="TABLE_TAG_SUGGESTIONS"
:on-set-code="setTableCode"
:on-set-area-id="setTableAreaId"
:on-set-seats="setTableSeats"
:on-set-disabled="setTableDisabled"
:on-set-tags="setTableTags"
@update:open="setTableDrawerOpen"
@submit="handleSubmitTable"
/>
<DineInBatchModal
:open="isBatchModalOpen"
:form="batchForm"
:is-saving="isSavingBatch"
:area-options="areaOptions"
:seats-options="DINE_IN_SEATS_OPTIONS"
:preview-codes="batchPreviewCodes"
:on-set-area-id="setBatchAreaId"
:on-set-code-prefix="setBatchCodePrefix"
:on-set-start-number="setBatchStartNumber"
:on-set-count="setBatchCount"
:on-set-seats="setBatchSeats"
@update:open="setBatchModalOpen"
@submit="handleSubmitBatch"
/>
<CopyToStoresModal
v-model:open="isCopyModalOpen"
title="复制堂食设置到其他门店"
confirm-text="确认复制"
:copy-candidates="copyCandidates"
:target-store-ids="copyTargetStoreIds"
:is-all-checked="isCopyAllChecked"
:is-indeterminate="isCopyIndeterminate"
:is-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
@check-all="handleCopyCheckAll"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,54 @@
/* 文件职责:堂食管理区域区块样式。 */
.page-store-dinein {
.dinein-area-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.dinein-area-pill {
padding: 6px 16px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 20px;
transition: all 0.2s ease;
}
.dinein-area-pill:hover {
color: #1677ff;
border-color: #1677ff;
}
.dinein-area-pill.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.dinein-area-info {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: #f8f9fb;
border-radius: 8px;
}
.dinein-area-description {
flex: 1;
min-width: 0;
font-size: 13px;
color: #4b5563;
}
.dinein-area-actions {
display: inline-flex;
gap: 4px;
align-items: center;
}
}

View File

@@ -0,0 +1,40 @@
/* 文件职责:堂食管理页面基础骨架与通用样式。 */
.page-store-dinein {
max-width: 980px;
.dinein-card {
margin-bottom: 16px;
overflow: hidden;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
.ant-card-head {
min-height: 52px;
padding: 0 18px;
background: #f8f9fb;
border-bottom: 1px solid #f3f4f6;
}
.ant-card-head-title {
padding: 12px 0;
}
.ant-card-extra {
padding: 12px 0;
}
.ant-card-body {
padding: 16px 18px;
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.dinein-number-input {
width: 92px;
}
}

View File

@@ -0,0 +1,123 @@
/* 文件职责:堂食管理抽屉与批量弹窗样式。 */
.dinein-area-drawer-wrap,
.dinein-table-drawer-wrap {
.ant-drawer-body {
padding: 16px 20px 90px;
}
.ant-drawer-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
}
.drawer-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
}
.drawer-form-block {
margin-bottom: 14px;
}
.drawer-form-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.drawer-form-label.required::before {
margin-right: 4px;
color: #ef4444;
content: '*';
}
.drawer-input-with-unit {
display: flex;
gap: 6px;
align-items: center;
}
.drawer-input {
width: 88px;
}
.drawer-form-hint {
font-size: 12px;
color: #9ca3af;
}
.drawer-switch-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.drawer-status-preview {
display: flex;
flex-direction: column;
gap: 6px;
}
.drawer-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.dinein-batch-modal-wrap {
.ant-modal-content {
overflow: hidden;
border-radius: 12px;
}
.ant-modal-body {
padding-top: 18px;
}
}
.batch-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.batch-form-item.full {
grid-column: span 2;
}
.batch-form-item label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
}
.batch-input-number {
width: 100%;
}
.batch-preview-wrap {
padding: 12px;
margin-top: 16px;
background: #f8f9fb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.batch-preview-title {
margin-bottom: 8px;
font-size: 12px;
color: #9ca3af;
}
.batch-preview-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}

View File

@@ -0,0 +1,7 @@
/* 文件职责:堂食管理页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './area.less';
@import './table.less';
@import './settings.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -0,0 +1,44 @@
/* 文件职责:堂食管理页面响应式规则。 */
.page-store-dinein {
@media (max-width: 992px) {
.dinein-table-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.dinein-area-info {
flex-direction: column;
align-items: flex-start;
}
.dinein-table-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.dinein-form-row {
flex-direction: column;
align-items: flex-start;
}
.dinein-form-label {
width: auto;
}
}
}
@media (max-width: 640px) {
.drawer-form-grid {
grid-template-columns: 1fr;
gap: 0;
}
.batch-form-grid {
grid-template-columns: 1fr;
}
.batch-form-item.full {
grid-column: span 1;
}
}

View File

@@ -0,0 +1,50 @@
/* 文件职责:堂食管理基础设置区块样式。 */
.page-store-dinein {
.dinein-form-row {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.dinein-form-row:last-of-type {
border-bottom: none;
}
.dinein-form-label {
flex-shrink: 0;
width: 120px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
}
.dinein-form-control {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.dinein-form-unit {
font-size: 12px;
color: #4b5563;
}
.dinein-form-hint {
font-size: 12px;
color: #9ca3af;
}
.dinein-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid #f3f4f6;
}
}

View File

@@ -0,0 +1,109 @@
/* 文件职责:堂食管理桌位区块样式。 */
.page-store-dinein {
.dinein-table-header-actions {
display: inline-flex;
gap: 8px;
align-items: center;
}
.dinein-table-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.dinein-table-card {
padding: 14px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
transition: all 0.2s ease;
}
.dinein-table-card:hover {
box-shadow: 0 6px 20px rgb(15 23 42 / 10%);
transform: translateY(-1px);
}
.dinein-table-card.disabled {
opacity: 0.6;
}
.dinein-table-code {
margin-bottom: 6px;
font-size: 22px;
font-weight: 700;
line-height: 1;
color: #1a1a2e;
}
.dinein-table-seat {
margin-bottom: 8px;
font-size: 13px;
color: #4b5563;
}
.dinein-table-status {
display: inline-flex;
gap: 5px;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
}
.dinein-table-status .status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.dinein-table-status.status-free {
color: #22c55e;
}
.dinein-table-status.status-free .status-dot {
background: #22c55e;
}
.dinein-table-status.status-dining {
color: #f59e0b;
}
.dinein-table-status.status-dining .status-dot {
background: #f59e0b;
}
.dinein-table-status.status-reserved {
color: #1677ff;
}
.dinein-table-status.status-reserved .status-dot {
background: #1677ff;
}
.dinein-table-status.status-disabled {
color: #9ca3af;
}
.dinein-table-status.status-disabled .status-dot {
background: #9ca3af;
}
.dinein-table-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 24px;
margin-bottom: 10px;
}
.dinein-table-footer {
display: flex;
gap: 4px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid #f3f4f6;
}
}

View File

@@ -0,0 +1,57 @@
/**
* 文件职责:堂食管理页面类型定义。
* 1. 声明区域、桌位、批量生成表单态。
* 2. 声明页面快照与选择项类型。
*/
import type {
DineInAreaDto,
DineInBasicSettingsDto,
DineInTableDto,
DineInTableStatus,
} from '#/api/store-dinein';
export type DineInAreaDrawerMode = 'create' | 'edit';
export type DineInTableDrawerMode = 'create' | 'edit';
export interface DineInAreaFormState {
description: string;
id: string;
name: string;
sort: number;
}
export interface DineInTableFormState {
areaId: string;
code: string;
id: string;
isDisabled: boolean;
seats: number;
sourceStatus: DineInTableStatus;
tags: string[];
}
export interface DineInBatchFormState {
areaId: string;
codePrefix: string;
count: number;
seats: number;
startNumber: number;
}
export interface DineInSettingsSnapshot {
areas: DineInAreaDto[];
basicSettings: DineInBasicSettingsDto;
tables: DineInTableDto[];
}
export interface DineInSeatsOption {
label: string;
value: number;
}
export interface DineInStatusOption {
className: string;
color: string;
label: string;
value: DineInTableStatus;
}