feat: 完成营业时间模块拆分并补充页面注释规范

This commit is contained in:
2026-02-16 09:59:44 +08:00
parent 4be997df63
commit 14857549ba
31 changed files with 3726 additions and 1 deletions

View File

@@ -0,0 +1,127 @@
import { requestClient } from '#/api/request';
// ========== 枚举 ==========
/** 时段类型 */
export enum SlotType {
/** 营业 */
Business = 1,
/** 配送 */
Delivery = 2,
/** 自提 */
Pickup = 3,
}
/** 特殊日期类型 */
export enum HolidayType {
/** 休息 */
Closed = 1,
/** 特殊营业 */
Special = 2,
}
// ========== DTO ==========
/** 时段 */
export interface TimeSlotDto {
id: string;
/** 时段类型 */
type: SlotType;
/** 开始时间 HH:mm */
startTime: string;
/** 结束时间 HH:mm */
endTime: string;
/** 容量上限(配送类型) */
capacity?: number;
/** 备注 */
remark?: string;
}
/** 每日营业时间 */
export interface DayHoursDto {
/** 星期几 0=周一 6=周日 */
dayOfWeek: number;
/** 是否营业 */
isOpen: boolean;
/** 时段列表 */
slots: TimeSlotDto[];
}
/** 特殊日期 */
export interface HolidayDto {
id: string;
/** 开始日期 */
startDate: string;
/** 结束日期(单日时与 startDate 相同) */
endDate: string;
/** 类型 */
type: HolidayType;
/** 营业开始时间(特殊营业) */
startTime?: string;
/** 营业结束时间(特殊营业) */
endTime?: string;
/** 原因 */
reason: string;
/** 备注 */
remark?: string;
}
/** 门店营业时间聚合 */
export interface StoreHoursDto {
storeId: string;
weeklyHours: DayHoursDto[];
holidays: HolidayDto[];
}
/** 保存每周时段参数 */
export interface SaveWeeklyHoursParams {
storeId: string;
weeklyHours: DayHoursDto[];
}
/** 保存特殊日期参数 */
export interface SaveHolidayParams {
storeId: string;
holiday: Omit<HolidayDto, 'id'> & { id?: string };
}
/** 复制营业时间参数 */
export interface CopyStoreHoursParams {
/** 源门店ID */
sourceStoreId: string;
/** 目标门店ID列表 */
targetStoreIds: string[];
/** 是否包含每周营业时间 */
includeWeeklyHours?: boolean;
/** 是否包含特殊日期 */
includeHolidays?: boolean;
}
// ========== API ==========
/** 获取门店营业时间 */
export async function getStoreHoursApi(storeId: string) {
return requestClient.get<StoreHoursDto>('/store/hours', {
params: { storeId },
});
}
/** 保存每周营业时间 */
export async function saveWeeklyHoursApi(data: SaveWeeklyHoursParams) {
return requestClient.post('/store/hours/weekly', data);
}
/** 保存特殊日期 */
export async function saveHolidayApi(data: SaveHolidayParams) {
return requestClient.post('/store/hours/holiday', data);
}
/** 删除特殊日期 */
export async function deleteHolidayApi(id: string) {
return requestClient.post('/store/hours/holiday/delete', { id });
}
/** 复制营业时间到其他门店 */
export async function copyStoreHoursApi(data: CopyStoreHoursParams) {
return requestClient.post('/store/hours/copy', data);
}

View File

@@ -1,4 +1,5 @@
// Mock 数据入口,仅在开发环境下使用
import './store';
import './store-hours';
console.warn('[Mock] Mock 数据已启用');

View File

@@ -0,0 +1,417 @@
import Mock from 'mockjs';
const Random = Mock.Random;
/** mockjs 请求回调参数 */
interface MockRequestOptions {
url: string;
type: string;
body: null | string;
}
interface TimeSlotMock {
id: string;
type: number;
startTime: string;
endTime: string;
capacity?: number;
remark?: string;
}
interface DayHoursMock {
dayOfWeek: number;
isOpen: boolean;
slots: TimeSlotMock[];
}
interface HolidayMock {
id: string;
startDate: string;
endDate: string;
type: number;
startTime?: string;
endTime?: string;
reason: string;
remark?: string;
}
interface StoreHoursState {
holidays: HolidayMock[];
weeklyHours: DayHoursMock[];
}
function parseUrlParams(url: string) {
const parsed = new URL(url, 'http://localhost');
const params: Record<string, string> = {};
parsed.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
}
function parseBody(options: MockRequestOptions) {
if (!options.body) return {};
try {
return JSON.parse(options.body);
} catch (error) {
console.error('[mock-store-hours] parseBody error:', error);
return {};
}
}
function normalizeDate(date?: string) {
if (!date) return '';
return String(date).slice(0, 10);
}
function normalizeTime(time?: string) {
if (!time) return '';
const matched = /(\d{2}:\d{2})/.exec(time);
return matched?.[1] ?? '';
}
function sortSlots(slots: TimeSlotMock[]) {
return [...slots].toSorted((a, b) => {
const startA = a.startTime;
const startB = b.startTime;
if (startA !== startB) return startA.localeCompare(startB);
return a.type - b.type;
});
}
function sortHolidays(holidays: HolidayMock[]) {
return [...holidays].toSorted((a, b) => {
const dateCompare = a.startDate.localeCompare(b.startDate);
if (dateCompare !== 0) return dateCompare;
return a.id.localeCompare(b.id);
});
}
function cloneWeeklyHours(weeklyHours: DayHoursMock[]) {
return weeklyHours.map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
function cloneHolidays(holidays: HolidayMock[]) {
return holidays.map((holiday) => ({ ...holiday }));
}
function createDefaultWeeklyHours(): DayHoursMock[] {
const weekdays = [
{
dayOfWeek: 0,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 1,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 2,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 3,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 4,
bizEnd: '23:00',
delEnd: '22:30',
delCap: 80,
pickEnd: '22:00',
},
{
dayOfWeek: 5,
bizEnd: '23:00',
delEnd: '22:30',
delCap: 80,
pickEnd: '22:00',
},
];
const result = weekdays.map((day) => ({
dayOfWeek: day.dayOfWeek,
isOpen: true,
slots: [
{ id: Random.guid(), type: 1, startTime: '09:00', endTime: day.bizEnd },
{
id: Random.guid(),
type: 2,
startTime: '10:00',
endTime: day.delEnd,
capacity: day.delCap,
},
{ id: Random.guid(), type: 3, startTime: '09:00', endTime: day.pickEnd },
],
}));
result.push({
dayOfWeek: 6,
isOpen: true,
slots: [
{ id: Random.guid(), type: 1, startTime: '10:00', endTime: '22:00' },
{
id: Random.guid(),
type: 2,
startTime: '10:30',
endTime: '21:30',
capacity: 60,
},
],
});
return result;
}
function createDefaultHolidays(): HolidayMock[] {
return [
{
id: Random.guid(),
startDate: '2026-02-17',
endDate: '2026-02-19',
type: 1,
reason: '春节假期',
},
{
id: Random.guid(),
startDate: '2026-04-05',
endDate: '2026-04-05',
type: 1,
reason: '清明节',
},
{
id: Random.guid(),
startDate: '2026-02-14',
endDate: '2026-02-14',
type: 2,
startTime: '09:00',
endTime: '23:30',
reason: '情人节延长营业',
},
{
id: Random.guid(),
startDate: '2026-05-01',
endDate: '2026-05-01',
type: 2,
startTime: '10:00',
endTime: '20:00',
reason: '劳动节缩短营业',
},
];
}
function normalizeWeeklyHoursInput(list: any): DayHoursMock[] {
const dayMap = new Map<number, DayHoursMock>();
if (Array.isArray(list)) {
for (const item of list) {
const dayOfWeek = Number(item?.dayOfWeek);
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6)
continue;
const slots: TimeSlotMock[] = Array.isArray(item?.slots)
? item.slots.map((slot: any) => ({
id: String(slot?.id || Random.guid()),
type: Number(slot?.type) || 1,
startTime: normalizeTime(slot?.startTime) || '09:00',
endTime: normalizeTime(slot?.endTime) || '22:00',
capacity:
Number(slot?.type) === 2 && slot?.capacity !== undefined
? Number(slot.capacity)
: undefined,
remark: slot?.remark || undefined,
}))
: [];
dayMap.set(dayOfWeek, {
dayOfWeek,
isOpen: Boolean(item?.isOpen),
slots: sortSlots(slots),
});
}
}
return Array.from({ length: 7 }).map((_, dayOfWeek) => {
return (
dayMap.get(dayOfWeek) ?? {
dayOfWeek,
isOpen: false,
slots: [],
}
);
});
}
function normalizeHolidayInput(holiday: any): HolidayMock {
const type = Number(holiday?.type) === 2 ? 2 : 1;
return {
id: String(holiday?.id || Random.guid()),
startDate:
normalizeDate(holiday?.startDate) || normalizeDate(holiday?.date),
endDate:
normalizeDate(holiday?.endDate) ||
normalizeDate(holiday?.startDate) ||
normalizeDate(holiday?.date),
type,
startTime:
type === 2 ? normalizeTime(holiday?.startTime) || undefined : undefined,
endTime:
type === 2 ? normalizeTime(holiday?.endTime) || undefined : undefined,
reason: holiday?.reason || '',
remark: holiday?.remark || undefined,
};
}
const storeHoursMap = new Map<string, StoreHoursState>();
function ensureStoreState(storeId = '') {
const key = storeId || 'default';
let state = storeHoursMap.get(key);
if (!state) {
state = {
weeklyHours: createDefaultWeeklyHours(),
holidays: createDefaultHolidays(),
};
storeHoursMap.set(key, state);
}
return state;
}
// 获取门店营业时间
Mock.mock(/\/store\/hours(?:\?|$)/, 'get', (options: MockRequestOptions) => {
const params = parseUrlParams(options.url);
const storeId = params.storeId || '';
const state = ensureStoreState(storeId);
return {
code: 200,
data: {
storeId,
weeklyHours: cloneWeeklyHours(state.weeklyHours),
holidays: cloneHolidays(state.holidays),
},
};
});
// 保存每周营业时间
Mock.mock(/\/store\/hours\/weekly/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
const state = ensureStoreState(storeId);
state.weeklyHours = normalizeWeeklyHoursInput(body.weeklyHours);
return { code: 200, data: null };
});
// 删除特殊日期
Mock.mock(
/\/store\/hours\/holiday\/delete/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const holidayId = String(body.id || '');
if (!holidayId) return { code: 200, data: null };
for (const [, state] of storeHoursMap) {
const index = state.holidays.findIndex(
(holiday) => holiday.id === holidayId,
);
if (index !== -1) {
state.holidays.splice(index, 1);
break;
}
}
return { code: 200, data: null };
},
);
// 新增 / 编辑特殊日期
Mock.mock(
/\/store\/hours\/holiday(?!\/delete)/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
const state = ensureStoreState(storeId);
const incomingHoliday = normalizeHolidayInput(body.holiday);
const existingIndex = state.holidays.findIndex(
(item) => item.id === incomingHoliday.id,
);
if (existingIndex === -1) {
state.holidays.push(incomingHoliday);
} else {
state.holidays[existingIndex] = incomingHoliday;
}
state.holidays = sortHolidays(state.holidays);
return {
code: 200,
data: { ...incomingHoliday },
};
},
);
// 复制营业时间
Mock.mock(/\/store\/hours\/copy/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const sourceStoreId = String(body.sourceStoreId || '');
const targetStoreIds: string[] = Array.isArray(body.targetStoreIds)
? body.targetStoreIds.map(String).filter(Boolean)
: [];
if (!sourceStoreId || targetStoreIds.length === 0) {
return {
code: 200,
data: { copiedCount: 0 },
};
}
const includeWeeklyHours = body.includeWeeklyHours !== false;
const includeHolidays = body.includeHolidays !== false;
const sourceState = ensureStoreState(sourceStoreId);
const uniqueTargets = [...new Set<string>(targetStoreIds)].filter(
(id) => id !== sourceStoreId,
);
for (const targetId of uniqueTargets) {
const targetState = ensureStoreState(targetId);
if (includeWeeklyHours) {
targetState.weeklyHours = cloneWeeklyHours(sourceState.weeklyHours);
}
if (includeHolidays) {
targetState.holidays = cloneHolidays(sourceState.holidays).map(
(holiday) => ({
...holiday,
id: Random.guid(),
}),
);
}
}
return {
code: 200,
data: {
copiedCount: uniqueTargets.length,
includeHolidays,
includeWeeklyHours,
},
};
});

View File

@@ -19,6 +19,15 @@ const routes: RouteRecordRaw[] = [
title: '门店列表',
},
},
{
name: 'StoreHours',
path: '/store/hours',
component: () => import('#/views/store/hours/index.vue'),
meta: {
icon: 'lucide:clock',
title: '营业时间',
},
},
],
},
];

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import type {
AddDaysMode,
AddSlotFormState,
DayName,
SlotTypeOption,
} from '../types';
import type { SlotType } from '#/api/store-hours';
import { Button, Drawer, Textarea } from 'ant-design-vue';
interface Props {
addSlotForm: AddSlotFormState;
dayNames: DayName[];
getSlotTypePillClass: (type: SlotType) => string;
isAddDaySelected: (dayOfWeek: number) => boolean;
isWeeklySubmitting: boolean;
onSetCapacity: (value: number) => void;
onSetEndTime: (value: string) => void;
onSetRemark: (value: string) => void;
onSetStartTime: (value: string) => void;
onSetType: (type: SlotType) => void;
open: boolean;
slotTypeDelivery: SlotType;
slotTypeOptions: SlotTypeOption[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'quickSelect', mode: AddDaysMode): void;
(event: 'submit'): void;
(event: 'toggleDay', dayOfWeek: number): void;
(event: 'update:open', value: boolean): void;
}>();
function getInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
:open="props.open"
title="添加时段"
:width="500"
:mask-closable="false"
:body-style="{ paddingBottom: '88px' }"
@update:open="(value) => emit('update:open', value)"
>
<div class="form-block">
<label class="form-label required">时段类型</label>
<div class="type-pill-group">
<button
v-for="item in props.slotTypeOptions"
:key="item.value"
type="button"
class="type-pill"
:class="[
props.getSlotTypePillClass(item.value),
{ active: props.addSlotForm.type === item.value },
]"
@click="props.onSetType(item.value)"
>
{{ item.label }}
</button>
</div>
</div>
<div class="form-block">
<label class="form-label required">适用星期</label>
<div class="day-pill-group">
<button
v-for="(day, index) in props.dayNames"
:key="day.label"
type="button"
class="day-pill"
:class="{ selected: props.isAddDaySelected(index) }"
@click="emit('toggleDay', index)"
>
{{ day.label }}
</button>
</div>
<div class="quick-actions">
<button
type="button"
class="quick-btn"
@click="emit('quickSelect', 'all')"
>
全选
</button>
<button
type="button"
class="quick-btn"
@click="emit('quickSelect', 'weekday')"
>
工作日
</button>
<button
type="button"
class="quick-btn"
@click="emit('quickSelect', 'weekend')"
>
周末
</button>
</div>
</div>
<div class="time-grid">
<div class="form-block">
<label class="form-label required">开始时间</label>
<input
:value="props.addSlotForm.startTime"
type="time"
class="native-input"
@input="(event) => props.onSetStartTime(getInputValue(event))"
/>
</div>
<div class="form-block">
<label class="form-label required">结束时间</label>
<input
:value="props.addSlotForm.endTime"
type="time"
class="native-input"
@input="(event) => props.onSetEndTime(getInputValue(event))"
/>
</div>
</div>
<div
v-if="props.addSlotForm.type === props.slotTypeDelivery"
class="form-block"
>
<label class="form-label">容量上限</label>
<div class="capacity-row">
<input
:value="props.addSlotForm.capacity"
type="number"
class="native-input capacity-input"
min="1"
step="1"
@input="
(event) => {
const value = Number(getInputValue(event));
props.onSetCapacity(Number.isFinite(value) ? value : 0);
}
"
/>
<span>/小时</span>
</div>
</div>
<div class="form-block">
<label class="form-label">备注</label>
<Textarea
:value="props.addSlotForm.remark"
:rows="2"
placeholder="可选,如:午市高峰时段"
@update:value="props.onSetRemark"
/>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isWeeklySubmitting"
@click="emit('submit')"
>
确认添加
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { StoreListItemDto } from '#/api/store';
import { Alert, Checkbox, Modal } from 'ant-design-vue';
interface Props {
copyCandidates: StoreListItemDto[];
copyTargetStoreIds: string[];
isCopyAllChecked: boolean;
isCopyIndeterminate: boolean;
isCopySubmitting: boolean;
open: boolean;
selectedStoreName: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'checkAll', checked: boolean): void;
(event: 'storeChange', payload: { checked: boolean; storeId: string }): void;
(event: 'submit'): void;
(event: 'toggleStore', payload: { checked: boolean; storeId: string }): void;
(event: 'update:open', value: boolean): void;
}>();
function readChecked(event: { target?: { checked?: boolean } }) {
return Boolean(event.target?.checked);
}
</script>
<template>
<Modal
:open="props.open"
title="复制到其他门店"
:confirm-loading="props.isCopySubmitting"
ok-text="确认复制"
cancel-text="取消"
@ok="emit('submit')"
@update:open="(value) => emit('update:open', value)"
>
<div class="copy-modal-content">
<Alert
message="将覆盖目标门店的现有设置,请谨慎操作"
type="warning"
show-icon
/>
<div class="copy-all-row">
<Checkbox
:checked="props.isCopyAllChecked"
:indeterminate="props.isCopyIndeterminate"
@change="(event) => emit('checkAll', readChecked(event))"
>
全选
</Checkbox>
</div>
<div class="copy-store-list">
<div
v-for="store in props.copyCandidates"
:key="store.id"
class="copy-store-item"
@click="
emit('toggleStore', {
storeId: store.id,
checked: !props.copyTargetStoreIds.includes(store.id),
})
"
>
<Checkbox
:checked="props.copyTargetStoreIds.includes(store.id)"
@click.stop
@change="
(event) =>
emit('storeChange', {
storeId: store.id,
checked: readChecked(event),
})
"
/>
<div class="copy-store-info">
<div class="copy-store-name">{{ store.name }}</div>
<div class="copy-store-address">{{ store.address || '--' }}</div>
</div>
</div>
</div>
<div class="copy-source-tip">
来源门店{{ props.selectedStoreName || '--' }}
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { DayEditFormState, SlotTypeOption } from '../types';
import type { SlotType } from '#/api/store-hours';
import { Button, Drawer, Select, Switch } from 'ant-design-vue';
interface Props {
dayEditForm: DayEditFormState;
isWeeklySubmitting: boolean;
onSetDayOpen: (value: boolean) => void;
onSetSlotCapacity: (slotId: string, value: number) => void;
onSetSlotEndTime: (slotId: string, value: string) => void;
onSetSlotStartTime: (slotId: string, value: string) => void;
onSetSlotType: (slotId: string, value: SlotType) => void;
open: boolean;
slotTypeDelivery: SlotType;
slotTypeOptions: SlotTypeOption[];
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'addSlotRow'): void;
(event: 'removeSlot', slotId: string): void;
(event: 'save'): void;
(event: 'update:open', value: boolean): void;
}>();
function getInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
:open="props.open"
:title="props.title"
:width="560"
:mask-closable="false"
:body-style="{ paddingBottom: '88px' }"
@update:open="(value) => emit('update:open', value)"
>
<div class="day-open-row">
<Switch
:checked="props.dayEditForm.isOpen"
@update:checked="(checked) => props.onSetDayOpen(Boolean(checked))"
/>
<span>今日营业</span>
<span v-if="!props.dayEditForm.isOpen" class="day-close-hint">
该日休息不接单
</span>
</div>
<div
class="slot-edit-list"
:class="{ disabled: !props.dayEditForm.isOpen }"
>
<div
v-for="slot in props.dayEditForm.slots"
:key="slot.id"
class="slot-edit-card"
>
<div class="slot-edit-head">
<Select
:value="slot.type"
class="slot-type-select"
:options="props.slotTypeOptions"
@update:value="
(value) => props.onSetSlotType(slot.id, Number(value) as SlotType)
"
/>
<Button
type="text"
danger
size="small"
@click="emit('removeSlot', slot.id)"
>
删除
</Button>
</div>
<div class="time-grid">
<div class="form-block">
<label class="form-label required">开始</label>
<input
:value="slot.startTime"
type="time"
class="native-input"
@input="
(event) =>
props.onSetSlotStartTime(slot.id, getInputValue(event))
"
/>
</div>
<div class="form-block">
<label class="form-label required">结束</label>
<input
:value="slot.endTime"
type="time"
class="native-input"
@input="
(event) => props.onSetSlotEndTime(slot.id, getInputValue(event))
"
/>
</div>
</div>
<div v-if="slot.type === props.slotTypeDelivery" class="form-block">
<label class="form-label">容量</label>
<div class="capacity-row">
<input
:value="slot.capacity"
type="number"
class="native-input capacity-input"
min="1"
step="1"
@input="
(event) => {
const value = Number(getInputValue(event));
props.onSetSlotCapacity(
slot.id,
Number.isFinite(value) ? value : 0,
);
}
"
/>
<span>/小时</span>
</div>
</div>
</div>
<button type="button" class="add-dashed-btn" @click="emit('addSlotRow')">
添加时段
</button>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isWeeklySubmitting"
@click="emit('save')"
>
保存修改
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import type { HolidayFormState } from '../types';
import type { HolidayType } from '#/api/store-hours';
import { Button, Drawer, Input, Textarea } from 'ant-design-vue';
interface Props {
holidayForm: HolidayFormState;
holidayTypeClosed: HolidayType;
holidayTypeSpecial: HolidayType;
isHolidaySubmitting: boolean;
onSetDateMode: (mode: 'range' | 'single') => void;
onSetEndTime: (value: string) => void;
onSetRangeEnd: (value: string) => void;
onSetRangeStart: (value: string) => void;
onSetReason: (value: string) => void;
onSetRemark: (value: string) => void;
onSetSingleDate: (value: string) => void;
onSetStartTime: (value: string) => void;
onSetType: (type: HolidayType) => void;
open: boolean;
submitText: string;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function getInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
:open="props.open"
:title="props.title"
:width="500"
:mask-closable="false"
:body-style="{ paddingBottom: '88px' }"
@update:open="(value) => emit('update:open', value)"
>
<div class="form-block">
<label class="form-label required">类型</label>
<div class="type-pill-group">
<button
type="button"
class="type-pill ht-closed"
:class="{
active: props.holidayForm.type === props.holidayTypeClosed,
}"
@click="props.onSetType(props.holidayTypeClosed)"
>
休息
</button>
<button
type="button"
class="type-pill ht-special"
:class="{
active: props.holidayForm.type === props.holidayTypeSpecial,
}"
@click="props.onSetType(props.holidayTypeSpecial)"
>
特殊营业
</button>
</div>
</div>
<div class="form-block">
<label class="form-label required">日期</label>
<div class="date-mode-row">
<button
type="button"
class="date-mode-pill"
:class="{ active: props.holidayForm.dateMode === 'single' }"
@click="props.onSetDateMode('single')"
>
单日
</button>
<button
type="button"
class="date-mode-pill"
:class="{ active: props.holidayForm.dateMode === 'range' }"
@click="props.onSetDateMode('range')"
>
日期范围
</button>
</div>
<template v-if="props.holidayForm.dateMode === 'single'">
<input
:value="props.holidayForm.singleDate"
type="date"
class="native-input"
@input="(event) => props.onSetSingleDate(getInputValue(event))"
/>
</template>
<template v-else>
<div class="date-range-row">
<input
:value="props.holidayForm.rangeStart"
type="date"
class="native-input"
@input="(event) => props.onSetRangeStart(getInputValue(event))"
/>
<span>~</span>
<input
:value="props.holidayForm.rangeEnd"
type="date"
class="native-input"
@input="(event) => props.onSetRangeEnd(getInputValue(event))"
/>
</div>
</template>
</div>
<div
v-if="props.holidayForm.type === props.holidayTypeSpecial"
class="form-block"
>
<label class="form-label">营业时间</label>
<div class="time-grid">
<div class="form-block">
<label class="form-sub-label">开始</label>
<input
:value="props.holidayForm.startTime"
type="time"
class="native-input"
@input="(event) => props.onSetStartTime(getInputValue(event))"
/>
</div>
<div class="form-block">
<label class="form-sub-label">结束</label>
<input
:value="props.holidayForm.endTime"
type="time"
class="native-input"
@input="(event) => props.onSetEndTime(getInputValue(event))"
/>
</div>
</div>
</div>
<div class="form-block">
<label class="form-label required">原因</label>
<Input
:value="props.holidayForm.reason"
placeholder="如:春节假期、情人节延长营业"
@update:value="props.onSetReason"
/>
</div>
<div class="form-block">
<label class="form-label">备注</label>
<Textarea
:value="props.holidayForm.remark"
:rows="2"
placeholder="可选"
@update:value="props.onSetRemark"
/>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isHolidaySubmitting"
@click="emit('submit')"
>
{{ props.submitText }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,165 @@
/**
* 添加时段抽屉动作:
* - 表单字段更新
* - 快捷选星期
* - 提交时段并做冲突校验
*/
import type { Ref } from 'vue';
import type { AddDaysMode, AddSlotFormState, DayName } from '../../types';
import type { DayHoursDto, TimeSlotDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import { SlotType } from '#/api/store-hours';
interface CreateAddSlotActionsOptions {
addSlotForm: AddSlotFormState;
cloneWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
createSlotId: () => string;
defaultDayName: DayName;
dayNames: DayName[];
isAddDrawerOpen: Ref<boolean>;
persistWeeklyHours: (
nextWeekly: DayHoursDto[],
successText: string,
) => Promise<boolean>;
selectedStoreId: Ref<string>;
sortSlots: (slots: TimeSlotDto[]) => TimeSlotDto[];
validateDaySlots: (slots: TimeSlotDto[]) => null | string;
validateSlot: (slot: TimeSlotDto) => null | string;
weeklyHours: Ref<DayHoursDto[]>;
}
export function createAddSlotActions(options: CreateAddSlotActionsOptions) {
// 1. 表单字段 setter。
function setAddSlotType(type: SlotType) {
options.addSlotForm.type = type;
}
function setAddSlotStartTime(value: string) {
options.addSlotForm.startTime = value;
}
function setAddSlotEndTime(value: string) {
options.addSlotForm.endTime = value;
}
function setAddSlotCapacity(value: number) {
options.addSlotForm.capacity = Math.max(0, Number(value) || 0);
}
function setAddSlotRemark(value: string) {
options.addSlotForm.remark = value;
}
// 2. 抽屉初始化与星期选择。
function resetAddSlotForm() {
options.addSlotForm.type = SlotType.Business;
options.addSlotForm.selectedDays = [0, 1, 2, 3, 4];
options.addSlotForm.startTime = '09:00';
options.addSlotForm.endTime = '22:00';
options.addSlotForm.capacity = 50;
options.addSlotForm.remark = '';
}
function openAddSlotDrawer() {
resetAddSlotForm();
options.isAddDrawerOpen.value = true;
}
function isAddDaySelected(dayOfWeek: number) {
return options.addSlotForm.selectedDays.includes(dayOfWeek);
}
function toggleAddDay(dayOfWeek: number) {
const existed = options.addSlotForm.selectedDays.includes(dayOfWeek);
options.addSlotForm.selectedDays = existed
? options.addSlotForm.selectedDays.filter((item) => item !== dayOfWeek)
: [...options.addSlotForm.selectedDays, dayOfWeek].toSorted(
(a, b) => a - b,
);
}
function quickSelectAddDays(mode: AddDaysMode) {
if (mode === 'all') {
options.addSlotForm.selectedDays = [0, 1, 2, 3, 4, 5, 6];
return;
}
if (mode === 'weekday') {
options.addSlotForm.selectedDays = [0, 1, 2, 3, 4];
return;
}
options.addSlotForm.selectedDays = [5, 6];
}
// 3. 提交前校验并持久化。
async function handleAddSlotSubmit() {
if (!options.selectedStoreId.value) return;
if (options.addSlotForm.selectedDays.length === 0) {
message.error('请至少选择一个适用星期');
return;
}
const nextType = Number(options.addSlotForm.type) as SlotType;
const slotTemplate: TimeSlotDto = {
id: options.createSlotId(),
type: nextType,
startTime: options.addSlotForm.startTime,
endTime: options.addSlotForm.endTime,
capacity:
nextType === SlotType.Delivery
? Number(options.addSlotForm.capacity)
: undefined,
remark: options.addSlotForm.remark.trim() || undefined,
};
const slotError = options.validateSlot(slotTemplate);
if (slotError) {
message.error(slotError);
return;
}
const nextWeekly = options.cloneWeeklyHours(options.weeklyHours.value);
for (const dayOfWeek of options.addSlotForm.selectedDays) {
const day = nextWeekly[dayOfWeek];
if (!day) continue;
day.isOpen = true;
day.slots = options.sortSlots([
...day.slots,
{
...slotTemplate,
id: options.createSlotId(),
},
]);
const dayError = options.validateDaySlots(day.slots);
if (dayError) {
const dayMeta = options.dayNames[dayOfWeek] ?? options.defaultDayName;
message.error(`${dayMeta.label}${dayError}`);
return;
}
}
const saved = await options.persistWeeklyHours(nextWeekly, '添加时段成功');
if (saved) {
options.isAddDrawerOpen.value = false;
}
}
return {
handleAddSlotSubmit,
isAddDaySelected,
openAddSlotDrawer,
quickSelectAddDays,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
toggleAddDay,
};
}

View File

@@ -0,0 +1,79 @@
/**
* 营业时间页面常量集合:
* - 星期维度
* - 颜色与文案映射
* - 下拉选项
*/
import type { DayName, SlotTypeOption } from '../../types';
import { HolidayType, SlotType } from '#/api/store-hours';
export const DAY_NAMES: DayName[] = [
{ label: '周一', labelEn: 'Monday' },
{ label: '周二', labelEn: 'Tuesday' },
{ label: '周三', labelEn: 'Wednesday' },
{ label: '周四', labelEn: 'Thursday' },
{ label: '周五', labelEn: 'Friday' },
{ label: '周六', labelEn: 'Saturday' },
{ label: '周日', labelEn: 'Sunday' },
];
export const DEFAULT_DAY_NAME: DayName = {
label: '周一',
labelEn: 'Monday',
};
export const DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export const SLOT_COLORS: Record<
number,
{ bg: string; border: string; dot: string; text: string }
> = {
[SlotType.Business]: {
bg: '#f6ffed',
border: '#b7eb8f',
dot: '#b7eb8f',
text: '#389e0d',
},
[SlotType.Delivery]: {
bg: '#e6f7ff',
border: '#91d5ff',
dot: '#91d5ff',
text: '#1890ff',
},
[SlotType.Pickup]: {
bg: '#fff7e6',
border: '#ffd591',
dot: '#ffd591',
text: '#d46b08',
},
};
export const HOLIDAY_COLORS: Record<number, { bg: string; text: string }> = {
[HolidayType.Closed]: { bg: '#fff2f0', text: '#ef4444' },
[HolidayType.Special]: { bg: '#fff7e6', text: '#f59e0b' },
};
export const DEFAULT_SLOT_COLOR = {
bg: '#f6ffed',
border: '#b7eb8f',
dot: '#b7eb8f',
text: '#389e0d',
};
export const DEFAULT_HOLIDAY_COLOR = {
bg: '#fff2f0',
text: '#ef4444',
};
export const SLOT_TYPE_LABELS: Record<number, string> = {
[SlotType.Business]: '营业',
[SlotType.Delivery]: '配送',
[SlotType.Pickup]: '自提',
};
export const SLOT_TYPE_OPTIONS: SlotTypeOption[] = [
{ label: '营业', value: SlotType.Business },
{ label: '配送', value: SlotType.Delivery },
{ label: '自提', value: SlotType.Pickup },
];

View File

@@ -0,0 +1,91 @@
/**
* 复制到其他门店动作:
* - 选择目标门店
* - 全选/单选联动
* - 提交复制请求
*/
import type { ComputedRef, Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import { message } from 'ant-design-vue';
import { copyStoreHoursApi } from '#/api/store-hours';
interface CreateCopyActionsOptions {
copyCandidates: ComputedRef<StoreListItemDto[]>;
copyTargetStoreIds: Ref<string[]>;
isCopyModalOpen: Ref<boolean>;
isCopySubmitting: Ref<boolean>;
selectedStoreId: Ref<string>;
}
export function createCopyActions(options: CreateCopyActionsOptions) {
// 1. 弹窗与选中状态维护。
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 toggleCopyAll(checked: boolean) {
if (checked) {
options.copyTargetStoreIds.value = options.copyCandidates.value.map(
(store) => store.id,
);
return;
}
options.copyTargetStoreIds.value = [];
}
function handleCopyCheckAll(checked: boolean) {
toggleCopyAll(Boolean(checked));
}
function handleCopyStoreChange(storeId: string, checked: boolean) {
toggleCopyStore(storeId, Boolean(checked));
}
// 2. 调用复制接口并处理结果提示。
async function handleCopySubmit() {
if (!options.selectedStoreId.value) return;
if (options.copyTargetStoreIds.value.length === 0) {
message.error('请至少选择一个目标门店');
return;
}
options.isCopySubmitting.value = true;
try {
await copyStoreHoursApi({
sourceStoreId: options.selectedStoreId.value,
targetStoreIds: options.copyTargetStoreIds.value,
includeWeeklyHours: true,
includeHolidays: true,
});
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,
handleCopyStoreChange,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
};
}

View File

@@ -0,0 +1,89 @@
/**
* 门店与营业时间数据加载动作:
* - 拉取门店列表
* - 按门店拉取营业时间
*/
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import type { DayHoursDto, HolidayDto } from '#/api/store-hours';
import { getStoreListApi } from '#/api/store';
import { getStoreHoursApi } from '#/api/store-hours';
interface CreateDataActionsOptions {
holidays: Ref<HolidayDto[]>;
isHoursLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
normalizeHolidays: (source: HolidayDto[]) => HolidayDto[];
normalizeWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
weeklyHours: Ref<DayHoursDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
// 1. 加载指定门店的营业时间配置。
async function loadStoreHours(storeId: string) {
options.isHoursLoading.value = true;
try {
const currentStoreId = storeId;
const result = await getStoreHoursApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return;
options.weeklyHours.value = options.normalizeWeeklyHours(
result.weeklyHours ?? [],
);
options.holidays.value = options.normalizeHolidays(result.holidays ?? []);
} catch (error) {
console.error(error);
} finally {
options.isHoursLoading.value = false;
}
}
// 2. 加载门店列表并处理默认选中逻辑。
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 = '';
options.weeklyHours.value = options.normalizeWeeklyHours([]);
options.holidays.value = [];
return;
}
const hasSelected = options.stores.value.some(
(store) => store.id === options.selectedStoreId.value,
);
if (!hasSelected) {
const firstStore = options.stores.value[0];
if (firstStore) {
options.selectedStoreId.value = firstStore.id;
}
} else if (options.selectedStoreId.value) {
await loadStoreHours(options.selectedStoreId.value);
}
} catch (error) {
console.error(error);
} finally {
options.isStoreLoading.value = false;
}
}
return {
loadStoreHours,
loadStores,
};
}

View File

@@ -0,0 +1,179 @@
/**
* 单日时段编辑动作:
* - 打开日编辑抽屉
* - 编辑/删除时段行
* - 保存当日配置
*/
import type { ComputedRef, Ref } from 'vue';
import type { DayEditFormState, DayName, EditSlotItem } from '../../types';
import type { DayHoursDto, TimeSlotDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import { SlotType } from '#/api/store-hours';
interface CreateDayEditActionsOptions {
cloneWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
createSlotId: () => string;
currentDayMeta: ComputedRef<DayName>;
dayEditForm: DayEditFormState;
isDayDrawerOpen: Ref<boolean>;
normalizeWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
persistWeeklyHours: (
nextWeekly: DayHoursDto[],
successText: string,
) => Promise<boolean>;
selectedStoreId: Ref<string>;
sortSlots: (slots: TimeSlotDto[]) => TimeSlotDto[];
validateDaySlots: (slots: TimeSlotDto[]) => null | string;
weeklyHours: Ref<DayHoursDto[]>;
}
export function createDayEditActions(options: CreateDayEditActionsOptions) {
// 1. 抽屉初始化与行增删。
function openDayDrawer(dayOfWeek: number) {
const source = options.normalizeWeeklyHours(options.weeklyHours.value)[
dayOfWeek
];
if (!source) return;
options.dayEditForm.dayOfWeek = dayOfWeek;
options.dayEditForm.isOpen = source.isOpen;
options.dayEditForm.slots = source.slots.map(
(slot): EditSlotItem => ({
id: slot.id || options.createSlotId(),
type: Number(slot.type) as SlotType,
startTime: slot.startTime,
endTime: slot.endTime,
capacity:
Number(slot.type) === SlotType.Delivery
? Number(slot.capacity ?? 50)
: undefined,
remark: slot.remark || '',
}),
);
options.isDayDrawerOpen.value = true;
}
function addDaySlotRow() {
options.dayEditForm.slots = [
...options.dayEditForm.slots,
{
id: options.createSlotId(),
type: SlotType.Business,
startTime: '09:00',
endTime: '22:00',
remark: '',
},
];
}
function removeDaySlot(slotId: string) {
options.dayEditForm.slots = options.dayEditForm.slots.filter(
(slot) => slot.id !== slotId,
);
}
// 2. 表单字段 setter。
function setDayEditOpen(value: boolean) {
options.dayEditForm.isOpen = value;
}
function updateDaySlot(
slotId: string,
updater: (slot: EditSlotItem) => void,
) {
const target = options.dayEditForm.slots.find((slot) => slot.id === slotId);
if (!target) return;
updater(target);
}
function setDaySlotType(slotId: string, value: SlotType) {
updateDaySlot(slotId, (slot) => {
slot.type = value;
if (value !== SlotType.Delivery) {
slot.capacity = undefined;
} else if (!slot.capacity || slot.capacity <= 0) {
slot.capacity = 50;
}
});
}
function setDaySlotStartTime(slotId: string, value: string) {
updateDaySlot(slotId, (slot) => {
slot.startTime = value;
});
}
function setDaySlotEndTime(slotId: string, value: string) {
updateDaySlot(slotId, (slot) => {
slot.endTime = value;
});
}
function setDaySlotCapacity(slotId: string, value: number) {
updateDaySlot(slotId, (slot) => {
slot.capacity = Math.max(0, Number(value) || 0);
});
}
// 3. 保存前校验并持久化。
async function handleSaveDay() {
if (!options.selectedStoreId.value) return;
if (options.dayEditForm.isOpen && options.dayEditForm.slots.length === 0) {
message.error('该日营业已开启,至少保留一个时段');
return;
}
const nextSlots: TimeSlotDto[] = options.dayEditForm.slots.map((slot) => {
const slotType = Number(slot.type) as SlotType;
return {
id: slot.id || options.createSlotId(),
type: slotType,
startTime: slot.startTime,
endTime: slot.endTime,
capacity:
slotType === SlotType.Delivery ? Number(slot.capacity) : undefined,
remark: slot.remark?.trim() || undefined,
};
});
if (options.dayEditForm.isOpen) {
const dayError = options.validateDaySlots(nextSlots);
if (dayError) {
message.error(`${options.currentDayMeta.value.label}${dayError}`);
return;
}
}
const nextWeekly = options.cloneWeeklyHours(options.weeklyHours.value);
nextWeekly[options.dayEditForm.dayOfWeek] = {
dayOfWeek: options.dayEditForm.dayOfWeek,
isOpen: options.dayEditForm.isOpen,
slots: options.sortSlots(nextSlots),
};
const saved = await options.persistWeeklyHours(
nextWeekly,
'营业时间已更新',
);
if (saved) {
options.isDayDrawerOpen.value = false;
}
}
return {
addDaySlotRow,
handleSaveDay,
openDayDrawer,
removeDaySlot,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
};
}

View File

@@ -0,0 +1,221 @@
/**
* 营业时间页面纯函数工具集:
* 仅包含格式化、归一化、样式映射、校验,不依赖组件状态。
*/
import type { DayHoursDto, HolidayDto, TimeSlotDto } from '#/api/store-hours';
import { HolidayType, SlotType } from '#/api/store-hours';
import {
DAY_INDEXES,
DEFAULT_HOLIDAY_COLOR,
DEFAULT_SLOT_COLOR,
HOLIDAY_COLORS,
SLOT_COLORS,
SLOT_TYPE_LABELS,
} from './constants';
// 1. 基础格式化工具。
export function createSlotId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function getTodayDate() {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, '0');
const date = String(today.getDate()).padStart(2, '0');
return `${today.getFullYear()}-${month}-${date}`;
}
export function toDateOnly(value?: string) {
if (!value) return '';
return String(value).slice(0, 10);
}
export function toTimeHHmm(value?: string) {
if (!value) return '';
const matched = /(\d{2}:\d{2})/.exec(value);
return matched?.[1] ?? '';
}
export function parseTimeToMinutes(time: string) {
const matched = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
if (!matched) return null;
return Number(matched[1]) * 60 + Number(matched[2]);
}
// 2. 数据归一化与排序。
export function sortSlots(slots: TimeSlotDto[]) {
return [...slots].toSorted((a, b) => {
const startA = parseTimeToMinutes(a.startTime) ?? 0;
const startB = parseTimeToMinutes(b.startTime) ?? 0;
if (startA !== startB) return startA - startB;
return Number(a.type) - Number(b.type);
});
}
export function normalizeSlots(slots: TimeSlotDto[]) {
return sortSlots(
(slots ?? []).map((slot) => {
const slotType = Number(slot.type) as SlotType;
return {
id: slot.id || createSlotId(),
type: SLOT_TYPE_LABELS[slotType] ? slotType : SlotType.Business,
startTime: toTimeHHmm(slot.startTime) || '09:00',
endTime: toTimeHHmm(slot.endTime) || '22:00',
capacity:
slotType === SlotType.Delivery && slot.capacity !== undefined
? Number(slot.capacity)
: undefined,
remark: slot.remark || '',
} as TimeSlotDto;
}),
);
}
export function normalizeWeeklyHours(source: DayHoursDto[]) {
const dayMap = new Map<number, DayHoursDto>();
for (const item of source ?? []) {
const dayOfWeek = Number(item.dayOfWeek);
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6)
continue;
const slots = normalizeSlots(item.slots ?? []);
dayMap.set(dayOfWeek, {
dayOfWeek,
isOpen: typeof item.isOpen === 'boolean' ? item.isOpen : slots.length > 0,
slots,
});
}
return DAY_INDEXES.map((dayOfWeek) => {
return (
dayMap.get(dayOfWeek) ?? {
dayOfWeek,
isOpen: false,
slots: [],
}
);
});
}
export function normalizeHolidays(source: HolidayDto[]) {
return [...(source ?? [])]
.map((holiday) => ({
id: String(holiday.id || createSlotId()),
startDate: toDateOnly(holiday.startDate),
endDate: toDateOnly(holiday.endDate || holiday.startDate),
type:
Number(holiday.type) === HolidayType.Special
? HolidayType.Special
: HolidayType.Closed,
startTime: toTimeHHmm(holiday.startTime),
endTime: toTimeHHmm(holiday.endTime),
reason: holiday.reason || '',
remark: holiday.remark || '',
}))
.toSorted((a, b) => {
const dateCompare = a.startDate.localeCompare(b.startDate);
if (dateCompare !== 0) return dateCompare;
return a.id.localeCompare(b.id);
});
}
export function cloneWeeklyHours(source: DayHoursDto[]) {
return normalizeWeeklyHours(source).map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
// 3. 展示映射工具(标签、颜色、文案)。
export function getSlotTypeLabel(type: SlotType) {
return SLOT_TYPE_LABELS[type] || '未知';
}
export function getSlotStyle(type: SlotType) {
const color = SLOT_COLORS[type] ?? DEFAULT_SLOT_COLOR;
return {
background: color.bg,
borderColor: color.border,
color: color.text,
};
}
export function getHolidayTagStyle(type: HolidayType) {
const color = HOLIDAY_COLORS[type] ?? DEFAULT_HOLIDAY_COLOR;
return {
background: color.bg,
color: color.text,
};
}
export function getLegendDotColor(type: SlotType) {
return (SLOT_COLORS[type] ?? DEFAULT_SLOT_COLOR).dot;
}
export function getHolidayTypeText(type: HolidayType) {
return type === HolidayType.Special ? '特殊营业' : '休息';
}
export function formatHolidayDate(holiday: HolidayDto) {
const start = toDateOnly(holiday.startDate);
const end = toDateOnly(holiday.endDate || holiday.startDate);
if (!end || start === end) return start;
return `${start} ~ ${end}`;
}
export function formatHolidayTime(holiday: HolidayDto) {
if (holiday.type === HolidayType.Closed) return '全天';
const start = toTimeHHmm(holiday.startTime);
const end = toTimeHHmm(holiday.endTime);
return start && end ? `${start} - ${end}` : '--';
}
export function getSlotTypePillClass(type: SlotType) {
if (type === SlotType.Business) return 'tp-biz';
if (type === SlotType.Delivery) return 'tp-del';
return 'tp-pick';
}
// 4. 业务校验工具(单时段与日内冲突)。
export function validateSlot(slot: TimeSlotDto) {
const start = parseTimeToMinutes(slot.startTime);
const end = parseTimeToMinutes(slot.endTime);
if (start === null || end === null) {
return '开始时间或结束时间格式不正确';
}
if (start >= end) {
return '开始时间必须早于结束时间';
}
if (slot.type === SlotType.Delivery) {
const capacity = Number(slot.capacity);
if (!Number.isInteger(capacity) || capacity <= 0) {
return '配送时段容量必须为正整数';
}
}
return null;
}
export function validateDaySlots(slots: TimeSlotDto[]) {
for (const slot of slots) {
const slotError = validateSlot(slot);
if (slotError) return slotError;
}
for (const type of [SlotType.Business, SlotType.Delivery, SlotType.Pickup]) {
const sameTypeSlots = sortSlots(slots.filter((slot) => slot.type === type));
for (let index = 1; index < sameTypeSlots.length; index++) {
const prevSlot = sameTypeSlots[index - 1];
const currentSlot = sameTypeSlots[index];
if (!prevSlot || !currentSlot) continue;
const prevEnd = parseTimeToMinutes(prevSlot.endTime) ?? 0;
const currentStart = parseTimeToMinutes(currentSlot.startTime) ?? 0;
if (currentStart < prevEnd) {
return `${getSlotTypeLabel(type)}时段存在重叠,请调整`;
}
}
}
return null;
}

View File

@@ -0,0 +1,248 @@
/**
* 节假日/特殊营业动作:
* - 抽屉初始化
* - 表单字段维护
* - 新增、编辑、删除
*/
import type { Ref } from 'vue';
import type { HolidayFormState } from '../../types';
import type { HolidayDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import {
deleteHolidayApi,
HolidayType,
saveHolidayApi,
} from '#/api/store-hours';
interface HolidayPayload {
endDate: string;
endTime?: string;
id?: string;
reason: string;
remark?: string;
startDate: string;
startTime?: string;
type: HolidayType;
}
interface CreateHolidayActionsOptions {
deletingHolidayId: Ref<string>;
getTodayDate: () => string;
holidayDrawerMode: Ref<'create' | 'edit'>;
holidayForm: HolidayFormState;
isHolidayDrawerOpen: Ref<boolean>;
isHolidaySubmitting: Ref<boolean>;
loadStoreHours: (storeId: string) => Promise<void>;
parseTimeToMinutes: (time: string) => null | number;
selectedStoreId: Ref<string>;
toDateOnly: (value?: string) => string;
toTimeHHmm: (value?: string) => string;
}
export function createHolidayActions(options: CreateHolidayActionsOptions) {
// 1. 表单字段 setter。
function setHolidayType(value: HolidayType) {
options.holidayForm.type = value;
}
function setHolidayDateMode(mode: 'range' | 'single') {
options.holidayForm.dateMode = mode;
}
function setHolidaySingleDate(value: string) {
options.holidayForm.singleDate = value;
}
function setHolidayRangeStart(value: string) {
options.holidayForm.rangeStart = value;
}
function setHolidayRangeEnd(value: string) {
options.holidayForm.rangeEnd = value;
}
function setHolidayStartTime(value: string) {
options.holidayForm.startTime = value;
}
function setHolidayEndTime(value: string) {
options.holidayForm.endTime = value;
}
function setHolidayReason(value: string) {
options.holidayForm.reason = value;
}
function setHolidayRemark(value: string) {
options.holidayForm.remark = value;
}
// 2. 抽屉初始化与回填。
function resetHolidayForm() {
const today = options.getTodayDate();
options.holidayForm.id = '';
options.holidayForm.type = HolidayType.Closed;
options.holidayForm.dateMode = 'single';
options.holidayForm.singleDate = today;
options.holidayForm.rangeStart = today;
options.holidayForm.rangeEnd = today;
options.holidayForm.startTime = '09:00';
options.holidayForm.endTime = '22:00';
options.holidayForm.reason = '';
options.holidayForm.remark = '';
}
function openHolidayDrawer(mode: 'create' | 'edit', holiday?: HolidayDto) {
options.holidayDrawerMode.value = mode;
if (mode === 'create' || !holiday) {
resetHolidayForm();
options.isHolidayDrawerOpen.value = true;
return;
}
const startDate = options.toDateOnly(holiday.startDate);
const endDate = options.toDateOnly(holiday.endDate || holiday.startDate);
options.holidayForm.id = holiday.id;
options.holidayForm.type = holiday.type;
options.holidayForm.dateMode = startDate === endDate ? 'single' : 'range';
options.holidayForm.singleDate = startDate;
options.holidayForm.rangeStart = startDate;
options.holidayForm.rangeEnd = endDate;
options.holidayForm.startTime =
options.toTimeHHmm(holiday.startTime) || '09:00';
options.holidayForm.endTime =
options.toTimeHHmm(holiday.endTime) || '22:00';
options.holidayForm.reason = holiday.reason || '';
options.holidayForm.remark = holiday.remark || '';
options.isHolidayDrawerOpen.value = true;
}
// 3. 组装提交载荷并进行合法性校验。
function buildHolidayPayload(): HolidayPayload | null {
const reason = options.holidayForm.reason.trim();
if (!reason) {
message.error('请填写原因');
return null;
}
let startDate = '';
let endDate = '';
if (options.holidayForm.dateMode === 'single') {
if (!options.holidayForm.singleDate) {
message.error('请选择日期');
return null;
}
startDate = options.holidayForm.singleDate;
endDate = options.holidayForm.singleDate;
} else {
if (!options.holidayForm.rangeStart || !options.holidayForm.rangeEnd) {
message.error('请完整填写日期范围');
return null;
}
if (options.holidayForm.rangeStart > options.holidayForm.rangeEnd) {
message.error('开始日期不能晚于结束日期');
return null;
}
startDate = options.holidayForm.rangeStart;
endDate = options.holidayForm.rangeEnd;
}
let startTime: string | undefined;
let endTime: string | undefined;
if (options.holidayForm.type === HolidayType.Special) {
const start = options.toTimeHHmm(options.holidayForm.startTime);
const end = options.toTimeHHmm(options.holidayForm.endTime);
const startMinutes = options.parseTimeToMinutes(start);
const endMinutes = options.parseTimeToMinutes(end);
if (
startMinutes === null ||
endMinutes === null ||
startMinutes >= endMinutes
) {
message.error('特殊营业时间不合法');
return null;
}
startTime = start;
endTime = end;
}
return {
id:
options.holidayDrawerMode.value === 'edit'
? options.holidayForm.id || undefined
: undefined,
startDate,
endDate,
type: options.holidayForm.type,
startTime,
endTime,
reason,
remark: options.holidayForm.remark.trim() || undefined,
};
}
// 4. 保存与删除动作。
async function handleHolidaySubmit() {
if (!options.selectedStoreId.value) return;
const payload = buildHolidayPayload();
if (!payload) return;
options.isHolidaySubmitting.value = true;
try {
await saveHolidayApi({
storeId: options.selectedStoreId.value,
holiday: payload,
});
message.success(
options.holidayDrawerMode.value === 'edit'
? '日期配置已更新'
: '日期配置已添加',
);
options.isHolidayDrawerOpen.value = false;
await options.loadStoreHours(options.selectedStoreId.value);
} catch (error) {
console.error(error);
} finally {
options.isHolidaySubmitting.value = false;
}
}
async function handleDeleteHoliday(id: string) {
if (!options.selectedStoreId.value) return;
options.deletingHolidayId.value = id;
try {
await deleteHolidayApi(id);
message.success('已删除');
await options.loadStoreHours(options.selectedStoreId.value);
} catch (error) {
console.error(error);
} finally {
options.deletingHolidayId.value = '';
}
}
return {
handleDeleteHoliday,
handleHolidaySubmit,
openHolidayDrawer,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
};
}

View File

@@ -0,0 +1,47 @@
/**
* 每周营业时间保存动作。
*/
import type { Ref } from 'vue';
import type { DayHoursDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import { saveWeeklyHoursApi } from '#/api/store-hours';
interface CreateWeeklyActionsOptions {
isWeeklySubmitting: Ref<boolean>;
loadStoreHours: (storeId: string) => Promise<void>;
normalizeWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
selectedStoreId: Ref<string>;
}
export function createWeeklyActions(options: CreateWeeklyActionsOptions) {
// 保存后刷新当前门店数据,保证页面展示与服务端一致。
async function persistWeeklyHours(
nextWeekly: DayHoursDto[],
successText: string,
) {
if (!options.selectedStoreId.value) return false;
options.isWeeklySubmitting.value = true;
try {
await saveWeeklyHoursApi({
storeId: options.selectedStoreId.value,
weeklyHours: options.normalizeWeeklyHours(nextWeekly),
});
message.success(successText);
await options.loadStoreHours(options.selectedStoreId.value);
return true;
} catch (error) {
console.error(error);
return false;
} finally {
options.isWeeklySubmitting.value = false;
}
}
return {
persistWeeklyHours,
};
}

View File

@@ -0,0 +1,362 @@
/**
* 营业时间页面主编排:
* 1. 维护页面级响应式状态
* 2. 组装各子域 action加载、每周时段、日期、复制
* 3. 对外暴露视图层可直接消费的状态与方法
*/
import type {
AddSlotFormState,
DayEditFormState,
HolidayFormState,
} from '../types';
import type { StoreListItemDto } from '#/api/store';
import type { DayHoursDto, HolidayDto } from '#/api/store-hours';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { HolidayType, SlotType } from '#/api/store-hours';
import { createAddSlotActions } from './store-hours-page/add-slot-actions';
import {
DAY_NAMES,
DEFAULT_DAY_NAME,
SLOT_TYPE_OPTIONS,
} from './store-hours-page/constants';
import { createCopyActions } from './store-hours-page/copy-actions';
import { createDataActions } from './store-hours-page/data-actions';
import { createDayEditActions } from './store-hours-page/day-edit-actions';
import {
cloneWeeklyHours,
createSlotId,
formatHolidayDate,
formatHolidayTime,
getHolidayTagStyle,
getHolidayTypeText,
getLegendDotColor,
getSlotStyle,
getSlotTypeLabel,
getSlotTypePillClass,
getTodayDate,
normalizeHolidays,
normalizeWeeklyHours,
parseTimeToMinutes,
sortSlots,
toDateOnly,
toTimeHHmm,
validateDaySlots,
validateSlot,
} from './store-hours-page/helpers';
import { createHolidayActions } from './store-hours-page/holiday-actions';
import { createWeeklyActions } from './store-hours-page/weekly-actions';
export function useStoreHoursPage() {
// 1. 页面级 loading / submitting 状态。
const isStoreLoading = ref(false);
const isHoursLoading = ref(false);
const isWeeklySubmitting = ref(false);
const isHolidaySubmitting = ref(false);
const isCopySubmitting = ref(false);
const deletingHolidayId = ref('');
// 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const weeklyHours = ref<DayHoursDto[]>([]);
const holidays = ref<HolidayDto[]>([]);
// 3. 抽屉/弹窗可见性。
const isAddDrawerOpen = ref(false);
const isDayDrawerOpen = ref(false);
const isHolidayDrawerOpen = ref(false);
const isCopyModalOpen = ref(false);
// 4. 各子模块表单状态。
const addSlotForm = reactive<AddSlotFormState>({
type: SlotType.Business,
selectedDays: [0, 1, 2, 3, 4],
startTime: '09:00',
endTime: '22:00',
capacity: 50,
remark: '',
});
const dayEditForm = reactive<DayEditFormState>({
dayOfWeek: 0,
isOpen: true,
slots: [],
});
const holidayDrawerMode = ref<'create' | 'edit'>('create');
const holidayForm = reactive<HolidayFormState>({
id: '',
type: HolidayType.Closed,
dateMode: 'single',
singleDate: '',
rangeStart: '',
rangeEnd: '',
startTime: '09:00',
endTime: '22:00',
reason: '',
remark: '',
});
const copyTargetStoreIds = ref<string[]>([]);
// 5. 页面衍生视图数据。
const storeOptions = computed(() =>
stores.value.map((store) => ({ label: store.name, value: store.id })),
);
const copyCandidates = computed(() =>
stores.value.filter((store) => store.id !== selectedStoreId.value),
);
const selectedStoreName = computed(
() =>
stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'',
);
const weekRows = computed(() =>
normalizeWeeklyHours(weeklyHours.value).map((day, index) => {
const dayMeta = DAY_NAMES[index] ?? DEFAULT_DAY_NAME;
return {
...day,
label: dayMeta.label,
labelEn: dayMeta.labelEn,
slots: sortSlots(day.slots),
};
}),
);
const holidayRows = computed(() => normalizeHolidays(holidays.value));
const currentDayMeta = computed(
() => DAY_NAMES[dayEditForm.dayOfWeek] ?? DEFAULT_DAY_NAME,
);
const holidayDrawerTitle = computed(() =>
holidayDrawerMode.value === 'edit' ? '编辑日期' : '添加日期',
);
const holidaySubmitText = computed(() =>
holidayDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
);
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,
);
// 6. 数据加载域 action。
const { loadStoreHours, loadStores } = createDataActions({
holidays,
isHoursLoading,
isStoreLoading,
normalizeHolidays,
normalizeWeeklyHours,
selectedStoreId,
stores,
weeklyHours,
});
const { persistWeeklyHours } = createWeeklyActions({
isWeeklySubmitting,
loadStoreHours,
normalizeWeeklyHours,
selectedStoreId,
});
// 7. 添加时段域 action。
const {
handleAddSlotSubmit,
isAddDaySelected,
openAddSlotDrawer,
quickSelectAddDays,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
toggleAddDay,
} = createAddSlotActions({
addSlotForm,
cloneWeeklyHours,
createSlotId,
defaultDayName: DEFAULT_DAY_NAME,
dayNames: DAY_NAMES,
isAddDrawerOpen,
persistWeeklyHours,
selectedStoreId,
sortSlots,
validateDaySlots,
validateSlot,
weeklyHours,
});
// 8. 日编辑域 action。
const {
addDaySlotRow,
handleSaveDay,
openDayDrawer,
removeDaySlot,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
} = createDayEditActions({
cloneWeeklyHours,
createSlotId,
currentDayMeta,
dayEditForm,
isDayDrawerOpen,
normalizeWeeklyHours,
persistWeeklyHours,
selectedStoreId,
sortSlots,
validateDaySlots,
weeklyHours,
});
// 9. 节假日域 action。
const {
handleDeleteHoliday,
handleHolidaySubmit,
openHolidayDrawer,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
} = createHolidayActions({
deletingHolidayId,
getTodayDate,
holidayDrawerMode,
holidayForm,
isHolidayDrawerOpen,
isHolidaySubmitting,
loadStoreHours,
parseTimeToMinutes,
selectedStoreId,
toDateOnly,
toTimeHHmm,
});
// 10. 复制域 action。
const {
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
// 11. 门店切换后自动刷新对应营业时间。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
weeklyHours.value = normalizeWeeklyHours([]);
holidays.value = [];
return;
}
await loadStoreHours(storeId);
});
// 12. 页面首屏初始化。
onMounted(loadStores);
return {
DAY_NAMES,
HolidayType,
SLOT_TYPE_OPTIONS,
SlotType,
addDaySlotRow,
addSlotForm,
copyCandidates,
copyTargetStoreIds,
currentDayMeta,
dayEditForm,
deletingHolidayId,
formatHolidayDate,
formatHolidayTime,
getHolidayTagStyle,
getHolidayTypeText,
getLegendDotColor,
getSlotStyle,
getSlotTypeLabel,
getSlotTypePillClass,
handleAddSlotSubmit,
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
handleDeleteHoliday,
handleHolidaySubmit,
handleSaveDay,
holidayDrawerTitle,
holidayForm,
holidayRows,
holidaySubmitText,
isAddDaySelected,
isAddDrawerOpen,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isDayDrawerOpen,
isHolidayDrawerOpen,
isHolidaySubmitting,
isHoursLoading,
isStoreLoading,
isWeeklySubmitting,
openAddSlotDrawer,
openCopyModal,
openDayDrawer,
openHolidayDrawer,
quickSelectAddDays,
removeDaySlot,
selectedStoreId,
selectedStoreName,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
storeOptions,
toggleAddDay,
toggleCopyStore,
weekRows,
};
}

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { Button, Card, Empty, Popconfirm, Select, Spin } from 'ant-design-vue';
import AddSlotDrawer from './components/AddSlotDrawer.vue';
import CopyStoreModal from './components/CopyStoreModal.vue';
import DayEditDrawer from './components/DayEditDrawer.vue';
import HolidayDrawer from './components/HolidayDrawer.vue';
import { useStoreHoursPage } from './composables/useStoreHoursPage';
const {
DAY_NAMES,
HolidayType,
SLOT_TYPE_OPTIONS,
SlotType,
addDaySlotRow,
addSlotForm,
copyCandidates,
copyTargetStoreIds,
currentDayMeta,
dayEditForm,
deletingHolidayId,
formatHolidayDate,
formatHolidayTime,
getHolidayTagStyle,
getHolidayTypeText,
getLegendDotColor,
getSlotStyle,
getSlotTypeLabel,
getSlotTypePillClass,
handleAddSlotSubmit,
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
handleDeleteHoliday,
handleHolidaySubmit,
handleSaveDay,
holidayDrawerTitle,
holidayForm,
holidayRows,
holidaySubmitText,
isAddDaySelected,
isAddDrawerOpen,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isDayDrawerOpen,
isHolidayDrawerOpen,
isHolidaySubmitting,
isHoursLoading,
isStoreLoading,
isWeeklySubmitting,
openAddSlotDrawer,
openCopyModal,
openDayDrawer,
openHolidayDrawer,
quickSelectAddDays,
removeDaySlot,
selectedStoreId,
selectedStoreName,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
storeOptions,
toggleAddDay,
toggleCopyStore,
weekRows,
} = useStoreHoursPage();
</script>
<template>
<Page title="营业时间" content-class="space-y-4 page-hours">
<Card :bordered="false" class="hours-toolbar-card">
<div class="hours-toolbar">
<Select
v-model:value="selectedStoreId"
class="store-selector"
placeholder="请选择门店"
:loading="isStoreLoading"
:options="storeOptions"
:disabled="isStoreLoading || storeOptions.length === 0"
/>
<div class="toolbar-spacer"></div>
<Button
:disabled="!selectedStoreId || copyCandidates.length === 0"
@click="openCopyModal"
>
复制到其他门店
</Button>
</div>
</Card>
<template v-if="storeOptions.length === 0">
<Card :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
</template>
<template v-else>
<Spin :spinning="isHoursLoading">
<Card :bordered="false">
<template #title>
<span class="section-title">每周营业时间</span>
</template>
<template #extra>
<Button
type="primary"
:disabled="!selectedStoreId"
@click="openAddSlotDrawer"
>
添加时段
</Button>
</template>
<div class="legend-row">
<span
v-for="item in SLOT_TYPE_OPTIONS"
:key="item.value"
class="legend-item"
>
<span
class="legend-dot"
:style="{ background: getLegendDotColor(item.value) }"
></span>
{{ item.label }}
</span>
</div>
<div class="week-grid">
<div v-for="day in weekRows" :key="day.dayOfWeek" class="week-row">
<div class="week-day">
{{ day.label }}
<div class="week-day-en">{{ day.labelEn }}</div>
</div>
<div class="week-slots">
<template v-if="day.isOpen && day.slots.length > 0">
<div
v-for="slot in day.slots"
:key="slot.id"
class="time-slot"
:style="getSlotStyle(slot.type)"
>
<span class="slot-type">{{
getSlotTypeLabel(slot.type)
}}</span>
<span class="slot-time">
{{ slot.startTime }}-{{ slot.endTime }}
</span>
<span
v-if="slot.type === SlotType.Delivery && slot.capacity"
class="slot-cap"
>
{{ slot.capacity }}/h
</span>
</div>
</template>
<span v-else class="week-closed">休息</span>
</div>
<div class="week-actions">
<Button
type="text"
size="small"
@click="openDayDrawer(day.dayOfWeek)"
>
编辑
</Button>
</div>
</div>
</div>
</Card>
<Card :bordered="false">
<template #title>
<span class="section-title">节假日 / 特殊日期</span>
</template>
<template #extra>
<Button
type="primary"
:disabled="!selectedStoreId"
@click="openHolidayDrawer('create')"
>
添加日期
</Button>
</template>
<div class="holiday-table-wrap">
<table class="holiday-table">
<thead>
<tr>
<th>日期</th>
<th>类型</th>
<th>时间</th>
<th>原因</th>
<th class="op-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="holidayRows.length === 0">
<td colspan="5">
<Empty description="暂无节假日配置" />
</td>
</tr>
<tr v-for="holiday in holidayRows" v-else :key="holiday.id">
<td>{{ formatHolidayDate(holiday) }}</td>
<td>
<span
class="holiday-tag"
:style="getHolidayTagStyle(holiday.type)"
>
{{ getHolidayTypeText(holiday.type) }}
</span>
</td>
<td>{{ formatHolidayTime(holiday) }}</td>
<td>{{ holiday.reason || '--' }}</td>
<td>
<Button
type="link"
size="small"
@click="openHolidayDrawer('edit', holiday)"
>
编辑
</Button>
<Popconfirm
title="确认删除该日期配置吗?"
ok-text="确认"
cancel-text="取消"
@confirm="handleDeleteHoliday(holiday.id)"
>
<Button
type="link"
size="small"
danger
:loading="deletingHolidayId === holiday.id"
>
删除
</Button>
</Popconfirm>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</Spin>
</template>
<AddSlotDrawer
v-model:open="isAddDrawerOpen"
:add-slot-form="addSlotForm"
:day-names="DAY_NAMES"
:get-slot-type-pill-class="getSlotTypePillClass"
:is-add-day-selected="isAddDaySelected"
:is-weekly-submitting="isWeeklySubmitting"
:on-set-capacity="setAddSlotCapacity"
:on-set-end-time="setAddSlotEndTime"
:on-set-remark="setAddSlotRemark"
:on-set-start-time="setAddSlotStartTime"
:on-set-type="setAddSlotType"
:slot-type-delivery="SlotType.Delivery"
:slot-type-options="SLOT_TYPE_OPTIONS"
@quick-select="quickSelectAddDays"
@submit="handleAddSlotSubmit"
@toggle-day="toggleAddDay"
/>
<DayEditDrawer
v-model:open="isDayDrawerOpen"
:day-edit-form="dayEditForm"
:is-weekly-submitting="isWeeklySubmitting"
:on-set-day-open="setDayEditOpen"
:on-set-slot-capacity="setDaySlotCapacity"
:on-set-slot-end-time="setDaySlotEndTime"
:on-set-slot-start-time="setDaySlotStartTime"
:on-set-slot-type="setDaySlotType"
:slot-type-delivery="SlotType.Delivery"
:slot-type-options="SLOT_TYPE_OPTIONS"
:title="`编辑时段 - ${currentDayMeta.label}`"
@add-slot-row="addDaySlotRow"
@remove-slot="removeDaySlot"
@save="handleSaveDay"
/>
<HolidayDrawer
v-model:open="isHolidayDrawerOpen"
:holiday-form="holidayForm"
:holiday-type-closed="HolidayType.Closed"
:holiday-type-special="HolidayType.Special"
:is-holiday-submitting="isHolidaySubmitting"
:on-set-date-mode="setHolidayDateMode"
:on-set-end-time="setHolidayEndTime"
:on-set-range-end="setHolidayRangeEnd"
:on-set-range-start="setHolidayRangeStart"
:on-set-reason="setHolidayReason"
:on-set-remark="setHolidayRemark"
:on-set-single-date="setHolidaySingleDate"
:on-set-start-time="setHolidayStartTime"
:on-set-type="setHolidayType"
:submit-text="holidaySubmitText"
:title="holidayDrawerTitle"
@submit="handleHolidaySubmit"
/>
<CopyStoreModal
v-model:open="isCopyModalOpen"
:copy-candidates="copyCandidates"
:copy-target-store-ids="copyTargetStoreIds"
:is-copy-all-checked="isCopyAllChecked"
:is-copy-indeterminate="isCopyIndeterminate"
:is-copy-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
@check-all="handleCopyCheckAll"
@store-change="
({ storeId, checked }) => handleCopyStoreChange(storeId, checked)
"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
"
/>
</Page>
</template>
<style src="./styles/index.less"></style>

View File

@@ -0,0 +1,87 @@
/* 页面基础骨架与通用表单样式。 */
.page-hours {
max-width: 980px;
.hours-toolbar-card .ant-card-body {
padding: 14px 16px;
}
.hours-toolbar {
display: flex;
gap: 12px;
align-items: center;
}
.store-selector {
width: 280px;
}
.toolbar-spacer {
flex: 1;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.legend-row {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 12px;
color: #9ca3af;
}
.legend-item {
display: flex;
gap: 4px;
align-items: center;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.form-block {
margin-bottom: 14px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.form-label.required::before {
margin-right: 4px;
color: #ef4444;
content: '*';
}
.form-sub-label {
display: block;
margin-bottom: 6px;
font-size: 11px;
color: #9ca3af;
}
.native-input {
box-sizing: border-box;
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid #d9d9d9;
border-radius: 8px;
}
.native-input:focus {
outline: none;
border-color: #1677ff;
}
}

View File

@@ -0,0 +1,58 @@
/* 复制到门店弹窗区域样式。 */
.page-hours {
.copy-modal-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.copy-all-row {
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.copy-store-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.copy-store-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
cursor: pointer;
}
.copy-store-item + .copy-store-item {
border-top: 1px solid #f5f5f5;
}
.copy-store-item:hover {
background: #fafcff;
}
.copy-store-info {
flex: 1;
min-width: 0;
}
.copy-store-name {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.copy-store-address {
margin-top: 2px;
font-size: 12px;
color: #9ca3af;
}
.copy-source-tip {
font-size: 12px;
color: #6b7280;
}
}

View File

@@ -0,0 +1,203 @@
/* 抽屉与编辑表单样式(新增时段、编辑时段、节假日抽屉)。 */
.page-hours {
.type-pill-group {
display: flex;
gap: 8px;
}
.type-pill {
padding: 6px 16px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.type-pill.active.tp-biz {
font-weight: 600;
color: #389e0d;
background: #f6ffed;
border-color: #b7eb8f;
}
.type-pill.active.tp-del {
font-weight: 600;
color: #1890ff;
background: #e6f7ff;
border-color: #91d5ff;
}
.type-pill.active.tp-pick {
font-weight: 600;
color: #d46b08;
background: #fff7e6;
border-color: #ffd591;
}
.type-pill.active.ht-closed {
font-weight: 600;
color: #ef4444;
background: #fff2f0;
border-color: #ffa39e;
}
.type-pill.active.ht-special {
font-weight: 600;
color: #f59e0b;
background: #fff7e6;
border-color: #ffd591;
}
.day-pill-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.day-pill {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 34px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.day-pill.selected {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.quick-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.quick-btn {
padding: 0;
font-size: 12px;
color: #1677ff;
cursor: pointer;
background: transparent;
border: none;
}
.quick-btn:hover {
text-decoration: underline;
}
.time-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 12px;
}
.capacity-row {
display: flex;
gap: 8px;
align-items: center;
}
.capacity-input {
max-width: 150px;
}
.drawer-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.day-open-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.day-close-hint {
font-size: 12px;
color: #ef4444;
}
.slot-edit-list.disabled {
pointer-events: none;
opacity: 0.5;
}
.slot-edit-card {
padding: 12px 14px;
margin-bottom: 10px;
background: #f8f9fb;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.slot-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.slot-type-select {
width: 140px;
}
.add-dashed-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
background: transparent;
border: 1px dashed #d1d5db;
border-radius: 10px;
}
.add-dashed-btn:hover {
color: #1677ff;
border-color: #1677ff;
}
.date-mode-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.date-mode-pill {
padding: 4px 12px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.date-mode-pill.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.date-range-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
}
}

View File

@@ -0,0 +1,44 @@
/* 节假日/特殊日期表格区域样式。 */
.page-hours {
.holiday-table-wrap {
overflow-x: auto;
}
.holiday-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.holiday-table th {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-align: left;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.holiday-table td {
padding: 10px 12px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.holiday-table tr:hover td {
background: #fafcff;
}
.holiday-table .op-column {
width: 120px;
}
.holiday-tag {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
}
}

View File

@@ -0,0 +1,7 @@
/* 营业时间页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './week.less';
@import './holiday.less';
@import './drawer.less';
@import './copy-modal.less';
@import './responsive.less';

View File

@@ -0,0 +1,22 @@
/* 营业时间页面响应式规则。 */
.page-hours {
@media (max-width: 768px) {
.store-selector {
width: 100%;
}
.hours-toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-spacer {
display: none;
}
.week-row {
grid-template-columns: 1fr;
row-gap: 8px;
}
}
}

View File

@@ -0,0 +1,71 @@
/* 每周营业时间区域样式。 */
.page-hours {
.week-grid {
display: flex;
flex-direction: column;
}
.week-row {
display: grid;
grid-template-columns: 84px 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.week-row:hover {
background: #fafcff;
}
.week-row:last-child {
border-bottom: none;
}
.week-day {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.week-day-en {
font-size: 11px;
font-weight: 400;
color: #9ca3af;
}
.week-slots {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.time-slot {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
border: 1px solid;
border-radius: 6px;
}
.slot-type {
font-weight: 600;
}
.slot-time {
opacity: 0.88;
}
.slot-cap {
font-size: 10px;
opacity: 0.75;
}
.week-closed {
font-size: 12px;
font-style: italic;
color: #9ca3af;
}
}

View File

@@ -0,0 +1,52 @@
import type { HolidayType, SlotType } from '#/api/store-hours';
export interface DayName {
label: string;
labelEn: string;
}
export interface SlotTypeOption {
label: string;
value: SlotType;
}
export interface AddSlotFormState {
type: SlotType;
selectedDays: number[];
startTime: string;
endTime: string;
capacity: number;
remark: string;
}
export interface EditSlotItem {
id: string;
type: SlotType;
startTime: string;
endTime: string;
capacity?: number;
remark?: string;
}
export interface DayEditFormState {
dayOfWeek: number;
isOpen: boolean;
slots: EditSlotItem[];
}
export type HolidayDateMode = 'range' | 'single';
export interface HolidayFormState {
id: string;
type: HolidayType;
dateMode: HolidayDateMode;
singleDate: string;
rangeStart: string;
rangeEnd: string;
startTime: string;
endTime: string;
reason: string;
remark: string;
}
export type AddDaysMode = 'all' | 'weekday' | 'weekend';