feat: 完成门店配置拆分并新增配送与自提设置页面

This commit is contained in:
2026-02-16 14:39:11 +08:00
parent 07495f8c35
commit 8d1325edf0
63 changed files with 6827 additions and 368 deletions

View File

@@ -0,0 +1,82 @@
/**
* 文件职责:配送设置模块 API 与 DTO 定义。
* 1. 维护配送模式、梯度、区域、通用设置类型。
* 2. 提供查询/保存/复制配送设置接口。
*/
import { requestClient } from '#/api/request';
/** 配送模式 */
export type DeliveryMode = 'polygon' | 'radius';
/** 半径梯度配置 */
export interface RadiusTierDto {
color: string;
deliveryFee: number;
etaMinutes: number;
id: string;
maxDistance: number;
minDistance: number;
minOrderAmount: number;
}
/** 多边形区域配置 */
export interface PolygonZoneDto {
color: string;
deliveryFee: number;
etaMinutes: number;
id: string;
minOrderAmount: number;
name: string;
priority: number;
}
/** 通用配送配置 */
export interface DeliveryGeneralSettingsDto {
/** 配送时间加成(分钟) */
etaAdjustmentMinutes: number;
/** 免配送费门槛(元),空值表示不启用 */
freeDeliveryThreshold: null | number;
/** 每小时配送上限(单) */
hourlyCapacityLimit: number;
/** 最大配送距离(公里) */
maxDeliveryDistance: number;
}
/** 门店配送设置聚合 */
export interface StoreDeliverySettingsDto {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
storeId: string;
}
/** 保存配送设置参数 */
export type SaveStoreDeliverySettingsParams = StoreDeliverySettingsDto;
/** 复制配送设置参数 */
export interface CopyStoreDeliverySettingsParams {
sourceStoreId: string;
targetStoreIds: string[];
}
/** 获取门店配送设置 */
export async function getStoreDeliverySettingsApi(storeId: string) {
return requestClient.get<StoreDeliverySettingsDto>('/store/delivery', {
params: { storeId },
});
}
/** 保存门店配送设置 */
export async function saveStoreDeliverySettingsApi(
data: SaveStoreDeliverySettingsParams,
) {
return requestClient.post('/store/delivery/save', data);
}
/** 复制配送设置到其他门店 */
export async function copyStoreDeliverySettingsApi(
data: CopyStoreDeliverySettingsParams,
) {
return requestClient.post('/store/delivery/copy', data);
}

View File

@@ -0,0 +1,138 @@
/**
* 文件职责:自提设置模块 API 与 DTO 定义。
* 1. 维护基本设置、大时段、精细规则与预览类型。
* 2. 提供查询、保存与复制自提设置接口。
*/
import { requestClient } from '#/api/request';
/** 自提模式 */
export type PickupMode = 'big' | 'fine';
/** 可选星期0=周一6=周日) */
export type PickupWeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** 自提基本设置 */
export interface PickupBasicSettingsDto {
/** 是否允许当天自提 */
allowSameDayPickup: boolean;
/** 可预约天数 */
bookingDays: number;
/** 单笔最大数量null 代表不限制 */
maxItemsPerOrder: null | number;
}
/** 大时段模式单条配置 */
export interface PickupSlotDto {
capacity: number;
cutoffMinutes: number;
dayOfWeeks: PickupWeekDay[];
enabled: boolean;
endTime: string;
id: string;
name: string;
/** 当前已预约数量(用于展示进度) */
reservedCount: number;
startTime: string;
}
/** 精细时段规则 */
export interface PickupFineRuleDto {
/** 适用星期 */
dayOfWeeks: PickupWeekDay[];
/** 每日结束时间 HH:mm */
dayEndTime: string;
/** 每日开始时间 HH:mm */
dayStartTime: string;
/** 时间间隔(分钟) */
intervalMinutes: number;
/** 最少提前预约小时数 */
minAdvanceHours: number;
/** 每个窗口容量 */
slotCapacity: number;
}
/** 预览时段状态 */
export type PickupPreviewStatus = 'almost' | 'available' | 'expired' | 'full';
/** 预览时段 */
export interface PickupPreviewSlotDto {
remainingCount: number;
status: PickupPreviewStatus;
time: string;
}
/** 预览日 */
export interface PickupPreviewDayDto {
date: string;
label: string;
slots: PickupPreviewSlotDto[];
subLabel: string;
}
/** 门店自提设置聚合 */
export interface StorePickupSettingsDto {
basicSettings: PickupBasicSettingsDto;
bigSlots: PickupSlotDto[];
fineRule: PickupFineRuleDto;
mode: PickupMode;
previewDays: PickupPreviewDayDto[];
storeId: string;
}
/** 保存基本设置参数 */
export interface SavePickupBasicSettingsParams {
basicSettings: PickupBasicSettingsDto;
mode?: PickupMode;
storeId: string;
}
/** 保存大时段参数 */
export interface SavePickupSlotsParams {
mode?: PickupMode;
slots: PickupSlotDto[];
storeId: string;
}
/** 保存精细规则参数 */
export interface SavePickupFineRuleParams {
fineRule: PickupFineRuleDto;
mode?: PickupMode;
storeId: string;
}
/** 复制自提设置参数 */
export interface CopyStorePickupSettingsParams {
sourceStoreId: string;
targetStoreIds: string[];
}
/** 获取门店自提设置 */
export async function getStorePickupSettingsApi(storeId: string) {
return requestClient.get<StorePickupSettingsDto>('/store/pickup', {
params: { storeId },
});
}
/** 保存基本设置 */
export async function savePickupBasicSettingsApi(
data: SavePickupBasicSettingsParams,
) {
return requestClient.post('/store/pickup/basic/save', data);
}
/** 保存大时段配置 */
export async function savePickupSlotsApi(data: SavePickupSlotsParams) {
return requestClient.post('/store/pickup/slots/save', data);
}
/** 保存精细规则 */
export async function savePickupFineRuleApi(data: SavePickupFineRuleParams) {
return requestClient.post('/store/pickup/fine-rule/save', data);
}
/** 复制到其他门店 */
export async function copyStorePickupSettingsApi(
data: CopyStorePickupSettingsParams,
) {
return requestClient.post('/store/pickup/copy', data);
}

View File

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

View File

@@ -0,0 +1,582 @@
import Mock from 'mockjs';
const Random = Mock.Random;
/** 文件职责:自提设置页面 Mock 接口。 */
interface MockRequestOptions {
body: null | string;
type: string;
url: string;
}
type PickupMode = 'big' | 'fine';
type PickupWeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type PickupPreviewStatus = 'almost' | 'available' | 'expired' | 'full';
interface PickupBasicSettingsMock {
allowSameDayPickup: boolean;
bookingDays: number;
maxItemsPerOrder: null | number;
}
interface PickupSlotMock {
capacity: number;
cutoffMinutes: number;
dayOfWeeks: PickupWeekDay[];
enabled: boolean;
endTime: string;
id: string;
name: string;
reservedCount: number;
startTime: string;
}
interface PickupFineRuleMock {
dayEndTime: string;
dayOfWeeks: PickupWeekDay[];
dayStartTime: string;
intervalMinutes: number;
minAdvanceHours: number;
slotCapacity: number;
}
interface PickupPreviewSlotMock {
remainingCount: number;
status: PickupPreviewStatus;
time: string;
}
interface PickupPreviewDayMock {
date: string;
label: string;
slots: PickupPreviewSlotMock[];
subLabel: string;
}
interface StorePickupState {
basicSettings: PickupBasicSettingsMock;
bigSlots: PickupSlotMock[];
fineRule: PickupFineRuleMock;
mode: PickupMode;
previewDays: PickupPreviewDayMock[];
}
const ALL_WEEK_DAYS: PickupWeekDay[] = [0, 1, 2, 3, 4, 5, 6];
const WEEKDAY_ONLY: PickupWeekDay[] = [0, 1, 2, 3, 4];
const WEEKEND_ONLY: PickupWeekDay[] = [5, 6];
const WEEKDAY_LABEL_MAP: Record<PickupWeekDay, string> = {
0: '周一',
1: '周二',
2: '周三',
3: '周四',
4: '周五',
5: '周六',
6: '周日',
};
const storePickupMap = new Map<string, StorePickupState>();
/** 解析 URL 查询参数。 */
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;
}
/** 解析请求体 JSON。 */
function parseBody(options: MockRequestOptions) {
if (!options.body) return {};
try {
return JSON.parse(options.body);
} catch (error) {
console.error('[mock-store-pickup] parseBody error:', error);
return {};
}
}
/** 确保门店状态存在。 */
function ensureStoreState(storeId = '') {
const key = storeId || 'default';
let state = storePickupMap.get(key);
if (!state) {
state = createDefaultState();
storePickupMap.set(key, state);
}
return state;
}
function createDefaultState(): StorePickupState {
const fineRule: PickupFineRuleMock = {
intervalMinutes: 30,
slotCapacity: 5,
dayStartTime: '09:00',
dayEndTime: '20:30',
minAdvanceHours: 2,
dayOfWeeks: [...ALL_WEEK_DAYS],
};
return {
mode: 'big',
basicSettings: {
allowSameDayPickup: true,
bookingDays: 3,
maxItemsPerOrder: 20,
},
bigSlots: sortSlots([
{
id: Random.guid(),
name: '上午时段',
startTime: '09:00',
endTime: '11:30',
cutoffMinutes: 30,
capacity: 20,
reservedCount: 5,
dayOfWeeks: [...WEEKDAY_ONLY],
enabled: true,
},
{
id: Random.guid(),
name: '午间时段',
startTime: '11:30',
endTime: '14:00',
cutoffMinutes: 20,
capacity: 30,
reservedCount: 12,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
},
{
id: Random.guid(),
name: '下午时段',
startTime: '14:00',
endTime: '17:00',
cutoffMinutes: 30,
capacity: 15,
reservedCount: 3,
dayOfWeeks: [...WEEKDAY_ONLY],
enabled: true,
},
{
id: Random.guid(),
name: '晚间时段',
startTime: '17:00',
endTime: '20:30',
cutoffMinutes: 30,
capacity: 25,
reservedCount: 8,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
},
{
id: Random.guid(),
name: '周末特惠',
startTime: '10:00',
endTime: '15:00',
cutoffMinutes: 45,
capacity: 40,
reservedCount: 18,
dayOfWeeks: [...WEEKEND_ONLY],
enabled: false,
},
]),
fineRule,
previewDays: generatePreviewDays(fineRule),
};
}
/** 深拷贝基础设置。 */
function cloneBasicSettings(source: PickupBasicSettingsMock) {
return { ...source };
}
/** 深拷贝大时段列表。 */
function cloneBigSlots(source: PickupSlotMock[]) {
return source.map((item) => ({
...item,
dayOfWeeks: [...item.dayOfWeeks],
}));
}
/** 深拷贝精细规则。 */
function cloneFineRule(source: PickupFineRuleMock) {
return {
...source,
dayOfWeeks: [...source.dayOfWeeks],
};
}
/** 深拷贝预览日列表。 */
function clonePreviewDays(source: PickupPreviewDayMock[]) {
return source.map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
/** 深拷贝门店配置。 */
function cloneStoreState(source: StorePickupState): StorePickupState {
return {
mode: source.mode,
basicSettings: cloneBasicSettings(source.basicSettings),
bigSlots: cloneBigSlots(source.bigSlots),
fineRule: cloneFineRule(source.fineRule),
previewDays: clonePreviewDays(source.previewDays),
};
}
/** 归一化基础设置提交数据。 */
function normalizeBasicSettings(source: any): PickupBasicSettingsMock {
return {
allowSameDayPickup: Boolean(source?.allowSameDayPickup),
bookingDays: clampInt(source?.bookingDays, 1, 30, 3),
maxItemsPerOrder:
source?.maxItemsPerOrder === null ||
source?.maxItemsPerOrder === undefined
? null
: clampInt(source?.maxItemsPerOrder, 0, 999, 20),
};
}
/** 归一化精细规则提交数据。 */
function normalizeFineRule(source: any): PickupFineRuleMock {
return {
intervalMinutes: clampInt(source?.intervalMinutes, 5, 180, 30),
slotCapacity: clampInt(source?.slotCapacity, 1, 999, 5),
dayStartTime: normalizeTime(source?.dayStartTime, '09:00'),
dayEndTime: normalizeTime(source?.dayEndTime, '20:30'),
minAdvanceHours: clampInt(source?.minAdvanceHours, 0, 72, 2),
dayOfWeeks: normalizeDayOfWeeks(source?.dayOfWeeks, [...ALL_WEEK_DAYS]),
};
}
/** 归一化大时段提交数据。 */
function normalizeSlots(source: any, previous: PickupSlotMock[]) {
const reservedMap = new Map(
previous.map((item) => [item.id, item.reservedCount]),
);
if (!Array.isArray(source) || source.length === 0) return [];
return sortSlots(
source.map((item, index) => {
const id = String(item?.id || Random.guid());
const capacity = clampInt(item?.capacity, 0, 9999, 0);
const existingReserved = reservedMap.get(id) ?? 0;
const incomingReserved = clampInt(
item?.reservedCount,
0,
capacity,
existingReserved,
);
return {
id,
name: String(item?.name || `时段${index + 1}`).trim(),
startTime: normalizeTime(item?.startTime, '09:00'),
endTime: normalizeTime(item?.endTime, '17:00'),
cutoffMinutes: clampInt(item?.cutoffMinutes, 0, 720, 30),
capacity,
reservedCount: Math.min(capacity, incomingReserved),
dayOfWeeks: normalizeDayOfWeeks(item?.dayOfWeeks, [...WEEKDAY_ONLY]),
enabled: Boolean(item?.enabled),
};
}),
);
}
/** 归一化模式字段。 */
function normalizeMode(mode: unknown, fallback: PickupMode): PickupMode {
return mode === 'fine' || mode === 'big' ? mode : fallback;
}
/** 排序大时段。 */
function sortSlots(source: PickupSlotMock[]) {
return source.toSorted((a, b) => {
const diff =
parseTimeToMinutes(a.startTime) - parseTimeToMinutes(b.startTime);
if (diff !== 0) return diff;
return a.name.localeCompare(b.name);
});
}
/** 归一化 HH:mm 时间。 */
function normalizeTime(time: unknown, fallback: string) {
const value = typeof time === 'string' ? time : '';
const matched = /(\d{2}):(\d{2})/.exec(value);
if (!matched) return fallback;
const hour = Number(matched[1]);
const minute = Number(matched[2]);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return fallback;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/** 归一化星期数组。 */
function normalizeDayOfWeeks(source: unknown, fallback: PickupWeekDay[]) {
if (!Array.isArray(source)) return fallback;
const values = source
.map(Number)
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)
.map((item) => item as PickupWeekDay);
const unique = [...new Set(values)].toSorted((a, b) => a - b);
return unique.length > 0 ? unique : fallback;
}
/** 数值裁剪为整数区间。 */
function clampInt(value: unknown, min: number, max: number, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return fallback;
const normalized = Math.floor(numberValue);
return Math.max(min, Math.min(max, normalized));
}
/** HH:mm 转分钟。 */
function parseTimeToMinutes(time: string) {
const matched = /^(\d{2}):(\d{2})$/.exec(time);
if (!matched) return Number.NaN;
return Number(matched[1]) * 60 + Number(matched[2]);
}
/** 生成三天预览数据。 */
function generatePreviewDays(
fineRule: PickupFineRuleMock,
baseDate = new Date(),
) {
const startMinutes = parseTimeToMinutes(fineRule.dayStartTime);
const endMinutes = parseTimeToMinutes(fineRule.dayEndTime);
if (!Number.isFinite(startMinutes) || !Number.isFinite(endMinutes)) return [];
if (endMinutes <= startMinutes || fineRule.intervalMinutes <= 0) return [];
return Array.from({ length: 3 }).map((_, index) => {
const date = addDays(baseDate, index);
const dateKey = toDateOnly(date);
const dayOfWeek = toPickupWeekDay(date);
const slots = fineRule.dayOfWeeks.includes(dayOfWeek)
? generateDaySlots({
date,
dateKey,
fineRule,
})
: [];
return {
date: dateKey,
label: `${date.getMonth() + 1}/${date.getDate()}`,
subLabel: resolvePreviewSubLabel(index, dayOfWeek),
slots,
};
});
}
/** 生成某天时段预览。 */
function generateDaySlots(payload: {
date: Date;
dateKey: string;
fineRule: PickupFineRuleMock;
}) {
const startMinutes = parseTimeToMinutes(payload.fineRule.dayStartTime);
const endMinutes = parseTimeToMinutes(payload.fineRule.dayEndTime);
const interval = payload.fineRule.intervalMinutes;
const total = Math.floor((endMinutes - startMinutes) / interval);
return Array.from({ length: total + 1 }).map((_, index) => {
const minutes = startMinutes + index * interval;
const time = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
minutes % 60,
).padStart(2, '0')}`;
const booked = calcMockBookedCount(
`${payload.dateKey}|${time}`,
payload.fineRule.slotCapacity,
);
const remainingCount = Math.max(0, payload.fineRule.slotCapacity - booked);
return {
time,
remainingCount,
status: resolvePreviewStatus({
date: payload.date,
fineRule: payload.fineRule,
remainingCount,
time,
}),
};
});
}
/** 计算时段预览状态。 */
function resolvePreviewStatus(payload: {
date: Date;
fineRule: PickupFineRuleMock;
remainingCount: number;
time: string;
}): PickupPreviewStatus {
const now = new Date();
const today = toDateOnly(now);
const dateKey = toDateOnly(payload.date);
const slotMinutes = parseTimeToMinutes(payload.time);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const minAdvanceMinutes = payload.fineRule.minAdvanceHours * 60;
if (dateKey < today) return 'expired';
if (dateKey === today && slotMinutes - nowMinutes <= minAdvanceMinutes) {
return 'expired';
}
if (payload.remainingCount <= 0) return 'full';
if (payload.remainingCount <= 1) return 'almost';
return 'available';
}
/** 计算稳定的伪随机已预约量。 */
function calcMockBookedCount(seed: string, capacity: number) {
if (capacity <= 0) return 0;
let hash = 0;
for (const char of seed) {
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
}
if (hash % 7 === 0) return capacity;
if (hash % 5 === 0) return Math.max(0, capacity - 1);
return hash % (capacity + 1);
}
function toDateOnly(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(baseDate: Date, days: number) {
const next = new Date(baseDate);
next.setDate(baseDate.getDate() + days);
return next;
}
function toPickupWeekDay(date: Date): PickupWeekDay {
const mapping: PickupWeekDay[] = [6, 0, 1, 2, 3, 4, 5];
return mapping[date.getDay()] ?? 0;
}
function resolvePreviewSubLabel(offset: number, dayOfWeek: PickupWeekDay) {
const dayText = WEEKDAY_LABEL_MAP[dayOfWeek];
if (offset === 0) return `${dayText} 今天`;
if (offset === 1) return `${dayText} 明天`;
if (offset === 2) return `${dayText} 后天`;
return dayText;
}
// 获取门店自提设置
Mock.mock(/\/store\/pickup(?:\?|$)/, 'get', (options: MockRequestOptions) => {
const params = parseUrlParams(options.url);
const storeId = String(params.storeId || '');
const state = ensureStoreState(storeId);
state.previewDays = generatePreviewDays(state.fineRule);
return {
code: 200,
data: {
storeId,
mode: state.mode,
basicSettings: cloneBasicSettings(state.basicSettings),
bigSlots: cloneBigSlots(state.bigSlots),
fineRule: cloneFineRule(state.fineRule),
previewDays: clonePreviewDays(state.previewDays),
},
};
});
// 保存自提基础设置
Mock.mock(
/\/store\/pickup\/basic\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) return { code: 200, data: null };
const state = ensureStoreState(storeId);
state.basicSettings = normalizeBasicSettings(body.basicSettings);
state.mode = normalizeMode(body.mode, state.mode);
return {
code: 200,
data: null,
};
},
);
// 保存自提大时段
Mock.mock(
/\/store\/pickup\/slots\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) return { code: 200, data: null };
const state = ensureStoreState(storeId);
state.bigSlots = normalizeSlots(body.slots, state.bigSlots);
state.mode = normalizeMode(body.mode, state.mode);
return {
code: 200,
data: null,
};
},
);
// 保存自提精细规则
Mock.mock(
/\/store\/pickup\/fine-rule\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) return { code: 200, data: null };
const state = ensureStoreState(storeId);
state.fineRule = normalizeFineRule(body.fineRule);
state.mode = normalizeMode(body.mode, state.mode);
state.previewDays = generatePreviewDays(state.fineRule);
return {
code: 200,
data: null,
};
},
);
// 复制门店自提设置
Mock.mock(/\/store\/pickup\/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 sourceState = ensureStoreState(sourceStoreId);
const uniqueTargets = [...new Set(targetStoreIds)].filter(
(storeId) => storeId !== sourceStoreId,
);
for (const targetStoreId of uniqueTargets) {
storePickupMap.set(targetStoreId, cloneStoreState(sourceState));
}
return {
code: 200,
data: {
copiedCount: uniqueTargets.length,
},
};
});

View File

@@ -28,6 +28,24 @@ const routes: RouteRecordRaw[] = [
title: '营业时间',
},
},
{
name: 'StoreDelivery',
path: '/store/delivery',
component: () => import('#/views/store/delivery/index.vue'),
meta: {
icon: 'lucide:truck',
title: '配送设置',
},
},
{
name: 'StorePickup',
path: '/store/pickup',
component: () => import('#/views/store/pickup/index.vue'),
meta: {
icon: 'lucide:shopping-bag',
title: '自提设置',
},
},
],
},
];

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
/**
* 文件职责:复制配置到其他门店弹窗。
* 1. 展示可选目标门店列表。
* 2. 处理全选/单选交互并向父级抛事件。
* 3. 仅做 UI 交互,不执行复制请求。
*/
import type { StoreListItemDto } from '#/api/store';
import { computed } from 'vue';
import { Button, Empty, Modal } from 'ant-design-vue';
interface Props {
confirmText?: string;
copyCandidates: StoreListItemDto[];
isAllChecked: boolean;
isIndeterminate: boolean;
isSubmitting: boolean;
open: boolean;
selectedStoreName: string;
targetStoreIds: string[];
title?: string;
warningText?: string;
}
const props = withDefaults(defineProps<Props>(), {
confirmText: '确认复制',
title: '复制配置到其他门店',
warningText: '将覆盖目标门店的现有设置,请谨慎操作',
});
const emit = defineEmits<{
(event: 'checkAll', checked: boolean): void;
(event: 'submit'): void;
(event: 'toggleStore', payload: { checked: boolean; storeId: string }): void;
(event: 'update:open', value: boolean): void;
}>();
const selectedStoreIdSet = computed(() => new Set(props.targetStoreIds));
const hasCandidates = computed(() => props.copyCandidates.length > 0);
/** 判断目标门店是否被选中。 */
function isStoreChecked(storeId: string) {
return selectedStoreIdSet.value.has(storeId);
}
/** 切换单个门店选中态并通知父级。 */
function toggleStore(storeId: string) {
emit('toggleStore', {
storeId,
checked: !isStoreChecked(storeId),
});
}
/** 切换全选状态并通知父级。 */
function toggleAll() {
emit('checkAll', !props.isAllChecked);
}
</script>
<template>
<Modal
:open="props.open"
:title="props.title"
:width="650"
:footer="null"
:mask-closable="true"
wrap-class-name="store-copy-modal-wrap"
@update:open="(value) => emit('update:open', value)"
>
<div class="store-copy-modal-content">
<div class="store-copy-modal-source">
当前门店{{ props.selectedStoreName || '--' }}
</div>
<div class="store-copy-modal-warning">
<span class="store-copy-modal-warning-icon">!</span>
<span>{{ props.warningText }}</span>
</div>
<template v-if="hasCandidates">
<div class="store-copy-all-row" @click="toggleAll">
<span
class="store-copy-check"
:class="{
checked: props.isAllChecked,
indeterminate: props.isIndeterminate && !props.isAllChecked,
}"
>
<span class="store-copy-check-mark"></span>
</span>
<span>全选</span>
</div>
<div class="store-copy-list">
<div
v-for="store in props.copyCandidates"
:key="store.id"
class="store-copy-item"
@click="toggleStore(store.id)"
>
<span
class="store-copy-check"
:class="{ checked: isStoreChecked(store.id) }"
>
<span class="store-copy-check-mark"></span>
</span>
<div class="store-copy-info">
<div class="store-copy-name">{{ store.name }}</div>
<div class="store-copy-address">{{ store.address || '--' }}</div>
</div>
</div>
</div>
</template>
<div v-else class="store-copy-empty">
<Empty description="暂无可复制的门店" />
</div>
</div>
<div class="store-copy-modal-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isSubmitting"
:disabled="props.targetStoreIds.length === 0"
@click="emit('submit')"
>
{{ props.confirmText }}
</Button>
</div>
</Modal>
</template>
<style lang="less">
/* 文件职责复制配置弹窗Teleport全局样式。 */
.store-copy-modal-wrap {
.ant-modal {
width: 650px !important;
max-width: calc(100vw - 32px);
}
.ant-modal-content {
overflow: hidden;
padding: 0;
border-radius: 14px;
box-shadow: 0 10px 30px rgb(0 0 0 / 12%);
}
.ant-modal-header {
margin-bottom: 0;
padding: 20px 28px 14px;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-title {
font-size: 16px;
font-weight: 600;
color: #1f2329;
}
.ant-modal-close {
top: 18px;
right: 20px;
width: 32px;
height: 32px;
border-radius: 8px;
color: #8f959e;
transition: all 0.2s ease;
}
.ant-modal-close:hover {
color: #4e5969;
background: #f2f3f5;
}
.ant-modal-body {
padding: 0;
}
}
.store-copy-modal-content {
padding: 18px 28px 0;
}
.store-copy-modal-source {
margin-bottom: 10px;
font-size: 13px;
color: #4e5969;
}
.store-copy-modal-warning {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 18px;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
color: #c48b26;
background: #fffbe6;
border: 1px solid #f7e4a1;
border-radius: 10px;
}
.store-copy-modal-warning-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 11px;
font-weight: 700;
color: #fff;
background: #f5b034;
border-radius: 50%;
}
.store-copy-all-row {
display: flex;
gap: 10px;
align-items: center;
padding: 0 0 12px;
font-size: 13px;
font-weight: 500;
color: #1f2329;
cursor: pointer;
}
.store-copy-list {
max-height: 340px;
padding-top: 12px;
overflow-y: auto;
border-top: 1px solid #f0f0f0;
}
.store-copy-item {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 10px;
padding: 12px 12px 10px;
background: #fafbfc;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s ease;
}
.store-copy-item:hover {
background: #f3f6fb;
}
.store-copy-info {
flex: 1;
min-width: 0;
}
.store-copy-name {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: #1f2329;
}
.store-copy-address {
margin-top: 2px;
font-size: 12px;
line-height: 1.5;
color: #86909c;
}
.store-copy-check {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 2px;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 5px;
transition: all 0.2s ease;
}
.store-copy-check-mark {
display: none;
width: 6px;
height: 10px;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) translate(-1px, -1px);
}
.store-copy-check.checked {
background: #1677ff;
border-color: #1677ff;
}
.store-copy-check.checked .store-copy-check-mark {
display: block;
}
.store-copy-check.indeterminate {
background: #1677ff;
border-color: #1677ff;
}
.store-copy-check.indeterminate .store-copy-check-mark {
display: block;
width: 10px;
height: 2px;
border: 0;
background: #fff;
transform: none;
}
.store-copy-empty {
padding: 20px 0;
}
.store-copy-modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 14px 28px 18px;
margin-top: 10px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* 文件职责:门店维度工具栏。
* 1. 提供门店下拉选择。
* 2. 提供“复制到其他门店”入口按钮。
* 3. 仅负责展示和事件透出,不承载业务请求。
*/
import { Button, Card, Select } from 'ant-design-vue';
interface StoreOption {
label: string;
value: string;
}
interface Props {
copyButtonText?: string;
copyDisabled?: boolean;
isStoreLoading: boolean;
selectedStoreId: string;
storeOptions: StoreOption[];
storePlaceholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
copyButtonText: '复制到其他门店',
copyDisabled: false,
storePlaceholder: '请选择门店',
});
const emit = defineEmits<{
(event: 'copy'): void;
(event: 'update:selectedStoreId', value: string): void;
}>();
/** 透传门店选择事件,统一收敛为字符串 ID。 */
function handleStoreChange(value: unknown) {
if (typeof value === 'number' || typeof value === 'string') {
emit('update:selectedStoreId', String(value));
return;
}
emit('update:selectedStoreId', '');
}
</script>
<template>
<Card :bordered="false" class="store-scope-toolbar-card">
<div class="store-scope-toolbar">
<Select
:value="props.selectedStoreId"
class="store-scope-selector"
:placeholder="props.storePlaceholder"
:loading="props.isStoreLoading"
:options="props.storeOptions"
:disabled="props.isStoreLoading || props.storeOptions.length === 0"
@update:value="(value) => handleStoreChange(value)"
/>
<div class="store-scope-spacer"></div>
<Button :disabled="props.copyDisabled" @click="emit('copy')">
{{ props.copyButtonText }}
</Button>
</div>
</Card>
</template>
<style scoped lang="less">
/* 文件职责:门店维度工具栏样式。 */
.store-scope-toolbar-card {
:deep(.ant-card-body) {
padding: 14px 16px;
}
}
.store-scope-toolbar {
display: flex;
gap: 12px;
align-items: center;
}
.store-scope-selector {
width: 280px;
}
.store-scope-spacer {
flex: 1;
}
@media (max-width: 768px) {
.store-scope-selector {
width: 100%;
}
.store-scope-toolbar {
flex-direction: column;
align-items: stretch;
}
.store-scope-spacer {
display: none;
}
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
/**
* 文件职责:通用配送设置区块。
* 1. 展示通用字段输入框。
* 2. 通过回调将字段改动上抛给父级。
*/
import type { DeliveryGeneralSettingsDto } from '#/api/store-delivery';
import { Button, Card, InputNumber } from 'ant-design-vue';
interface Props {
isSaving: boolean;
onSetEtaAdjustmentMinutes: (value: number) => void;
onSetFreeDeliveryThreshold: (value: null | number) => void;
onSetHourlyCapacityLimit: (value: number) => void;
onSetMaxDeliveryDistance: (value: number) => void;
settings: DeliveryGeneralSettingsDto;
}
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">
<template #title>
<span class="section-title">通用设置</span>
</template>
<div class="general-grid">
<div class="general-field">
<label>免配送费门槛</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.freeDeliveryThreshold ?? undefined"
:min="0"
:precision="0"
:controls="false"
placeholder="如50"
class="field-input"
@update:value="
(value) =>
props.onSetFreeDeliveryThreshold(
value === null || value === undefined
? null
: toNumber(value, 0),
)
"
/>
<span></span>
</div>
<div class="field-hint">订单满此金额免配送费留空则不启用</div>
</div>
<div class="general-field">
<label>最大配送距离</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.maxDeliveryDistance"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
placeholder="如5"
class="field-input"
@update:value="
(value) => props.onSetMaxDeliveryDistance(toNumber(value, 0))
"
/>
<span>公里</span>
</div>
<div class="field-hint">仅半径模式生效</div>
</div>
<div class="general-field">
<label>每小时配送上限</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.hourlyCapacityLimit"
:min="1"
:precision="0"
:controls="false"
placeholder="如50"
class="field-input"
@update:value="
(value) => props.onSetHourlyCapacityLimit(toNumber(value, 1))
"
/>
<span></span>
</div>
<div class="field-hint">达到上限后暂停接单</div>
</div>
<div class="general-field">
<label>配送时间预估加成</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.etaAdjustmentMinutes"
:min="0"
:precision="0"
:controls="false"
placeholder="如10"
class="field-input"
@update:value="
(value) => props.onSetEtaAdjustmentMinutes(toNumber(value, 0))
"
/>
<span>分钟</span>
</div>
<div class="field-hint">高峰期可适当上调预估送达时间</div>
</div>
</div>
<div class="general-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,89 @@
<script setup lang="ts">
/**
* 文件职责:配送模式区块。
* 1. 展示配送模式切换按钮。
* 2. 展示地图占位与半径/区域两种预览。
*/
import type { DeliveryMode, RadiusTierDto } from '#/api/store-delivery';
import { computed } from 'vue';
import { Card } from 'ant-design-vue';
interface Props {
mode: DeliveryMode;
modeOptions: Array<{ label: string; value: DeliveryMode }>;
radiusTiers: RadiusTierDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'changeMode', mode: DeliveryMode): void;
}>();
const radiusLabels = computed(() => {
const fallback = ['1km', '3km', '5km'];
const sorted = props.radiusTiers
.toSorted((a, b) => a.maxDistance - b.maxDistance)
.slice(0, 3);
if (sorted.length === 0) return fallback;
const labels = sorted.map((item) => `${item.maxDistance}km`);
while (labels.length < 3) {
labels.push(fallback[labels.length] ?? '5km');
}
return labels;
});
</script>
<template>
<Card :bordered="false" class="delivery-mode-card">
<template #title>
<span class="section-title">配送模式</span>
</template>
<div class="delivery-mode-switch">
<button
v-for="item in props.modeOptions"
:key="item.value"
type="button"
class="mode-switch-item"
:class="{ active: props.mode === item.value }"
@click="emit('changeMode', item.value)"
>
{{ item.label }}
</button>
</div>
<div class="delivery-map-area">
<span class="map-grid grid-h map-grid-h-1"></span>
<span class="map-grid grid-h map-grid-h-2"></span>
<span class="map-grid grid-h map-grid-h-3"></span>
<span class="map-grid grid-v map-grid-v-1"></span>
<span class="map-grid grid-v map-grid-v-2"></span>
<span class="map-grid grid-v map-grid-v-3"></span>
<span class="map-pin"></span>
<template v-if="props.mode === 'radius'">
<span class="radius-circle radius-3">
<span class="radius-label">{{ radiusLabels[2] }}</span>
</span>
<span class="radius-circle radius-2">
<span class="radius-label">{{ radiusLabels[1] }}</span>
</span>
<span class="radius-circle radius-1">
<span class="radius-label">{{ radiusLabels[0] }}</span>
</span>
</template>
<div v-else class="polygon-hint">
<div class="polygon-hint-title">多边形区域模式</div>
<div class="polygon-hint-desc">
点击绘制新区域后可在地图上框选配送范围
</div>
</div>
</div>
</Card>
</template>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
/**
* 文件职责:半径梯度编辑抽屉。
* 1. 展示梯度表单字段。
* 2. 通过回调更新父级状态并提交。
*/
import type { RadiusTierFormState } from '../types';
import { Button, Drawer, InputNumber } from 'ant-design-vue';
interface Props {
colorPalette: string[];
form: RadiusTierFormState;
onSetColor: (value: string) => void;
onSetDeliveryFee: (value: number) => void;
onSetEtaMinutes: (value: number) => void;
onSetMaxDistance: (value: number) => void;
onSetMinDistance: (value: number) => void;
onSetMinOrderAmount: (value: number) => void;
open: boolean;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
</script>
<template>
<Drawer
class="delivery-tier-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>
<div class="distance-range-row">
<InputNumber
:value="props.form.minDistance"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMinDistance(toNumber(value, props.form.minDistance))
"
/>
<span class="distance-separator">~</span>
<InputNumber
:value="props.form.maxDistance"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMaxDistance(toNumber(value, props.form.maxDistance))
"
/>
<span>km</span>
</div>
</div>
<div class="drawer-form-grid">
<div class="drawer-form-block">
<label class="drawer-form-label required">配送费</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.deliveryFee"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetDeliveryFee(toNumber(value, props.form.deliveryFee))
"
/>
<span></span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">预计送达</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.etaMinutes"
:min="1"
:precision="0"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetEtaMinutes(toNumber(value, props.form.etaMinutes))
"
/>
<span>分钟</span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">起送金额</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.minOrderAmount"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMinOrderAmount(
toNumber(value, props.form.minOrderAmount),
)
"
/>
<span></span>
</div>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">梯度颜色</label>
<div class="color-palette">
<button
v-for="color in props.colorPalette"
:key="color"
type="button"
class="color-dot"
:class="{ active: props.form.color === color }"
:style="{ background: color }"
@click="props.onSetColor(color)"
></button>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button type="primary" @click="emit('submit')">确认</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* 文件职责:配送区域编辑抽屉。
* 1. 展示区域字段编辑表单。
* 2. 通过回调更新父级状态并提交。
*/
import type { PolygonZoneFormState } from '../types';
import { Button, Drawer, Input, InputNumber } from 'ant-design-vue';
interface Props {
colorPalette: string[];
form: PolygonZoneFormState;
onSetColor: (value: string) => void;
onSetDeliveryFee: (value: number) => void;
onSetEtaMinutes: (value: number) => void;
onSetMinOrderAmount: (value: number) => void;
onSetName: (value: string) => void;
onSetPriority: (value: number) => void;
open: boolean;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
class="delivery-zone-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"
show-count
placeholder="例如:核心区域"
@input="(event) => props.onSetName(readInputValue(event))"
/>
</div>
<div class="drawer-form-grid">
<div class="drawer-form-block">
<label class="drawer-form-label required">配送费</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.deliveryFee"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetDeliveryFee(toNumber(value, props.form.deliveryFee))
"
/>
<span></span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">起送金额</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.minOrderAmount"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMinOrderAmount(
toNumber(value, props.form.minOrderAmount),
)
"
/>
<span></span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">预计送达</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.etaMinutes"
:min="1"
:precision="0"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetEtaMinutes(toNumber(value, props.form.etaMinutes))
"
/>
<span>分钟</span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">优先级</label>
<InputNumber
:value="props.form.priority"
:min="1"
:precision="0"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) => props.onSetPriority(toNumber(value, props.form.priority))
"
/>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">区域颜色</label>
<div class="color-palette">
<button
v-for="color in props.colorPalette"
:key="color"
type="button"
class="color-dot"
:class="{ active: props.form.color === color }"
:style="{ background: color }"
@click="props.onSetColor(color)"
></button>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button type="primary" @click="emit('submit')">确认</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
/**
* 文件职责:多边形区域列表区块。
* 1. 展示区域表格信息。
* 2. 抛出新增、编辑、删除事件。
*/
import type { PolygonZoneDto } from '#/api/store-delivery';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
interface Props {
formatCurrency: (value: number) => string;
isSaving: boolean;
zones: PolygonZoneDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', zoneId: string): void;
(event: 'edit', zone: PolygonZoneDto): void;
}>();
</script>
<template>
<Card :bordered="false">
<template #title>
<span class="section-title">配送区域</span>
</template>
<template #extra>
<Button type="primary" :disabled="props.isSaving" @click="emit('add')">
绘制新区域
</Button>
</template>
<div v-if="props.zones.length > 0" class="zone-table-wrap">
<table class="zone-table">
<thead>
<tr>
<th>区域名称</th>
<th>配送费</th>
<th>起送金额</th>
<th>预计送达</th>
<th>优先级</th>
<th class="zone-op-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="zone in props.zones" :key="zone.id">
<td>
<span
class="zone-color"
:style="{ background: zone.color }"
></span>
{{ zone.name }}
</td>
<td>{{ props.formatCurrency(zone.deliveryFee) }}</td>
<td>{{ props.formatCurrency(zone.minOrderAmount) }}</td>
<td>{{ zone.etaMinutes }} 分钟</td>
<td>{{ zone.priority }}</td>
<td class="zone-op-cell">
<div class="zone-actions">
<Button type="link" size="small" @click="emit('edit', zone)">
编辑
</Button>
<Popconfirm
title="确认删除该配送区域吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', zone.id)"
>
<Button type="link" size="small" danger>删除</Button>
</Popconfirm>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Empty v-else description="暂无区域配置" />
</Card>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* 文件职责:半径梯度列表区块。
* 1. 展示梯度卡片信息。
* 2. 抛出新增、编辑、删除事件。
*/
import type { RadiusTierDto } from '#/api/store-delivery';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
interface Props {
formatCurrency: (value: number) => string;
formatDistanceRange: (tier: RadiusTierDto) => string;
isSaving: boolean;
tiers: RadiusTierDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', tierId: string): void;
(event: 'edit', tier: RadiusTierDto): void;
}>();
</script>
<template>
<Card :bordered="false">
<template #title>
<span class="section-title">距离梯度</span>
</template>
<template #extra>
<Button type="primary" :disabled="props.isSaving" @click="emit('add')">
添加梯度
</Button>
</template>
<div v-if="props.tiers.length > 0" class="tier-list">
<div
v-for="(tier, index) in props.tiers"
:key="tier.id"
class="tier-card"
>
<div class="tier-num" :style="{ background: tier.color }">
{{ index + 1 }}
</div>
<div class="tier-field">
<label>距离范围</label>
<div class="value">{{ props.formatDistanceRange(tier) }}</div>
</div>
<div class="tier-field">
<label>配送费</label>
<div class="value">{{ props.formatCurrency(tier.deliveryFee) }}</div>
</div>
<div class="tier-field">
<label>预计送达</label>
<div class="value">{{ tier.etaMinutes }} 分钟</div>
</div>
<div class="tier-field">
<label>起送金额</label>
<div class="value">
{{ props.formatCurrency(tier.minOrderAmount) }}
</div>
</div>
<div class="tier-actions">
<Button type="text" size="small" @click="emit('edit', tier)">
编辑
</Button>
<Popconfirm
title="确认删除该梯度吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', tier.id)"
>
<Button type="text" size="small" danger>删除</Button>
</Popconfirm>
</div>
</div>
</div>
<Empty v-else description="暂无梯度配置" />
<div class="delivery-tip">超出最大配送半径的地址将无法下单</div>
</Card>
</template>

View File

@@ -0,0 +1,96 @@
/**
* 文件职责:配送设置页面常量。
* 1. 定义默认配送模式与默认数据。
* 2. 统一维护颜色、选项等静态配置。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
export const DELIVERY_MODE_OPTIONS: Array<{
label: string;
value: DeliveryMode;
}> = [
{ label: '按半径配送', value: 'radius' },
{ label: '按区域配送(多边形)', value: 'polygon' },
];
export const TIER_COLOR_PALETTE = [
'#52c41a',
'#faad14',
'#ff4d4f',
'#1677ff',
'#13c2c2',
];
export const DEFAULT_DELIVERY_MODE: DeliveryMode = 'radius';
export const DEFAULT_RADIUS_TIERS: RadiusTierDto[] = [
{
id: 'tier-1',
minDistance: 0,
maxDistance: 1,
deliveryFee: 3,
etaMinutes: 20,
minOrderAmount: 15,
color: '#52c41a',
},
{
id: 'tier-2',
minDistance: 1,
maxDistance: 3,
deliveryFee: 5,
etaMinutes: 35,
minOrderAmount: 20,
color: '#faad14',
},
{
id: 'tier-3',
minDistance: 3,
maxDistance: 5,
deliveryFee: 8,
etaMinutes: 50,
minOrderAmount: 25,
color: '#ff4d4f',
},
];
export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
{
id: 'zone-core',
name: '核心区域',
color: '#52c41a',
deliveryFee: 3,
minOrderAmount: 15,
etaMinutes: 20,
priority: 1,
},
{
id: 'zone-cbd',
name: '朝阳CBD',
color: '#1677ff',
deliveryFee: 5,
minOrderAmount: 20,
etaMinutes: 35,
priority: 2,
},
{
id: 'zone-slt',
name: '三里屯片区',
color: '#faad14',
deliveryFee: 6,
minOrderAmount: 25,
etaMinutes: 40,
priority: 3,
},
];
export const DEFAULT_GENERAL_SETTINGS: DeliveryGeneralSettingsDto = {
freeDeliveryThreshold: 30,
maxDeliveryDistance: 5,
hourlyCapacityLimit: 50,
etaAdjustmentMinutes: 10,
};

View File

@@ -0,0 +1,81 @@
import type { ComputedRef, Ref } from 'vue';
/**
* 文件职责:配送设置复制动作。
* 1. 维护复制弹窗开关与目标门店选择。
* 2. 提交复制请求并反馈结果。
*/
import type { StoreListItemDto } from '#/api/store';
import { message } from 'ant-design-vue';
import { copyStoreDeliverySettingsApi } from '#/api/store-delivery';
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) {
if (checked) {
options.copyTargetStoreIds.value = options.copyCandidates.value.map(
(item) => item.id,
);
return;
}
options.copyTargetStoreIds.value = [];
}
/** 提交复制请求。 */
async function handleCopySubmit() {
if (!options.selectedStoreId.value) return;
if (options.copyTargetStoreIds.value.length === 0) {
message.error('请至少选择一个目标门店');
return;
}
options.isCopySubmitting.value = true;
try {
await copyStoreDeliverySettingsApi({
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,205 @@
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:配送设置数据动作。
* 1. 加载门店列表与当前门店配送设置。
* 2. 保存与重置页面配置。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
import type { DeliverySettingsSnapshot } from '#/views/store/delivery/types';
import { message } from 'ant-design-vue';
import { getStoreListApi } from '#/api/store';
import {
getStoreDeliverySettingsApi,
saveStoreDeliverySettingsApi,
} from '#/api/store-delivery';
import {
DEFAULT_DELIVERY_MODE,
DEFAULT_GENERAL_SETTINGS,
DEFAULT_POLYGON_ZONES,
DEFAULT_RADIUS_TIERS,
} from './constants';
import {
cloneGeneralSettings,
clonePolygonZones,
cloneRadiusTiers,
createSettingsSnapshot,
sortPolygonZones,
sortRadiusTiers,
} from './helpers';
interface CreateDataActionsOptions {
generalSettings: DeliveryGeneralSettingsDto;
isSaving: Ref<boolean>;
isSettingsLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
mode: Ref<DeliveryMode>;
polygonZones: Ref<PolygonZoneDto[]>;
radiusTiers: Ref<RadiusTierDto[]>;
selectedStoreId: Ref<string>;
snapshot: Ref<DeliverySettingsSnapshot | null>;
stores: Ref<StoreListItemDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
/** 同步通用设置对象,保留 reactive 引用。 */
function syncGeneralSettings(next: DeliveryGeneralSettingsDto) {
options.generalSettings.freeDeliveryThreshold = next.freeDeliveryThreshold;
options.generalSettings.maxDeliveryDistance = next.maxDeliveryDistance;
options.generalSettings.hourlyCapacityLimit = next.hourlyCapacityLimit;
options.generalSettings.etaAdjustmentMinutes = next.etaAdjustmentMinutes;
}
/** 将快照应用到页面状态。 */
function applySnapshot(snapshot: DeliverySettingsSnapshot) {
options.mode.value = snapshot.mode;
options.radiusTiers.value = sortRadiusTiers(snapshot.radiusTiers);
options.polygonZones.value = sortPolygonZones(snapshot.polygonZones);
syncGeneralSettings(snapshot.generalSettings);
}
/** 读取当前页面状态并生成快照。 */
function buildCurrentSnapshot() {
return createSettingsSnapshot({
mode: options.mode.value,
radiusTiers: options.radiusTiers.value,
polygonZones: options.polygonZones.value,
generalSettings: options.generalSettings,
});
}
/** 回填默认配置,作为接口异常时的兜底展示。 */
function applyDefaultSettings() {
options.mode.value = DEFAULT_DELIVERY_MODE;
options.radiusTiers.value = sortRadiusTiers(DEFAULT_RADIUS_TIERS);
options.polygonZones.value = sortPolygonZones(DEFAULT_POLYGON_ZONES);
syncGeneralSettings(cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS));
}
/** 加载指定门店的配送设置。 */
async function loadStoreSettings(storeId: string) {
options.isSettingsLoading.value = true;
try {
const currentStoreId = storeId;
const result = await getStoreDeliverySettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return;
options.mode.value = result.mode ?? DEFAULT_DELIVERY_MODE;
options.radiusTiers.value = sortRadiusTiers(
result.radiusTiers?.length ? result.radiusTiers : DEFAULT_RADIUS_TIERS,
);
options.polygonZones.value = sortPolygonZones(
result.polygonZones?.length
? result.polygonZones
: clonePolygonZones(DEFAULT_POLYGON_ZONES),
);
syncGeneralSettings({
...DEFAULT_GENERAL_SETTINGS,
...result.generalSettings,
});
options.snapshot.value = buildCurrentSnapshot();
} catch (error) {
console.error(error);
applyDefaultSettings();
options.snapshot.value = buildCurrentSnapshot();
} finally {
options.isSettingsLoading.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 = '';
options.snapshot.value = null;
applyDefaultSettings();
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;
}
return;
}
if (options.selectedStoreId.value) {
await loadStoreSettings(options.selectedStoreId.value);
}
} catch (error) {
console.error(error);
options.stores.value = [];
options.selectedStoreId.value = '';
options.snapshot.value = null;
applyDefaultSettings();
} finally {
options.isStoreLoading.value = false;
}
}
/** 保存当前门店配送设置。 */
async function saveCurrentSettings() {
if (!options.selectedStoreId.value) return;
options.isSaving.value = true;
try {
await saveStoreDeliverySettingsApi({
storeId: options.selectedStoreId.value,
mode: options.mode.value,
radiusTiers: cloneRadiusTiers(options.radiusTiers.value),
polygonZones: clonePolygonZones(options.polygonZones.value),
generalSettings: cloneGeneralSettings(options.generalSettings),
});
options.snapshot.value = buildCurrentSnapshot();
message.success('配送设置已保存');
} catch (error) {
console.error(error);
} finally {
options.isSaving.value = false;
}
}
/** 重置到最近一次加载/保存后的快照。 */
function resetFromSnapshot() {
if (!options.snapshot.value) {
applyDefaultSettings();
return;
}
applySnapshot(options.snapshot.value);
message.success('已恢复到最近一次保存状态');
}
return {
loadStoreSettings,
loadStores,
resetFromSnapshot,
saveCurrentSettings,
};
}

View File

@@ -0,0 +1,82 @@
/**
* 文件职责:配送设置纯函数工具。
* 1. 负责格式化展示文案。
* 2. 负责克隆与归一化数据,避免引用污染。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
import type { DeliverySettingsSnapshot } from '#/views/store/delivery/types';
import { TIER_COLOR_PALETTE } from './constants';
/** 深拷贝半径梯度数据,避免直接复用原引用。 */
export function cloneRadiusTiers(source: RadiusTierDto[]) {
return source.map((item) => ({ ...item }));
}
/** 深拷贝多边形区域数据,避免直接复用原引用。 */
export function clonePolygonZones(source: PolygonZoneDto[]) {
return source.map((item) => ({ ...item }));
}
/** 复制通用设置对象,供快照与回滚使用。 */
export function cloneGeneralSettings(source: DeliveryGeneralSettingsDto) {
return { ...source };
}
/** 生成页面级快照,用于重置恢复。 */
export function createSettingsSnapshot(payload: {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
}): DeliverySettingsSnapshot {
return {
mode: payload.mode,
radiusTiers: cloneRadiusTiers(payload.radiusTiers),
polygonZones: clonePolygonZones(payload.polygonZones),
generalSettings: cloneGeneralSettings(payload.generalSettings),
};
}
/** 按距离上限升序整理梯度,保证展示顺序稳定。 */
export function sortRadiusTiers(source: RadiusTierDto[]) {
return cloneRadiusTiers(source).toSorted(
(a, b) => a.maxDistance - b.maxDistance,
);
}
/** 按优先级升序整理区域,保证展示顺序稳定。 */
export function sortPolygonZones(source: PolygonZoneDto[]) {
return clonePolygonZones(source).toSorted((a, b) => a.priority - b.priority);
}
/** 将金额格式化为货币文案。 */
export function formatCurrency(value: number) {
return `¥${Number(value || 0).toFixed(2)}`;
}
/** 将梯度距离格式化为区间文案。 */
export function formatDistanceRange(tier: RadiusTierDto) {
return `${tier.minDistance} ~ ${tier.maxDistance} km`;
}
/** 生成梯度 ID便于前端新增记录标识。 */
export function createTierId() {
return `tier-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 生成区域 ID便于前端新增记录标识。 */
export function createZoneId() {
return `zone-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 根据序号获取梯度颜色,超出时循环取值。 */
export function getTierColorByIndex(index: number) {
if (TIER_COLOR_PALETTE.length === 0) return '#1677ff';
return TIER_COLOR_PALETTE[index % TIER_COLOR_PALETTE.length] || '#1677ff';
}

View File

@@ -0,0 +1,172 @@
import type { Ref } from 'vue';
/**
* 文件职责:半径梯度编辑动作。
* 1. 管理梯度抽屉开关与表单赋值。
* 2. 处理梯度新增、编辑、删除。
*/
import type { RadiusTierDto } from '#/api/store-delivery';
import type {
DeliveryDrawerMode,
RadiusTierFormState,
} from '#/views/store/delivery/types';
import { message } from 'ant-design-vue';
interface CreateTierActionsOptions {
createTierId: () => string;
getTierColorByIndex: (index: number) => string;
isTierDrawerOpen: Ref<boolean>;
radiusTiers: Ref<RadiusTierDto[]>;
sortRadiusTiers: (source: RadiusTierDto[]) => RadiusTierDto[];
tierDrawerMode: Ref<DeliveryDrawerMode>;
tierForm: RadiusTierFormState;
}
export function createTierActions(options: CreateTierActionsOptions) {
/** 打开新增/编辑梯度抽屉,并填充表单。 */
function openTierDrawer(mode: DeliveryDrawerMode, tier?: RadiusTierDto) {
options.tierDrawerMode.value = mode;
if (mode === 'edit' && tier) {
options.tierForm.id = tier.id;
options.tierForm.minDistance = tier.minDistance;
options.tierForm.maxDistance = tier.maxDistance;
options.tierForm.deliveryFee = tier.deliveryFee;
options.tierForm.etaMinutes = tier.etaMinutes;
options.tierForm.minOrderAmount = tier.minOrderAmount;
options.tierForm.color = tier.color;
options.isTierDrawerOpen.value = true;
return;
}
const sorted = options.sortRadiusTiers(options.radiusTiers.value);
const lastTier = sorted.at(-1);
const nextMin = Number(lastTier?.maxDistance ?? 0);
options.tierForm.id = '';
options.tierForm.minDistance = nextMin;
options.tierForm.maxDistance = nextMin + 1;
options.tierForm.deliveryFee = Number(lastTier?.deliveryFee ?? 5);
options.tierForm.etaMinutes = Number(lastTier?.etaMinutes ?? 30);
options.tierForm.minOrderAmount = Number(lastTier?.minOrderAmount ?? 20);
options.tierForm.color = options.getTierColorByIndex(
options.radiusTiers.value.length,
);
options.isTierDrawerOpen.value = true;
}
/** 切换梯度抽屉可见性。 */
function setTierDrawerOpen(value: boolean) {
options.isTierDrawerOpen.value = value;
}
/** 更新梯度表单最小距离。 */
function setTierMinDistance(value: number) {
options.tierForm.minDistance = Math.max(0, Number(value || 0));
}
/** 更新梯度表单最大距离。 */
function setTierMaxDistance(value: number) {
options.tierForm.maxDistance = Math.max(0, Number(value || 0));
}
/** 更新梯度表单配送费。 */
function setTierDeliveryFee(value: number) {
options.tierForm.deliveryFee = Math.max(0, Number(value || 0));
}
/** 更新梯度表单预计送达时间。 */
function setTierEtaMinutes(value: number) {
options.tierForm.etaMinutes = Math.max(1, Number(value || 1));
}
/** 更新梯度表单起送金额。 */
function setTierMinOrderAmount(value: number) {
options.tierForm.minOrderAmount = Math.max(0, Number(value || 0));
}
/** 更新梯度表单颜色。 */
function setTierColor(value: string) {
options.tierForm.color = value || '#1677ff';
}
/** 提交梯度表单并更新列表。 */
function handleTierSubmit() {
// 1. 校验区间与字段合法性。
if (options.tierForm.maxDistance <= options.tierForm.minDistance) {
message.error('结束距离必须大于起始距离');
return;
}
if (
options.tierForm.deliveryFee < 0 ||
options.tierForm.minOrderAmount < 0
) {
message.error('金额字段不能小于 0');
return;
}
// 2. 校验与现有梯度区间冲突。
const hasOverlap = options.radiusTiers.value.some((item) => {
if (item.id === options.tierForm.id) return false;
const isDisjoint =
options.tierForm.maxDistance <= item.minDistance ||
options.tierForm.minDistance >= item.maxDistance;
return !isDisjoint;
});
if (hasOverlap) {
message.error('距离区间与已有梯度重叠,请调整后重试');
return;
}
// 3. 组装记录并写回列表。
const record: RadiusTierDto = {
id: options.tierForm.id || options.createTierId(),
minDistance: options.tierForm.minDistance,
maxDistance: options.tierForm.maxDistance,
deliveryFee: options.tierForm.deliveryFee,
etaMinutes: options.tierForm.etaMinutes,
minOrderAmount: options.tierForm.minOrderAmount,
color: options.tierForm.color,
};
options.radiusTiers.value =
options.tierDrawerMode.value === 'edit' && options.tierForm.id
? options.sortRadiusTiers(
options.radiusTiers.value.map((item) =>
item.id === options.tierForm.id ? record : item,
),
)
: options.sortRadiusTiers([...options.radiusTiers.value, record]);
options.isTierDrawerOpen.value = false;
message.success(
options.tierDrawerMode.value === 'edit' ? '梯度已更新' : '梯度已添加',
);
}
/** 删除指定梯度。 */
function handleDeleteTier(tierId: string) {
if (options.radiusTiers.value.length <= 1) {
message.warning('至少保留一个梯度');
return;
}
options.radiusTiers.value = options.radiusTiers.value.filter(
(item) => item.id !== tierId,
);
message.success('梯度已删除');
}
return {
handleDeleteTier,
handleTierSubmit,
openTierDrawer,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
};
}

View File

@@ -0,0 +1,154 @@
import type { Ref } from 'vue';
/**
* 文件职责:多边形区域编辑动作。
* 1. 管理区域抽屉开关与表单赋值。
* 2. 处理区域新增、编辑、删除。
*/
import type { PolygonZoneDto } from '#/api/store-delivery';
import type {
DeliveryDrawerMode,
PolygonZoneFormState,
} from '#/views/store/delivery/types';
import { message } from 'ant-design-vue';
interface CreateZoneActionsOptions {
createZoneId: () => string;
getTierColorByIndex: (index: number) => string;
isZoneDrawerOpen: Ref<boolean>;
polygonZones: Ref<PolygonZoneDto[]>;
sortPolygonZones: (source: PolygonZoneDto[]) => PolygonZoneDto[];
zoneDrawerMode: Ref<DeliveryDrawerMode>;
zoneForm: PolygonZoneFormState;
}
export function createZoneActions(options: CreateZoneActionsOptions) {
/** 打开新增/编辑区域抽屉,并填充表单。 */
function openZoneDrawer(mode: DeliveryDrawerMode, zone?: PolygonZoneDto) {
options.zoneDrawerMode.value = mode;
if (mode === 'edit' && zone) {
options.zoneForm.id = zone.id;
options.zoneForm.name = zone.name;
options.zoneForm.deliveryFee = zone.deliveryFee;
options.zoneForm.minOrderAmount = zone.minOrderAmount;
options.zoneForm.etaMinutes = zone.etaMinutes;
options.zoneForm.priority = zone.priority;
options.zoneForm.color = zone.color;
options.isZoneDrawerOpen.value = true;
return;
}
const nextPriority = options.polygonZones.value.length + 1;
options.zoneForm.id = '';
options.zoneForm.name = '';
options.zoneForm.deliveryFee = 5;
options.zoneForm.minOrderAmount = 20;
options.zoneForm.etaMinutes = 30;
options.zoneForm.priority = nextPriority;
options.zoneForm.color = options.getTierColorByIndex(nextPriority - 1);
options.isZoneDrawerOpen.value = true;
}
/** 切换区域抽屉可见性。 */
function setZoneDrawerOpen(value: boolean) {
options.isZoneDrawerOpen.value = value;
}
/** 更新区域名称。 */
function setZoneName(value: string) {
options.zoneForm.name = value;
}
/** 更新区域配送费。 */
function setZoneDeliveryFee(value: number) {
options.zoneForm.deliveryFee = Math.max(0, Number(value || 0));
}
/** 更新区域起送金额。 */
function setZoneMinOrderAmount(value: number) {
options.zoneForm.minOrderAmount = Math.max(0, Number(value || 0));
}
/** 更新区域预计送达时间。 */
function setZoneEtaMinutes(value: number) {
options.zoneForm.etaMinutes = Math.max(1, Number(value || 1));
}
/** 更新区域优先级。 */
function setZonePriority(value: number) {
options.zoneForm.priority = Math.max(1, Math.floor(Number(value || 1)));
}
/** 更新区域标识色。 */
function setZoneColor(value: string) {
options.zoneForm.color = value || '#1677ff';
}
/** 提交区域表单并更新列表。 */
function handleZoneSubmit() {
// 1. 必填校验。
const normalizedName = options.zoneForm.name.trim();
if (!normalizedName) {
message.error('请输入区域名称');
return;
}
// 2. 优先级冲突校验。
const hasPriorityConflict = options.polygonZones.value.some((item) => {
if (item.id === options.zoneForm.id) return false;
return item.priority === options.zoneForm.priority;
});
if (hasPriorityConflict) {
message.error('优先级已存在,请调整后重试');
return;
}
// 3. 写回列表。
const record: PolygonZoneDto = {
id: options.zoneForm.id || options.createZoneId(),
name: normalizedName,
deliveryFee: options.zoneForm.deliveryFee,
minOrderAmount: options.zoneForm.minOrderAmount,
etaMinutes: options.zoneForm.etaMinutes,
priority: options.zoneForm.priority,
color: options.zoneForm.color,
};
options.polygonZones.value =
options.zoneDrawerMode.value === 'edit' && options.zoneForm.id
? options.sortPolygonZones(
options.polygonZones.value.map((item) =>
item.id === options.zoneForm.id ? record : item,
),
)
: options.sortPolygonZones([...options.polygonZones.value, record]);
options.isZoneDrawerOpen.value = false;
message.success(
options.zoneDrawerMode.value === 'edit' ? '区域已更新' : '区域已添加',
);
}
/** 删除指定区域。 */
function handleDeleteZone(zoneId: string) {
options.polygonZones.value = options.polygonZones.value.filter(
(item) => item.id !== zoneId,
);
message.success('区域已删除');
}
return {
handleDeleteZone,
handleZoneSubmit,
openZoneDrawer,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
};
}

View File

@@ -0,0 +1,327 @@
import type {
DeliveryDrawerMode,
DeliverySettingsSnapshot,
PolygonZoneFormState,
RadiusTierFormState,
} from '../types';
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:配送设置页面主编排。
* 1. 维护页面状态与抽屉状态。
* 2. 组装数据加载、复制、梯度、区域动作。
* 3. 对外暴露视图层可直接消费的状态与方法。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
DEFAULT_DELIVERY_MODE,
DEFAULT_GENERAL_SETTINGS,
DEFAULT_POLYGON_ZONES,
DEFAULT_RADIUS_TIERS,
DELIVERY_MODE_OPTIONS,
TIER_COLOR_PALETTE,
} from './delivery-page/constants';
import { createCopyActions } from './delivery-page/copy-actions';
import { createDataActions } from './delivery-page/data-actions';
import {
cloneGeneralSettings,
clonePolygonZones,
cloneRadiusTiers,
createTierId,
createZoneId,
formatCurrency,
formatDistanceRange,
getTierColorByIndex,
sortPolygonZones,
sortRadiusTiers,
} from './delivery-page/helpers';
import { createTierActions } from './delivery-page/tier-actions';
import { createZoneActions } from './delivery-page/zone-actions';
export function useStoreDeliveryPage() {
// 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false);
const isSettingsLoading = ref(false);
const isSaving = ref(false);
const isCopySubmitting = ref(false);
// 2. 页面主业务数据。
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const deliveryMode = ref<DeliveryMode>(DEFAULT_DELIVERY_MODE);
const radiusTiers = ref<RadiusTierDto[]>(
cloneRadiusTiers(DEFAULT_RADIUS_TIERS),
);
const polygonZones = ref<PolygonZoneDto[]>(
clonePolygonZones(DEFAULT_POLYGON_ZONES),
);
const generalSettings = reactive<DeliveryGeneralSettingsDto>(
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
// 3. 页面弹窗与抽屉状态。
const isCopyModalOpen = ref(false);
const copyTargetStoreIds = ref<string[]>([]);
const snapshot = ref<DeliverySettingsSnapshot | null>(null);
const isTierDrawerOpen = ref(false);
const tierDrawerMode = ref<DeliveryDrawerMode>('create');
const tierForm = reactive<RadiusTierFormState>({
id: '',
minDistance: 0,
maxDistance: 1,
deliveryFee: 5,
etaMinutes: 30,
minOrderAmount: 20,
color: getTierColorByIndex(0),
});
const isZoneDrawerOpen = ref(false);
const zoneDrawerMode = ref<DeliveryDrawerMode>('create');
const zoneForm = reactive<PolygonZoneFormState>({
id: '',
name: '',
deliveryFee: 5,
minOrderAmount: 20,
etaMinutes: 30,
priority: 1,
color: getTierColorByIndex(0),
});
// 4. 页面衍生视图数据。
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 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 isRadiusMode = computed(() => deliveryMode.value === 'radius');
const isPageLoading = computed(() => isSettingsLoading.value);
const tierDrawerTitle = computed(() =>
tierDrawerMode.value === 'edit' ? '编辑梯度' : '添加梯度',
);
const zoneDrawerTitle = computed(() =>
zoneDrawerMode.value === 'edit' ? '编辑区域' : '新增区域',
);
// 5. 数据域动作装配。
const {
loadStoreSettings,
loadStores,
resetFromSnapshot,
saveCurrentSettings,
} = createDataActions({
generalSettings,
isSaving,
isSettingsLoading,
isStoreLoading,
mode: deliveryMode,
polygonZones,
radiusTiers,
selectedStoreId,
snapshot,
stores,
});
const {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
const {
handleDeleteTier,
handleTierSubmit,
openTierDrawer,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
} = createTierActions({
createTierId,
getTierColorByIndex,
isTierDrawerOpen,
radiusTiers,
sortRadiusTiers,
tierDrawerMode,
tierForm,
});
const {
handleDeleteZone,
handleZoneSubmit,
openZoneDrawer,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
} = createZoneActions({
createZoneId,
getTierColorByIndex,
isZoneDrawerOpen,
polygonZones,
sortPolygonZones,
zoneDrawerMode,
zoneForm,
});
// 6. 页面字段更新方法。
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setDeliveryMode(value: DeliveryMode) {
deliveryMode.value = value;
}
function setFreeDeliveryThreshold(value: null | number) {
if (value === null || value === undefined) {
generalSettings.freeDeliveryThreshold = null;
return;
}
generalSettings.freeDeliveryThreshold = Math.max(0, Number(value || 0));
}
function setMaxDeliveryDistance(value: number) {
generalSettings.maxDeliveryDistance = Math.max(0, Number(value || 0));
}
function setHourlyCapacityLimit(value: number) {
generalSettings.hourlyCapacityLimit = Math.max(
1,
Math.floor(Number(value || 1)),
);
}
function setEtaAdjustmentMinutes(value: number) {
generalSettings.etaAdjustmentMinutes = Math.max(
0,
Math.floor(Number(value || 0)),
);
}
// 7. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
deliveryMode.value = DEFAULT_DELIVERY_MODE;
radiusTiers.value = cloneRadiusTiers(DEFAULT_RADIUS_TIERS);
polygonZones.value = clonePolygonZones(DEFAULT_POLYGON_ZONES);
Object.assign(
generalSettings,
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
snapshot.value = null;
return;
}
await loadStoreSettings(storeId);
});
// 8. 页面首屏初始化。
onMounted(loadStores);
return {
DELIVERY_MODE_OPTIONS,
copyCandidates,
copyTargetStoreIds,
deliveryMode,
formatCurrency,
formatDistanceRange,
generalSettings,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteTier,
handleDeleteZone,
handleTierSubmit,
handleZoneSubmit,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isPageLoading,
isRadiusMode,
isSaving,
isStoreLoading,
isTierDrawerOpen,
isZoneDrawerOpen,
openCopyModal,
openTierDrawer,
openZoneDrawer,
polygonZones,
radiusTiers,
resetFromSnapshot,
saveCurrentSettings,
selectedStoreId,
selectedStoreName,
setDeliveryMode,
setEtaAdjustmentMinutes,
setFreeDeliveryThreshold,
setHourlyCapacityLimit,
setMaxDeliveryDistance,
setSelectedStoreId,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
storeOptions,
tierColorPalette: TIER_COLOR_PALETTE,
tierDrawerMode,
tierDrawerTitle,
tierForm,
toggleCopyStore,
zoneDrawerMode,
zoneDrawerTitle,
zoneForm,
};
}

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
/**
* 文件职责:配送设置页面主视图。
* 1. 组合配送模式、梯度/区域、通用设置子组件。
* 2. 承接门店维度切换与复制弹窗。
*/
import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import DeliveryCommonSettingsCard from './components/DeliveryCommonSettingsCard.vue';
import DeliveryModeCard from './components/DeliveryModeCard.vue';
import DeliveryTierDrawer from './components/DeliveryTierDrawer.vue';
import DeliveryZoneDrawer from './components/DeliveryZoneDrawer.vue';
import PolygonZoneSection from './components/PolygonZoneSection.vue';
import RadiusTierSection from './components/RadiusTierSection.vue';
import { useStoreDeliveryPage } from './composables/useStoreDeliveryPage';
const {
DELIVERY_MODE_OPTIONS,
copyCandidates,
copyTargetStoreIds,
deliveryMode,
formatCurrency,
formatDistanceRange,
generalSettings,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteTier,
handleDeleteZone,
handleTierSubmit,
handleZoneSubmit,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isPageLoading,
isRadiusMode,
isSaving,
isStoreLoading,
isTierDrawerOpen,
isZoneDrawerOpen,
openCopyModal,
openTierDrawer,
openZoneDrawer,
polygonZones,
radiusTiers,
resetFromSnapshot,
saveCurrentSettings,
selectedStoreId,
selectedStoreName,
setDeliveryMode,
setEtaAdjustmentMinutes,
setFreeDeliveryThreshold,
setHourlyCapacityLimit,
setMaxDeliveryDistance,
setSelectedStoreId,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
storeOptions,
tierColorPalette,
tierDrawerTitle,
tierForm,
toggleCopyStore,
zoneDrawerTitle,
zoneForm,
} = useStoreDeliveryPage();
</script>
<template>
<Page title="配送设置" content-class="space-y-4 page-store-delivery">
<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">
<DeliveryModeCard
:mode="deliveryMode"
:mode-options="DELIVERY_MODE_OPTIONS"
:radius-tiers="radiusTiers"
@change-mode="setDeliveryMode"
/>
<RadiusTierSection
v-if="isRadiusMode"
:tiers="radiusTiers"
:format-currency="formatCurrency"
:format-distance-range="formatDistanceRange"
:is-saving="isSaving"
@add="openTierDrawer('create')"
@edit="(tier) => openTierDrawer('edit', tier)"
@delete="handleDeleteTier"
/>
<PolygonZoneSection
v-else
:zones="polygonZones"
:format-currency="formatCurrency"
:is-saving="isSaving"
@add="openZoneDrawer('create')"
@edit="(zone) => openZoneDrawer('edit', zone)"
@delete="handleDeleteZone"
/>
<DeliveryCommonSettingsCard
:settings="generalSettings"
:is-saving="isSaving"
:on-set-free-delivery-threshold="setFreeDeliveryThreshold"
:on-set-max-delivery-distance="setMaxDeliveryDistance"
:on-set-hourly-capacity-limit="setHourlyCapacityLimit"
:on-set-eta-adjustment-minutes="setEtaAdjustmentMinutes"
@reset="resetFromSnapshot"
@save="saveCurrentSettings"
/>
</Spin>
</template>
<DeliveryTierDrawer
:open="isTierDrawerOpen"
:title="tierDrawerTitle"
:form="tierForm"
:color-palette="tierColorPalette"
:on-set-min-distance="setTierMinDistance"
:on-set-max-distance="setTierMaxDistance"
:on-set-delivery-fee="setTierDeliveryFee"
:on-set-eta-minutes="setTierEtaMinutes"
:on-set-min-order-amount="setTierMinOrderAmount"
:on-set-color="setTierColor"
@update:open="setTierDrawerOpen"
@submit="handleTierSubmit"
/>
<DeliveryZoneDrawer
:open="isZoneDrawerOpen"
:title="zoneDrawerTitle"
:form="zoneForm"
:color-palette="tierColorPalette"
:on-set-name="setZoneName"
:on-set-delivery-fee="setZoneDeliveryFee"
:on-set-min-order-amount="setZoneMinOrderAmount"
:on-set-eta-minutes="setZoneEtaMinutes"
:on-set-priority="setZonePriority"
:on-set-color="setZoneColor"
@update:open="setZoneDrawerOpen"
@submit="handleZoneSubmit"
/>
<CopyToStoresModal
v-model:open="isCopyModalOpen"
:copy-candidates="copyCandidates"
:target-store-ids="copyTargetStoreIds"
:is-all-checked="isCopyAllChecked"
:is-indeterminate="isCopyIndeterminate"
:is-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
title="复制配送设置到其他门店"
confirm-text="确认复制"
@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,10 @@
/* 文件职责:配送设置页面基础骨架样式。 */
.page-store-delivery {
max-width: 980px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
}

View File

@@ -0,0 +1,38 @@
/* 文件职责:通用配送设置区块样式。 */
.page-store-delivery {
.general-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 24px;
}
.general-field label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #4b5563;
}
.field-input-row {
display: flex;
gap: 6px;
align-items: center;
}
.field-input {
width: 140px;
}
.field-hint {
margin-top: 4px;
font-size: 11px;
color: #9ca3af;
}
.general-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
}

View File

@@ -0,0 +1,85 @@
/* 文件职责:配送设置抽屉与表单样式。 */
.delivery-tier-drawer-wrap,
.delivery-zone-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 14px;
}
.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: '*';
}
.distance-range-row {
display: flex;
gap: 8px;
align-items: center;
}
.distance-separator {
color: #9ca3af;
}
.drawer-input-with-unit {
display: flex;
gap: 6px;
align-items: center;
}
.drawer-input {
width: 120px;
}
.color-palette {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.color-dot {
position: relative;
width: 22px;
height: 22px;
cursor: pointer;
border: none;
border-radius: 50%;
}
.color-dot.active::after {
position: absolute;
inset: -3px;
content: '';
border: 2px solid #111827;
border-radius: 50%;
}
.drawer-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}

View File

@@ -0,0 +1,8 @@
/* 文件职责:配送设置页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './mode.less';
@import './tier.less';
@import './zone.less';
@import './common.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -0,0 +1,167 @@
/* 文件职责:配送模式切换与地图占位样式。 */
.page-store-delivery {
.delivery-mode-switch {
display: flex;
gap: 2px;
width: fit-content;
padding: 3px;
margin-bottom: 16px;
background: #f8f9fb;
border-radius: 8px;
}
.mode-switch-item {
padding: 6px 18px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
transition: all 0.2s ease;
}
.mode-switch-item.active {
font-weight: 600;
color: #1677ff;
background: #fff;
box-shadow: 0 2px 6px rgb(15 23 42 / 8%);
}
.delivery-map-area {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 320px;
overflow: hidden;
background: linear-gradient(180deg, #f0f5ff 0%, #f7faff 100%);
border: 1px dashed #adc6ff;
border-radius: 10px;
}
.map-grid {
position: absolute;
background: #d6e4ff;
}
.grid-h {
right: 0;
left: 0;
height: 1px;
}
.grid-v {
top: 0;
bottom: 0;
width: 1px;
}
.map-grid-h-1 {
top: 25%;
}
.map-grid-h-2 {
top: 50%;
}
.map-grid-h-3 {
top: 75%;
}
.map-grid-v-1 {
left: 25%;
}
.map-grid-v-2 {
left: 50%;
}
.map-grid-v-3 {
left: 75%;
}
.map-pin {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
font-size: 20px;
line-height: 1;
color: #1677ff;
transform: translate(-50%, -100%);
}
.radius-circle {
position: absolute;
top: 50%;
left: 50%;
border-style: dashed;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.radius-label {
position: absolute;
bottom: -16px;
left: 50%;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
transform: translateX(-50%);
}
.radius-1 {
width: 100px;
height: 100px;
background: rgb(82 196 26 / 8%);
border-color: #52c41a;
border-width: 2px;
}
.radius-1 .radius-label {
color: #52c41a;
}
.radius-2 {
width: 180px;
height: 180px;
background: rgb(250 173 20 / 5%);
border-color: #faad14;
border-width: 2px;
}
.radius-2 .radius-label {
color: #faad14;
}
.radius-3 {
width: 260px;
height: 260px;
background: rgb(255 77 79 / 4%);
border-color: #ff4d4f;
border-width: 2px;
}
.radius-3 .radius-label {
color: #ff4d4f;
}
.polygon-hint {
z-index: 3;
color: #3f87ff;
text-align: center;
}
.polygon-hint-title {
margin-bottom: 6px;
font-size: 16px;
font-weight: 600;
}
.polygon-hint-desc {
font-size: 13px;
opacity: 0.8;
}
}

View File

@@ -0,0 +1,47 @@
/* 文件职责:配送设置页面响应式规则。 */
.page-store-delivery {
@media (max-width: 992px) {
.tier-card {
grid-template-columns: 40px 1fr 1fr;
row-gap: 10px;
}
.tier-actions {
grid-column: span 3;
justify-content: flex-start;
}
}
@media (max-width: 768px) {
.general-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.delivery-map-area {
height: 260px;
}
.radius-1 {
width: 86px;
height: 86px;
}
.radius-2 {
width: 150px;
height: 150px;
}
.radius-3 {
width: 214px;
height: 214px;
}
}
}
@media (max-width: 768px) {
.drawer-form-grid {
grid-template-columns: 1fr;
gap: 0;
}
}

View File

@@ -0,0 +1,62 @@
/* 文件职责:半径梯度区块样式。 */
.page-store-delivery {
.tier-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tier-card {
display: grid;
grid-template-columns: 40px 1fr 1fr 1fr 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: #f8f9fb;
border-radius: 10px;
box-shadow: 0 2px 8px rgb(15 23 42 / 6%);
transition: all 0.2s ease;
}
.tier-card:hover {
box-shadow: 0 6px 16px rgb(15 23 42 / 10%);
}
.tier-num {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 12px;
font-weight: 600;
color: #fff;
border-radius: 50%;
}
.tier-field label {
display: block;
margin-bottom: 3px;
font-size: 11px;
color: #9ca3af;
}
.tier-field .value {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.tier-actions {
display: flex;
gap: 4px;
align-items: center;
justify-content: flex-end;
}
.delivery-tip {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
}

View File

@@ -0,0 +1,59 @@
/* 文件职责:多边形区域表格样式。 */
.page-store-delivery {
.zone-table-wrap {
overflow-x: auto;
}
.zone-table {
width: 100%;
min-width: 760px;
font-size: 13px;
border-collapse: collapse;
}
.zone-table th {
padding: 10px 12px;
font-weight: 600;
color: #6b7280;
text-align: left;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.zone-table td {
padding: 10px 12px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.zone-table tr:last-child td {
border-bottom: none;
}
.zone-table tr:hover td {
background: #f6faff;
}
.zone-op-column {
width: 140px;
}
.zone-op-cell {
min-width: 120px;
}
.zone-actions {
display: flex;
gap: 4px;
align-items: center;
}
.zone-color {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 6px;
vertical-align: middle;
border-radius: 3px;
}
}

View File

@@ -0,0 +1,40 @@
/**
* 文件职责:配送设置页面类型定义。
* 1. 声明页面表单态类型。
* 2. 声明页面快照与抽屉模式类型。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
export type DeliveryDrawerMode = 'create' | 'edit';
export interface RadiusTierFormState {
color: string;
deliveryFee: number;
etaMinutes: number;
id: string;
maxDistance: number;
minDistance: number;
minOrderAmount: number;
}
export interface PolygonZoneFormState {
color: string;
deliveryFee: number;
etaMinutes: number;
id: string;
minOrderAmount: number;
name: string;
priority: number;
}
export interface DeliverySettingsSnapshot {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
}

View File

@@ -1,107 +0,0 @@
<script setup lang="ts">
import type { StoreListItemDto } from '#/api/store';
import { computed } from 'vue';
import { Button, 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;
}>();
const selectedStoreIdSet = computed(() => new Set(props.copyTargetStoreIds));
function isStoreChecked(storeId: string) {
return selectedStoreIdSet.value.has(storeId);
}
function toggleStore(storeId: string) {
emit('toggleStore', {
storeId,
checked: !isStoreChecked(storeId),
});
}
function toggleAll() {
emit('checkAll', !props.isCopyAllChecked);
}
</script>
<template>
<Modal
:open="props.open"
title="复制营业时间到其他门店"
:width="650"
:footer="null"
:mask-closable="true"
wrap-class-name="copy-store-modal-wrap"
@update:open="(value) => emit('update:open', value)"
>
<div class="copy-modal-content">
<div class="copy-modal-warning">
<span class="copy-modal-warning-icon">!</span>
<span>将覆盖目标门店的现有设置请谨慎操作</span>
</div>
<div class="copy-all-row" @click="toggleAll">
<span
class="copy-check"
:class="{
checked: props.isCopyAllChecked,
indeterminate: props.isCopyIndeterminate && !props.isCopyAllChecked,
}"
>
<span class="copy-check-mark"></span>
</span>
<span>全选</span>
</div>
<div class="copy-store-list">
<div
v-for="store in props.copyCandidates"
:key="store.id"
class="copy-store-item"
@click="toggleStore(store.id)"
>
<span
class="copy-check"
:class="{ checked: isStoreChecked(store.id) }"
>
<span class="copy-check-mark"></span>
</span>
<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>
<div class="copy-modal-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isCopySubmitting"
:disabled="props.copyTargetStoreIds.length === 0"
@click="emit('submit')"
>
确认复制
</Button>
</div>
</Modal>
</template>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { Button, Card, Empty, Popconfirm, Select, Spin } from 'ant-design-vue';
import { Button, Card, Empty, Popconfirm, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.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';
@@ -31,7 +32,6 @@ const {
getSlotTypePillClass,
handleAddSlotSubmit,
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
handleDeleteHoliday,
handleHolidaySubmit,
@@ -88,25 +88,14 @@ const {
<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>
<StoreScopeToolbar
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0"
@update:selected-store-id="(value) => (selectedStoreId = value)"
@copy="openCopyModal"
/>
<template v-if="storeOptions.length === 0">
<Card :bordered="false">
@@ -324,18 +313,17 @@ const {
@submit="handleHolidaySubmit"
/>
<CopyStoreModal
<CopyToStoresModal
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"
:target-store-ids="copyTargetStoreIds"
:is-all-checked="isCopyAllChecked"
:is-indeterminate="isCopyIndeterminate"
:is-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
title="复制营业时间到其他门店"
confirm-text="确认复制"
@check-all="handleCopyCheckAll"
@store-change="
({ storeId, checked }) => handleCopyStoreChange(storeId, checked)
"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
@@ -344,4 +332,6 @@ const {
</Page>
</template>
<style src="./styles/index.less"></style>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -2,24 +2,6 @@
.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;

View File

@@ -1,185 +0,0 @@
/* 复制到其他门店弹窗:样式挂在 Modal wrap class避免 Teleport 后样式丢失。 */
.copy-store-modal-wrap {
.ant-modal {
width: 650px !important;
max-width: calc(100vw - 32px);
}
.ant-modal-content {
overflow: hidden;
padding: 0;
border-radius: 14px;
box-shadow: 0 10px 30px rgb(0 0 0 / 12%);
}
.ant-modal-header {
margin-bottom: 0;
padding: 20px 28px 14px;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-title {
font-size: 16px;
font-weight: 600;
color: #1f2329;
}
.ant-modal-close {
top: 18px;
right: 20px;
width: 32px;
height: 32px;
border-radius: 8px;
color: #8f959e;
transition: all 0.2s ease;
}
.ant-modal-close:hover {
color: #4e5969;
background: #f2f3f5;
}
.ant-modal-body {
padding: 0;
}
}
.copy-modal-content {
padding: 18px 28px 0;
}
.copy-modal-warning {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 18px;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
color: #c48b26;
background: #fffbe6;
border: 1px solid #f7e4a1;
border-radius: 10px;
}
.copy-modal-warning-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 11px;
font-weight: 700;
color: #fff;
background: #f5b034;
border-radius: 50%;
}
.copy-all-row {
display: flex;
gap: 10px;
align-items: center;
padding: 0 0 12px;
font-size: 13px;
font-weight: 500;
color: #1f2329;
cursor: pointer;
}
.copy-store-list {
max-height: 340px;
padding-top: 12px;
overflow-y: auto;
border-top: 1px solid #f0f0f0;
}
.copy-store-item {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 10px;
padding: 12px 12px 10px;
background: #fafbfc;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s ease;
}
.copy-store-item:hover {
background: #f3f6fb;
}
.copy-store-info {
flex: 1;
min-width: 0;
}
.copy-store-name {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: #1f2329;
}
.copy-store-address {
margin-top: 2px;
font-size: 12px;
line-height: 1.5;
color: #86909c;
}
.copy-check {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 2px;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 5px;
transition: all 0.2s ease;
}
.copy-check-mark {
display: none;
width: 6px;
height: 10px;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) translate(-1px, -1px);
}
.copy-check.checked {
background: #1677ff;
border-color: #1677ff;
}
.copy-check.checked .copy-check-mark {
display: block;
}
.copy-check.indeterminate {
background: #1677ff;
border-color: #1677ff;
}
.copy-check.indeterminate .copy-check-mark {
display: block;
width: 10px;
height: 2px;
border: 0;
background: #fff;
transform: none;
}
.copy-modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 14px 28px 18px;
margin-top: 10px;
border-top: 1px solid #f0f0f0;
}

View File

@@ -21,8 +21,7 @@
}
.ant-drawer-close {
margin-inline-start: auto;
margin-inline-end: 0;
margin-inline: auto 0;
color: #999;
border-radius: 6px;
transition: all 0.2s ease;
@@ -213,9 +212,9 @@
padding: 0 11px;
font-size: 13px;
color: #1f1f1f;
outline: none;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
}
@@ -230,10 +229,10 @@
padding: 8px 11px;
font-size: 13px;
color: #1f1f1f;
resize: vertical;
outline: none;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
resize: vertical;
transition: all 0.2s ease;
}
@@ -292,9 +291,9 @@
}
.day-edit-drawer-wrap .day-open-toggle input {
opacity: 0;
width: 0;
height: 0;
opacity: 0;
}
.day-edit-drawer-wrap .day-open-slider {
@@ -427,9 +426,9 @@
padding: 0 11px;
font-size: 13px;
color: #1f1f1f;
outline: none;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
}
@@ -610,9 +609,9 @@
padding: 0 11px;
font-size: 13px;
color: #1f1f1f;
outline: none;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
}
@@ -627,10 +626,10 @@
padding: 8px 11px;
font-size: 13px;
color: #1f1f1f;
resize: vertical;
outline: none;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
resize: vertical;
transition: all 0.2s ease;
}

View File

@@ -3,5 +3,4 @@
@import './week.less';
@import './holiday.less';
@import './drawer.less';
@import './copy-modal.less';
@import './responsive.less';

View File

@@ -1,19 +1,6 @@
/* 营业时间页面响应式规则。 */
.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

@@ -126,4 +126,6 @@ function handleExport() {
</Page>
</template>
<style src="./styles/index.less"></style>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -30,8 +30,8 @@
.page-store-list {
.store-drawer-form .store-drawer-section-title {
margin: 0 0 16px;
padding-left: 10px;
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
color: #1f1f1f;

View File

@@ -23,10 +23,10 @@
.store-list-table .store-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 500;
color: #1f1f1f;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* 文件职责:自提基本设置区块。
* 1. 展示基础配置字段。
* 2. 将字段更新与保存/重置事件抛给父级。
*/
import type { PickupBasicSettingsDto } from '#/api/store-pickup';
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
interface Props {
isSaving: boolean;
onSetAllowSameDayPickup: (value: boolean) => void;
onSetBookingDays: (value: number) => void;
onSetMaxItemsPerOrder: (value: null | number) => void;
settings: PickupBasicSettingsDto;
}
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="pickup-card">
<template #title>
<span class="section-title">基本设置</span>
</template>
<div class="pickup-form-row">
<div class="pickup-label">允许当天自提</div>
<div class="pickup-control">
<Switch
:checked="props.settings.allowSameDayPickup"
@update:checked="
(value) => props.onSetAllowSameDayPickup(Boolean(value))
"
/>
<span class="pickup-hint">开启后顾客可选择当天自提</span>
</div>
</div>
<div class="pickup-form-row">
<div class="pickup-label">可预约天数</div>
<div class="pickup-control">
<InputNumber
:value="props.settings.bookingDays"
:min="1"
:precision="0"
:controls="false"
class="pickup-number-input"
@update:value="(value) => props.onSetBookingDays(toNumber(value, 1))"
/>
<span class="pickup-unit"></span>
<span class="pickup-hint">顾客可提前预约的天数</span>
</div>
</div>
<div class="pickup-form-row">
<div class="pickup-label">单笔最大数量</div>
<div class="pickup-control">
<InputNumber
:value="props.settings.maxItemsPerOrder ?? undefined"
:min="0"
:precision="0"
:controls="false"
class="pickup-number-input"
placeholder="留空不限制"
@update:value="
(value) =>
props.onSetMaxItemsPerOrder(
value === null || value === undefined
? null
: toNumber(value, 0),
)
"
/>
<span class="pickup-unit"></span>
<span class="pickup-hint">留空则不限制</span>
</div>
</div>
<div class="pickup-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,111 @@
<script setup lang="ts">
/**
* 文件职责:大时段模式区块。
* 1. 展示自提时段表格。
* 2. 抛出新增、编辑、删除、启用切换事件。
*/
import type { PickupSlotDto } from '#/api/store-pickup';
import { Button, Card, Empty, Popconfirm, Switch } from 'ant-design-vue';
interface Props {
calcReservedPercent: (slot: PickupSlotDto) => number;
formatDayOfWeeksText: (dayOfWeeks: PickupSlotDto['dayOfWeeks']) => string;
isSaving: boolean;
slots: PickupSlotDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', slotId: string): void;
(event: 'edit', slot: PickupSlotDto): void;
(event: 'toggleEnabled', payload: { enabled: boolean; slotId: string }): void;
}>();
</script>
<template>
<Card :bordered="false" class="pickup-card">
<template #title>
<span class="section-title">自提时段</span>
</template>
<template #extra>
<Button type="primary" :disabled="props.isSaving" @click="emit('add')">
添加时段
</Button>
</template>
<div v-if="props.slots.length > 0" class="pickup-slot-table-wrap">
<table class="pickup-slot-table">
<thead>
<tr>
<th>时段名称</th>
<th>时间范围</th>
<th>截止(分钟)</th>
<th>容量</th>
<th>已预约</th>
<th>适用星期</th>
<th>状态</th>
<th class="op-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="slot in props.slots" :key="slot.id">
<td class="slot-name-cell">{{ slot.name }}</td>
<td>{{ slot.startTime }}-{{ slot.endTime }}</td>
<td>{{ slot.cutoffMinutes }}</td>
<td>{{ slot.capacity }}</td>
<td>
<div class="slot-progress">
<div class="slot-progress-bar">
<div
class="slot-progress-fill"
:style="{ width: `${props.calcReservedPercent(slot)}%` }"
></div>
</div>
<span class="slot-progress-text">
{{ Math.max(0, slot.reservedCount) }}/{{ slot.capacity }}
</span>
</div>
</td>
<td>
<span class="slot-weekday-tag">
{{ props.formatDayOfWeeksText(slot.dayOfWeeks) }}
</span>
</td>
<td>
<Switch
size="small"
:checked="slot.enabled"
@update:checked="
(value) =>
emit('toggleEnabled', {
slotId: slot.id,
enabled: Boolean(value),
})
"
/>
</td>
<td class="slot-op-cell">
<div class="slot-op-actions">
<Button type="link" size="small" @click="emit('edit', slot)">
编辑
</Button>
<Popconfirm
title="确认删除该时段吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', slot.id)"
>
<Button type="link" size="small" danger>删除</Button>
</Popconfirm>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Empty v-else description="暂无时段配置" />
</Card>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
/**
* 文件职责:精细时段规则区块。
* 1. 展示规则配置表单。
* 2. 透传规则变更与保存/重置事件。
*/
import type { PickupFineRuleDto, PickupWeekDay } from '#/api/store-pickup';
import type { PickupWeekDayOption } from '#/views/store/pickup/types';
import { Button, Card, InputNumber, Select, TimePicker } from 'ant-design-vue';
interface Props {
fineRule: PickupFineRuleDto;
intervalOptions: Array<{ label: string; value: number }>;
isDaySelected: (day: PickupWeekDay) => boolean;
isSaving: boolean;
onQuickSelectDays: (mode: 'all' | 'weekday' | 'weekend') => void;
onSetDayEndTime: (value: string) => void;
onSetDayStartTime: (value: string) => void;
onSetIntervalMinutes: (value: number) => void;
onSetMinAdvanceHours: (value: number) => void;
onSetSlotCapacity: (value: number) => void;
onToggleDay: (day: PickupWeekDay) => void;
weekDayOptions: PickupWeekDayOption[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'reset'): void;
(event: 'save'): void;
}>();
function toNumber(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function hasFormatMethod(
value: unknown,
): value is { format: (pattern: string) => string } {
return Boolean(
value &&
typeof value === 'object' &&
'format' in value &&
typeof (value as { format?: unknown }).format === 'function',
);
}
function readTimeValue(value: unknown) {
if (typeof value === 'string') return value;
if (hasFormatMethod(value)) return value.format('HH:mm');
return '';
}
</script>
<template>
<Card :bordered="false" class="pickup-card">
<template #title>
<span class="section-title">生成规则</span>
</template>
<div class="pickup-fine-grid">
<div class="pickup-fine-field">
<label>时间间隔</label>
<Select
:value="props.fineRule.intervalMinutes"
:options="props.intervalOptions"
@update:value="
(value) => props.onSetIntervalMinutes(toNumber(value, 30))
"
/>
</div>
<div class="pickup-fine-field">
<label>每个时段容量</label>
<div class="field-input-with-unit">
<InputNumber
:value="props.fineRule.slotCapacity"
:min="1"
:precision="0"
:controls="false"
class="pickup-number-input"
@update:value="
(value) => props.onSetSlotCapacity(toNumber(value, 1))
"
/>
<span></span>
</div>
<div class="field-hint">每个时间窗口最大接单量</div>
</div>
<div class="pickup-fine-field">
<label>每日开始时间</label>
<TimePicker
:value="props.fineRule.dayStartTime"
value-format="HH:mm"
format="HH:mm"
:allow-clear="false"
class="pickup-time-picker"
input-read-only
@update:value="
(value) => props.onSetDayStartTime(readTimeValue(value))
"
/>
</div>
<div class="pickup-fine-field">
<label>每日结束时间</label>
<TimePicker
:value="props.fineRule.dayEndTime"
value-format="HH:mm"
format="HH:mm"
:allow-clear="false"
class="pickup-time-picker"
input-read-only
@update:value="(value) => props.onSetDayEndTime(readTimeValue(value))"
/>
</div>
<div class="pickup-fine-field">
<label>最少提前预约</label>
<div class="field-input-with-unit">
<InputNumber
:value="props.fineRule.minAdvanceHours"
:min="0"
:precision="0"
:controls="false"
class="pickup-number-input"
@update:value="
(value) => props.onSetMinAdvanceHours(toNumber(value, 0))
"
/>
<span>小时</span>
</div>
<div class="field-hint">下单时间距取餐时间的最小间隔</div>
</div>
</div>
<div class="pickup-fine-week-wrap">
<label>适用星期</label>
<div class="pickup-day-pill-group">
<button
v-for="item in props.weekDayOptions"
:key="item.value"
type="button"
class="pickup-day-pill"
:class="{ selected: props.isDaySelected(item.value) }"
@click="props.onToggleDay(item.value)"
>
{{ item.label }}
</button>
</div>
<div class="pickup-quick-actions">
<button type="button" @click="props.onQuickSelectDays('all')">
全选
</button>
<button type="button" @click="props.onQuickSelectDays('weekday')">
工作日
</button>
<button type="button" @click="props.onQuickSelectDays('weekend')">
周末
</button>
</div>
</div>
<div class="pickup-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,34 @@
<script setup lang="ts">
/**
* 文件职责:自提模式切换条。
* 1. 展示大时段/精细时段切换入口。
* 2. 抛出模式变更事件。
*/
import type { PickupMode } from '#/api/store-pickup';
interface Props {
mode: PickupMode;
options: Array<{ label: string; value: PickupMode }>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'change', mode: PickupMode): void;
}>();
</script>
<template>
<div class="pickup-mode-switch">
<button
v-for="item in props.options"
:key="item.value"
type="button"
class="pickup-mode-item"
:class="{ active: props.mode === item.value }"
@click="emit('change', item.value)"
>
{{ item.label }}
</button>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* 文件职责:精细模式时段预览区块。
* 1. 展示按日切换与时段状态网格。
* 2. 仅承载展示逻辑,不处理数据持久化。
*/
import type {
PickupPreviewDayDto,
PickupPreviewSlotDto,
} from '#/api/store-pickup';
import { Card, Empty } from 'ant-design-vue';
interface Props {
days: PickupPreviewDayDto[];
selectedDate: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'selectDate', date: string): void;
}>();
const previewSubtitle = '根据规则自动生成,以下为预览效果';
function getPreviewStatusText(slot: PickupPreviewSlotDto) {
if (slot.status === 'expired') return '已过期';
if (slot.status === 'full') return '已满';
return `剩余 ${slot.remainingCount}`;
}
</script>
<template>
<Card :bordered="false" class="pickup-card">
<template #title>
<span class="section-title">时段预览</span>
</template>
<template #extra>
<span class="pickup-preview-subtitle">{{ previewSubtitle }}</span>
</template>
<template v-if="props.days.length > 0">
<div class="pickup-preview-day-tabs">
<button
v-for="day in props.days"
:key="day.date"
type="button"
class="pickup-preview-day-tab"
:class="{ active: props.selectedDate === day.date }"
@click="emit('selectDate', day.date)"
>
<span class="tab-date">{{ day.label }}</span>
<span class="tab-sub">{{ day.subLabel }}</span>
</button>
</div>
<div
v-for="day in props.days"
v-show="props.selectedDate === day.date"
:key="`grid-${day.date}`"
class="pickup-preview-slot-grid"
>
<div
v-for="slot in day.slots"
:key="`${day.date}-${slot.time}`"
class="pickup-preview-slot-cell"
:class="slot.status"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status">{{ getPreviewStatusText(slot) }}</div>
</div>
</div>
<div class="pickup-preview-legend">
<span>
<i class="legend-dot expired"></i>
已过期
</span>
<span>
<i class="legend-dot available"></i>
可预约
</span>
<span>
<i class="legend-dot almost"></i>
即将满
</span>
<span>
<i class="legend-dot full"></i>
已满
</span>
</div>
</template>
<Empty v-else description="暂无预览数据,请先完善生成规则" />
</Card>
</template>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
/**
* 文件职责:大时段新增/编辑抽屉。
* 1. 展示时段编辑表单。
* 2. 通过回调更新父级状态并提交。
*/
import type { PickupWeekDay } from '#/api/store-pickup';
import type {
PickupSlotFormState,
PickupWeekDayOption,
} from '#/views/store/pickup/types';
import {
Button,
Drawer,
Input,
InputNumber,
Switch,
TimePicker,
} from 'ant-design-vue';
interface Props {
form: PickupSlotFormState;
isDaySelected: (day: PickupWeekDay) => boolean;
isSaving: boolean;
onQuickSelectDays: (mode: 'all' | 'weekday' | 'weekend') => void;
onSetCapacity: (value: number) => void;
onSetCutoffMinutes: (value: number) => void;
onSetEnabled: (value: boolean) => void;
onSetEndTime: (value: string) => void;
onSetName: (value: string) => void;
onSetStartTime: (value: string) => void;
onToggleDay: (day: PickupWeekDay) => void;
open: boolean;
title: string;
weekDayOptions: PickupWeekDayOption[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
function hasFormatMethod(
value: unknown,
): value is { format: (pattern: string) => string } {
return Boolean(
value &&
typeof value === 'object' &&
'format' in value &&
typeof (value as { format?: unknown }).format === 'function',
);
}
function readTimeValue(value: unknown) {
if (typeof value === 'string') return value;
if (hasFormatMethod(value)) return value.format('HH:mm');
return '';
}
</script>
<template>
<Drawer
class="pickup-slot-drawer-wrap"
:open="props.open"
:title="props.title"
:width="480"
:mask-closable="true"
@update:open="(value) => emit('update:open', value)"
>
<div class="pickup-drawer-field">
<label class="drawer-label required">时段名称</label>
<Input
:value="props.form.name"
:maxlength="20"
placeholder="如:上午时段"
@input="(event) => props.onSetName(readInputValue(event))"
/>
</div>
<div class="pickup-drawer-grid">
<div class="pickup-drawer-field">
<label class="drawer-label required">开始时间</label>
<TimePicker
:value="props.form.startTime"
value-format="HH:mm"
format="HH:mm"
:allow-clear="false"
class="pickup-time-picker"
input-read-only
@update:value="(value) => props.onSetStartTime(readTimeValue(value))"
/>
</div>
<div class="pickup-drawer-field">
<label class="drawer-label required">结束时间</label>
<TimePicker
:value="props.form.endTime"
value-format="HH:mm"
format="HH:mm"
:allow-clear="false"
class="pickup-time-picker"
input-read-only
@update:value="(value) => props.onSetEndTime(readTimeValue(value))"
/>
</div>
</div>
<div class="pickup-drawer-grid">
<div class="pickup-drawer-field">
<label class="drawer-label required">截止时间</label>
<div class="field-input-with-unit">
<InputNumber
:value="props.form.cutoffMinutes"
:min="0"
:precision="0"
:controls="false"
class="pickup-number-input"
@update:value="
(value) => props.onSetCutoffMinutes(toNumber(value, 0))
"
/>
<span>分钟</span>
</div>
<div class="field-hint">时段开始前停止接单</div>
</div>
<div class="pickup-drawer-field">
<label class="drawer-label required">容量上限</label>
<div class="field-input-with-unit">
<InputNumber
:value="props.form.capacity"
:min="0"
:precision="0"
:controls="false"
class="pickup-number-input"
@update:value="(value) => props.onSetCapacity(toNumber(value, 0))"
/>
<span></span>
</div>
<div class="field-hint">该时段最大接单数</div>
</div>
</div>
<div class="pickup-drawer-field">
<label class="drawer-label required">适用星期</label>
<div class="pickup-day-pill-group">
<button
v-for="item in props.weekDayOptions"
:key="item.value"
type="button"
class="pickup-day-pill"
:class="{ selected: props.isDaySelected(item.value) }"
@click="props.onToggleDay(item.value)"
>
{{ item.label }}
</button>
</div>
<div class="pickup-quick-actions">
<button type="button" @click="props.onQuickSelectDays('all')">
全选
</button>
<button type="button" @click="props.onQuickSelectDays('weekday')">
工作日
</button>
<button type="button" @click="props.onQuickSelectDays('weekend')">
周末
</button>
</div>
</div>
<div class="pickup-drawer-field">
<label class="drawer-label">启用状态</label>
<Switch
:checked="props.form.enabled"
@update:checked="(value) => props.onSetEnabled(Boolean(value))"
/>
</div>
<template #footer>
<div class="pickup-drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.form.id ? '保存修改' : '确认添加' }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,117 @@
/**
* 文件职责:自提设置页面静态常量。
* 1. 维护默认值、模式选项、星期枚举。
* 2. 维护精细规则可选间隔等静态配置。
*/
import type {
PickupBasicSettingsDto,
PickupFineRuleDto,
PickupMode,
PickupSlotDto,
PickupWeekDay,
} from '#/api/store-pickup';
import type { PickupWeekDayOption } from '#/views/store/pickup/types';
export const PICKUP_MODE_OPTIONS: Array<{ label: string; value: PickupMode }> =
[
{ label: '大时段模式', value: 'big' },
{ label: '精细时段模式', value: 'fine' },
];
export const DEFAULT_PICKUP_MODE: PickupMode = 'big';
export const WEEKDAY_OPTIONS: PickupWeekDayOption[] = [
{ label: '周一', value: 0 },
{ label: '周二', value: 1 },
{ label: '周三', value: 2 },
{ label: '周四', value: 3 },
{ label: '周五', value: 4 },
{ label: '周六', value: 5 },
{ label: '周日', value: 6 },
];
export const ALL_WEEK_DAYS: PickupWeekDay[] = [0, 1, 2, 3, 4, 5, 6];
export const WEEKDAY_ONLY: PickupWeekDay[] = [0, 1, 2, 3, 4];
export const WEEKEND_ONLY: PickupWeekDay[] = [5, 6];
export const FINE_INTERVAL_OPTIONS: Array<{ label: string; value: number }> = [
{ label: '15 分钟', value: 15 },
{ label: '20 分钟', value: 20 },
{ label: '25 分钟', value: 25 },
{ label: '30 分钟', value: 30 },
{ label: '45 分钟', value: 45 },
{ label: '60 分钟', value: 60 },
];
export const DEFAULT_PICKUP_BASIC_SETTINGS: PickupBasicSettingsDto = {
allowSameDayPickup: true,
bookingDays: 3,
maxItemsPerOrder: 20,
};
export const DEFAULT_BIG_SLOTS: PickupSlotDto[] = [
{
id: 'slot-morning',
name: '上午时段',
startTime: '09:00',
endTime: '11:30',
cutoffMinutes: 30,
capacity: 20,
reservedCount: 5,
dayOfWeeks: [...WEEKDAY_ONLY],
enabled: true,
},
{
id: 'slot-noon',
name: '午间时段',
startTime: '11:30',
endTime: '14:00',
cutoffMinutes: 20,
capacity: 30,
reservedCount: 12,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
},
{
id: 'slot-afternoon',
name: '下午时段',
startTime: '14:00',
endTime: '17:00',
cutoffMinutes: 30,
capacity: 15,
reservedCount: 3,
dayOfWeeks: [...WEEKDAY_ONLY],
enabled: true,
},
{
id: 'slot-evening',
name: '晚间时段',
startTime: '17:00',
endTime: '20:30',
cutoffMinutes: 30,
capacity: 25,
reservedCount: 8,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
},
{
id: 'slot-weekend',
name: '周末特惠',
startTime: '10:00',
endTime: '15:00',
cutoffMinutes: 45,
capacity: 40,
reservedCount: 18,
dayOfWeeks: [...WEEKEND_ONLY],
enabled: false,
},
];
export const DEFAULT_FINE_RULE: PickupFineRuleDto = {
intervalMinutes: 30,
slotCapacity: 5,
dayStartTime: '09:00',
dayEndTime: '20:30',
minAdvanceHours: 2,
dayOfWeeks: [...ALL_WEEK_DAYS],
};

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 { copyStorePickupSettingsApi } from '#/api/store-pickup';
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((store) => store.id)
: [];
}
/** 提交复制请求。 */
async function handleCopySubmit() {
if (!options.selectedStoreId.value) return;
if (options.copyTargetStoreIds.value.length === 0) {
message.error('请至少选择一个目标门店');
return;
}
options.isCopySubmitting.value = true;
try {
await copyStorePickupSettingsApi({
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,263 @@
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:自提设置数据动作。
* 1. 加载门店列表与门店自提配置。
* 2. 保存基本设置、大时段、精细规则并维护快照。
*/
import type {
PickupBasicSettingsDto,
PickupFineRuleDto,
PickupMode,
PickupPreviewDayDto,
PickupSlotDto,
} from '#/api/store-pickup';
import type { PickupSettingsSnapshot } from '#/views/store/pickup/types';
import { message } from 'ant-design-vue';
import { getStoreListApi } from '#/api/store';
import {
getStorePickupSettingsApi,
savePickupBasicSettingsApi,
savePickupFineRuleApi,
savePickupSlotsApi,
} from '#/api/store-pickup';
import {
DEFAULT_BIG_SLOTS,
DEFAULT_FINE_RULE,
DEFAULT_PICKUP_BASIC_SETTINGS,
DEFAULT_PICKUP_MODE,
} from './constants';
import {
cloneBasicSettings,
cloneBigSlots,
cloneFineRule,
clonePreviewDays,
createSettingsSnapshot,
generatePreviewDays,
sortSlots,
} from './helpers';
interface CreateDataActionsOptions {
basicSettings: PickupBasicSettingsDto;
bigSlots: Ref<PickupSlotDto[]>;
fineRule: PickupFineRuleDto;
isPageLoading: Ref<boolean>;
isSavingBasic: Ref<boolean>;
isSavingFineRule: Ref<boolean>;
isSavingSlots: Ref<boolean>;
isStoreLoading: Ref<boolean>;
mode: Ref<PickupMode>;
previewDays: Ref<PickupPreviewDayDto[]>;
selectedStoreId: Ref<string>;
snapshot: Ref<null | PickupSettingsSnapshot>;
stores: Ref<StoreListItemDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
/** 同步基本设置,保持 reactive 引用不变。 */
function syncBasicSettings(next: PickupBasicSettingsDto) {
options.basicSettings.allowSameDayPickup = next.allowSameDayPickup;
options.basicSettings.bookingDays = next.bookingDays;
options.basicSettings.maxItemsPerOrder = next.maxItemsPerOrder;
}
/** 同步精细规则,保持 reactive 引用不变。 */
function syncFineRule(next: PickupFineRuleDto) {
options.fineRule.intervalMinutes = next.intervalMinutes;
options.fineRule.slotCapacity = next.slotCapacity;
options.fineRule.dayStartTime = next.dayStartTime;
options.fineRule.dayEndTime = next.dayEndTime;
options.fineRule.minAdvanceHours = next.minAdvanceHours;
options.fineRule.dayOfWeeks = [...next.dayOfWeeks];
}
/** 创建当前页面快照,供重置回滚使用。 */
function buildCurrentSnapshot() {
return createSettingsSnapshot({
mode: options.mode.value,
basicSettings: options.basicSettings,
bigSlots: options.bigSlots.value,
fineRule: options.fineRule,
previewDays: options.previewDays.value,
});
}
/** 应用默认配置(接口异常兜底)。 */
function applyDefaultSettings() {
options.mode.value = DEFAULT_PICKUP_MODE;
syncBasicSettings(cloneBasicSettings(DEFAULT_PICKUP_BASIC_SETTINGS));
options.bigSlots.value = sortSlots(cloneBigSlots(DEFAULT_BIG_SLOTS));
syncFineRule(cloneFineRule(DEFAULT_FINE_RULE));
options.previewDays.value = generatePreviewDays(options.fineRule);
}
/** 应用快照到当前页面状态。 */
function applySnapshot(snapshot: PickupSettingsSnapshot) {
options.mode.value = snapshot.mode;
syncBasicSettings(snapshot.basicSettings);
options.bigSlots.value = sortSlots(cloneBigSlots(snapshot.bigSlots));
syncFineRule(snapshot.fineRule);
options.previewDays.value = clonePreviewDays(snapshot.previewDays);
}
/** 加载指定门店自提设置。 */
async function loadStoreSettings(storeId: string) {
options.isPageLoading.value = true;
try {
const currentStoreId = storeId;
const result = await getStorePickupSettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return;
options.mode.value = result.mode ?? DEFAULT_PICKUP_MODE;
syncBasicSettings({
...DEFAULT_PICKUP_BASIC_SETTINGS,
...result.basicSettings,
});
options.bigSlots.value = sortSlots(
result.bigSlots?.length
? result.bigSlots
: cloneBigSlots(DEFAULT_BIG_SLOTS),
);
syncFineRule({
...DEFAULT_FINE_RULE,
...result.fineRule,
});
options.previewDays.value =
result.previewDays?.length > 0
? clonePreviewDays(result.previewDays)
: generatePreviewDays(options.fineRule);
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 = '';
options.snapshot.value = null;
applyDefaultSettings();
return;
}
const hasSelected = options.stores.value.some(
(item) => item.id === options.selectedStoreId.value,
);
if (!hasSelected) {
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 = '';
options.snapshot.value = null;
applyDefaultSettings();
} finally {
options.isStoreLoading.value = false;
}
}
/** 保存基本设置并更新快照。 */
async function saveBasicSettings() {
if (!options.selectedStoreId.value) return;
options.isSavingBasic.value = true;
try {
await savePickupBasicSettingsApi({
storeId: options.selectedStoreId.value,
mode: options.mode.value,
basicSettings: cloneBasicSettings(options.basicSettings),
});
options.snapshot.value = buildCurrentSnapshot();
message.success('基本设置已保存');
} catch (error) {
console.error(error);
} finally {
options.isSavingBasic.value = false;
}
}
/** 保存大时段并更新快照。 */
async function saveBigSlots() {
if (!options.selectedStoreId.value) return;
options.isSavingSlots.value = true;
try {
await savePickupSlotsApi({
storeId: options.selectedStoreId.value,
mode: options.mode.value,
slots: cloneBigSlots(options.bigSlots.value),
});
options.snapshot.value = buildCurrentSnapshot();
message.success('大时段配置已保存');
} catch (error) {
console.error(error);
} finally {
options.isSavingSlots.value = false;
}
}
/** 保存精细规则并刷新预览。 */
async function saveFineRule() {
if (!options.selectedStoreId.value) return;
options.isSavingFineRule.value = true;
try {
await savePickupFineRuleApi({
storeId: options.selectedStoreId.value,
mode: options.mode.value,
fineRule: cloneFineRule(options.fineRule),
});
options.previewDays.value = generatePreviewDays(options.fineRule);
options.snapshot.value = buildCurrentSnapshot();
message.success('精细规则已保存');
} catch (error) {
console.error(error);
} finally {
options.isSavingFineRule.value = false;
}
}
/** 重置到最近一次快照。 */
function resetFromSnapshot() {
if (!options.snapshot.value) {
applyDefaultSettings();
return;
}
applySnapshot(options.snapshot.value);
message.success('已恢复到最近一次保存状态');
}
return {
loadStoreSettings,
loadStores,
resetFromSnapshot,
saveBasicSettings,
saveBigSlots,
saveFineRule,
};
}

View File

@@ -0,0 +1,140 @@
import type { Ref } from 'vue';
/**
* 文件职责:精细规则动作。
* 1. 管理精细规则字段与适用星期选择。
* 2. 处理规则校验与保存提交流程。
*/
import type {
PickupFineRuleDto,
PickupPreviewDayDto,
PickupWeekDay,
} from '#/api/store-pickup';
import { message } from 'ant-design-vue';
import { ALL_WEEK_DAYS, WEEKDAY_ONLY, WEEKEND_ONLY } from './constants';
import { generatePreviewDays, parseTimeToMinutes } from './helpers';
interface CreateFineRuleActionsOptions {
fineRule: PickupFineRuleDto;
previewDays: Ref<PickupPreviewDayDto[]>;
saveFineRule: () => Promise<void>;
selectedPreviewDate: Ref<string>;
}
export function createFineRuleActions(options: CreateFineRuleActionsOptions) {
function setFineIntervalMinutes(value: number) {
options.fineRule.intervalMinutes = Math.max(
5,
Math.floor(Number(value || 5)),
);
refreshPreviewDays();
}
function setFineSlotCapacity(value: number) {
options.fineRule.slotCapacity = Math.max(1, Math.floor(Number(value || 1)));
refreshPreviewDays();
}
function setFineDayStartTime(value: string) {
options.fineRule.dayStartTime = value;
refreshPreviewDays();
}
function setFineDayEndTime(value: string) {
options.fineRule.dayEndTime = value;
refreshPreviewDays();
}
function setFineMinAdvanceHours(value: number) {
options.fineRule.minAdvanceHours = Math.max(
0,
Math.floor(Number(value || 0)),
);
refreshPreviewDays();
}
function isFineDaySelected(day: PickupWeekDay) {
return options.fineRule.dayOfWeeks.includes(day);
}
function toggleFineDay(day: PickupWeekDay) {
options.fineRule.dayOfWeeks = options.fineRule.dayOfWeeks.includes(day)
? options.fineRule.dayOfWeeks.filter((item) => item !== day)
: [...options.fineRule.dayOfWeeks, day].toSorted((a, b) => a - b);
refreshPreviewDays();
}
function quickSelectFineDays(mode: 'all' | 'weekday' | 'weekend') {
if (mode === 'all') {
options.fineRule.dayOfWeeks = [...ALL_WEEK_DAYS];
} else if (mode === 'weekday') {
options.fineRule.dayOfWeeks = [...WEEKDAY_ONLY];
} else {
options.fineRule.dayOfWeeks = [...WEEKEND_ONLY];
}
refreshPreviewDays();
}
function setSelectedPreviewDate(date: string) {
options.selectedPreviewDate.value = date;
}
/** 重新计算预览,保障规则修改后页面即时反馈。 */
function refreshPreviewDays() {
options.previewDays.value = generatePreviewDays(options.fineRule);
if (options.previewDays.value.length === 0) {
options.selectedPreviewDate.value = '';
return;
}
const hasCurrent = options.previewDays.value.some(
(day) => day.date === options.selectedPreviewDate.value,
);
if (!hasCurrent) {
const firstDay = options.previewDays.value[0];
options.selectedPreviewDate.value = firstDay?.date ?? '';
}
}
/** 校验精细规则并提交保存。 */
async function handleSaveFineRule() {
// 1. 核心字段校验。
if (options.fineRule.dayOfWeeks.length === 0) {
message.error('请至少选择一个适用星期');
return;
}
if (options.fineRule.slotCapacity <= 0) {
message.error('每个时段容量必须大于 0');
return;
}
if (options.fineRule.intervalMinutes <= 0) {
message.error('时间间隔必须大于 0');
return;
}
const start = parseTimeToMinutes(options.fineRule.dayStartTime);
const end = parseTimeToMinutes(options.fineRule.dayEndTime);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
message.error('每日结束时间必须晚于开始时间');
return;
}
// 2. 提交保存。
await options.saveFineRule();
}
return {
handleSaveFineRule,
isFineDaySelected,
quickSelectFineDays,
refreshPreviewDays,
setFineDayEndTime,
setFineDayStartTime,
setFineIntervalMinutes,
setFineMinAdvanceHours,
setFineSlotCapacity,
setSelectedPreviewDate,
toggleFineDay,
};
}

View File

@@ -0,0 +1,291 @@
/**
* 文件职责:自提设置页面纯函数工具。
* 1. 负责克隆、格式化、校验、排序等纯逻辑。
* 2. 负责根据精细规则生成预览数据。
*/
import type {
PickupBasicSettingsDto,
PickupFineRuleDto,
PickupPreviewDayDto,
PickupPreviewSlotDto,
PickupPreviewStatus,
PickupSlotDto,
PickupWeekDay,
} from '#/api/store-pickup';
import type { PickupSettingsSnapshot } from '#/views/store/pickup/types';
import {
ALL_WEEK_DAYS,
WEEKDAY_ONLY,
WEEKDAY_OPTIONS,
WEEKEND_ONLY,
} from './constants';
/** 深拷贝基础设置对象。 */
export function cloneBasicSettings(source: PickupBasicSettingsDto) {
return { ...source };
}
/** 深拷贝大时段列表。 */
export function cloneBigSlots(source: PickupSlotDto[]) {
return source.map((item) => ({
...item,
dayOfWeeks: [...item.dayOfWeeks],
}));
}
/** 深拷贝精细规则。 */
export function cloneFineRule(source: PickupFineRuleDto) {
return {
...source,
dayOfWeeks: [...source.dayOfWeeks],
};
}
/** 深拷贝预览列表。 */
export function clonePreviewDays(source: PickupPreviewDayDto[]) {
return source.map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
/** 组装快照,用于重置场景。 */
export function createSettingsSnapshot(payload: {
basicSettings: PickupBasicSettingsDto;
bigSlots: PickupSlotDto[];
fineRule: PickupFineRuleDto;
mode: 'big' | 'fine';
previewDays: PickupPreviewDayDto[];
}): PickupSettingsSnapshot {
return {
mode: payload.mode,
basicSettings: cloneBasicSettings(payload.basicSettings),
bigSlots: cloneBigSlots(payload.bigSlots),
fineRule: cloneFineRule(payload.fineRule),
previewDays: clonePreviewDays(payload.previewDays),
};
}
/** HH:mm 转分钟。 */
export function parseTimeToMinutes(time: string) {
const matched = /^(\d{2}):(\d{2})$/.exec(time);
if (!matched) return Number.NaN;
return Number(matched[1]) * 60 + Number(matched[2]);
}
/** 按开始时间升序排序时段。 */
export function sortSlots(source: PickupSlotDto[]) {
return cloneBigSlots(source).toSorted((a, b) => {
const diff =
parseTimeToMinutes(a.startTime) - parseTimeToMinutes(b.startTime);
if (diff !== 0) return diff;
return a.name.localeCompare(b.name);
});
}
/** 生成时段唯一 ID。 */
export function createSlotId() {
return `pickup-slot-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 根据星期数组格式化展示文案。 */
export function formatDayOfWeeksText(dayOfWeeks: PickupWeekDay[]) {
const sorted = [...new Set(dayOfWeeks)].toSorted((a, b) => a - b);
if (isSameDaySet(sorted, ALL_WEEK_DAYS)) return '每天';
if (isSameDaySet(sorted, WEEKDAY_ONLY)) return '周一至周五';
if (isSameDaySet(sorted, WEEKEND_ONLY)) return '周六周日';
return sorted
.map(
(day) => WEEKDAY_OPTIONS.find((item) => item.value === day)?.label ?? '',
)
.filter(Boolean)
.join('、');
}
/** 计算预约使用率百分比。 */
export function calcReservedPercent(slot: PickupSlotDto) {
if (slot.capacity <= 0) return 0;
const ratio = (Math.max(0, slot.reservedCount) / slot.capacity) * 100;
return Math.min(100, Math.round(ratio));
}
/** 校验新增/编辑时段。 */
export function validateSlotForm(payload: {
capacity: number;
cutoffMinutes: number;
dayOfWeeks: PickupWeekDay[];
endTime: string;
name: string;
slotId?: string;
slots: PickupSlotDto[];
startTime: string;
}) {
// 1. 必填与基础值校验。
if (!payload.name.trim()) return '请输入时段名称';
if (payload.dayOfWeeks.length === 0) return '请至少选择一个适用星期';
const start = parseTimeToMinutes(payload.startTime);
const end = parseTimeToMinutes(payload.endTime);
if (!Number.isFinite(start) || !Number.isFinite(end))
return '请选择正确的时间';
if (end <= start) return '结束时间必须晚于开始时间';
if (payload.cutoffMinutes < 0) return '截止分钟不能小于 0';
if (payload.capacity < 0) return '容量不能小于 0';
// 2. 同星期时段重叠校验。
const hasOverlap = payload.slots.some((item) => {
if (payload.slotId && item.id === payload.slotId) return false;
const overlapDays = item.dayOfWeeks.filter((day) =>
payload.dayOfWeeks.includes(day),
);
if (overlapDays.length === 0) return false;
const itemStart = parseTimeToMinutes(item.startTime);
const itemEnd = parseTimeToMinutes(item.endTime);
return !(end <= itemStart || start >= itemEnd);
});
if (hasOverlap) return '同一星期存在时间重叠,请调整后重试';
return '';
}
/** 生成 3 天预览数据。 */
export function generatePreviewDays(
fineRule: PickupFineRuleDto,
baseDate = new Date(),
) {
const startMinutes = parseTimeToMinutes(fineRule.dayStartTime);
const endMinutes = parseTimeToMinutes(fineRule.dayEndTime);
if (!Number.isFinite(startMinutes) || !Number.isFinite(endMinutes)) return [];
if (endMinutes <= startMinutes || fineRule.intervalMinutes <= 0) return [];
return Array.from({ length: 3 }).map((_, index) => {
const date = addDays(baseDate, index);
const dateKey = toDateOnly(date);
const dayOfWeek = toPickupWeekDay(date);
const isEnabledDay = fineRule.dayOfWeeks.includes(dayOfWeek);
const slots = isEnabledDay
? generateDaySlots({
date,
dateKey,
fineRule,
})
: [];
return {
date: dateKey,
label: `${date.getMonth() + 1}/${date.getDate()}`,
subLabel: resolvePreviewSubLabel(index, dayOfWeek),
slots,
};
});
}
/** 是否为有效自提日索引。 */
function toPickupWeekDay(date: Date): PickupWeekDay {
const jsDay = date.getDay(); // 0=周日
const mapping: PickupWeekDay[] = [6, 0, 1, 2, 3, 4, 5];
return mapping[jsDay] ?? 0;
}
/** 生成某日预览时段。 */
function generateDaySlots(payload: {
date: Date;
dateKey: string;
fineRule: PickupFineRuleDto;
}): PickupPreviewSlotDto[] {
const startMinutes = parseTimeToMinutes(payload.fineRule.dayStartTime);
const endMinutes = parseTimeToMinutes(payload.fineRule.dayEndTime);
const interval = payload.fineRule.intervalMinutes;
const total = Math.floor((endMinutes - startMinutes) / interval);
return Array.from({ length: total + 1 }).map((_, index) => {
const minutes = startMinutes + index * interval;
const time = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
minutes % 60,
).padStart(2, '0')}`;
const booked = calcMockBookedCount(
`${payload.dateKey}|${time}`,
payload.fineRule.slotCapacity,
);
const remainingCount = Math.max(0, payload.fineRule.slotCapacity - booked);
const status = resolvePreviewStatus({
date: payload.date,
fineRule: payload.fineRule,
remainingCount,
time,
});
return {
time,
status,
remainingCount,
};
});
}
/** 计算预览时段状态。 */
function resolvePreviewStatus(payload: {
date: Date;
fineRule: PickupFineRuleDto;
remainingCount: number;
time: string;
}): PickupPreviewStatus {
const now = new Date();
const today = toDateOnly(now);
const dateKey = toDateOnly(payload.date);
const slotMinutes = parseTimeToMinutes(payload.time);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const minAdvanceMinutes = payload.fineRule.minAdvanceHours * 60;
if (dateKey < today) return 'expired';
if (dateKey === today && slotMinutes - nowMinutes <= minAdvanceMinutes) {
return 'expired';
}
if (payload.remainingCount <= 0) return 'full';
if (payload.remainingCount <= 1) return 'almost';
return 'available';
}
/** 简单哈希,保障预览可复现。 */
function calcMockBookedCount(seed: string, capacity: number) {
if (capacity <= 0) return 0;
let hash = 0;
for (const char of seed) {
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
}
if (hash % 7 === 0) return capacity;
if (hash % 5 === 0) return Math.max(0, capacity - 1);
return hash % (capacity + 1);
}
function isSameDaySet(a: PickupWeekDay[], b: PickupWeekDay[]) {
if (a.length !== b.length) return false;
return a.every((day, index) => day === b[index]);
}
function toDateOnly(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(baseDate: Date, days: number) {
const next = new Date(baseDate);
next.setDate(baseDate.getDate() + days);
return next;
}
function resolvePreviewSubLabel(offset: number, dayOfWeek: PickupWeekDay) {
const dayText = WEEKDAY_OPTIONS.find(
(item) => item.value === dayOfWeek,
)?.label;
if (offset === 0) return `${dayText} 今天`;
if (offset === 1) return `${dayText} 明天`;
if (offset === 2) return `${dayText} 后天`;
return dayText ?? '';
}

View File

@@ -0,0 +1,189 @@
import type { Ref } from 'vue';
/**
* 文件职责:大时段模式动作。
* 1. 管理新增/编辑抽屉与表单状态。
* 2. 处理时段新增、编辑、删除、启用切换。
*/
import type { PickupSlotDto, PickupWeekDay } from '#/api/store-pickup';
import type {
PickupDrawerMode,
PickupSlotFormState,
} from '#/views/store/pickup/types';
import { message } from 'ant-design-vue';
import { ALL_WEEK_DAYS, WEEKDAY_ONLY, WEEKEND_ONLY } from './constants';
import { sortSlots, validateSlotForm } from './helpers';
interface CreateSlotActionsOptions {
bigSlots: Ref<PickupSlotDto[]>;
createSlotId: () => string;
isSlotDrawerOpen: Ref<boolean>;
saveBigSlots: () => Promise<void>;
slotDrawerMode: Ref<PickupDrawerMode>;
slotForm: PickupSlotFormState;
}
export function createSlotActions(options: CreateSlotActionsOptions) {
/** 打开新增/编辑抽屉并初始化表单。 */
function openSlotDrawer(mode: PickupDrawerMode, slot?: PickupSlotDto) {
options.slotDrawerMode.value = mode;
if (mode === 'edit' && slot) {
options.slotForm.id = slot.id;
options.slotForm.name = slot.name;
options.slotForm.startTime = slot.startTime;
options.slotForm.endTime = slot.endTime;
options.slotForm.cutoffMinutes = slot.cutoffMinutes;
options.slotForm.capacity = slot.capacity;
options.slotForm.dayOfWeeks = [...slot.dayOfWeeks];
options.slotForm.enabled = slot.enabled;
options.isSlotDrawerOpen.value = true;
return;
}
options.slotForm.id = '';
options.slotForm.name = '';
options.slotForm.startTime = '09:00';
options.slotForm.endTime = '17:00';
options.slotForm.cutoffMinutes = 30;
options.slotForm.capacity = 20;
options.slotForm.dayOfWeeks = [...WEEKDAY_ONLY];
options.slotForm.enabled = true;
options.isSlotDrawerOpen.value = true;
}
/** 控制抽屉可见性。 */
function setSlotDrawerOpen(value: boolean) {
options.isSlotDrawerOpen.value = value;
}
function setSlotName(value: string) {
options.slotForm.name = value;
}
function setSlotStartTime(value: string) {
options.slotForm.startTime = value;
}
function setSlotEndTime(value: string) {
options.slotForm.endTime = value;
}
function setSlotCutoffMinutes(value: number) {
options.slotForm.cutoffMinutes = Math.max(
0,
Math.floor(Number(value || 0)),
);
}
function setSlotCapacity(value: number) {
options.slotForm.capacity = Math.max(0, Math.floor(Number(value || 0)));
}
function setSlotEnabled(value: boolean) {
options.slotForm.enabled = Boolean(value);
}
function isSlotDaySelected(day: PickupWeekDay) {
return options.slotForm.dayOfWeeks.includes(day);
}
function toggleSlotDay(day: PickupWeekDay) {
options.slotForm.dayOfWeeks = options.slotForm.dayOfWeeks.includes(day)
? options.slotForm.dayOfWeeks.filter((item) => item !== day)
: [...options.slotForm.dayOfWeeks, day].toSorted((a, b) => a - b);
}
function quickSelectSlotDays(mode: 'all' | 'weekday' | 'weekend') {
if (mode === 'all') {
options.slotForm.dayOfWeeks = [...ALL_WEEK_DAYS];
return;
}
if (mode === 'weekday') {
options.slotForm.dayOfWeeks = [...WEEKDAY_ONLY];
return;
}
options.slotForm.dayOfWeeks = [...WEEKEND_ONLY];
}
/** 提交新增/编辑并持久化时段列表。 */
async function handleSubmitSlot() {
// 1. 校验表单合法性。
const validateMessage = validateSlotForm({
slotId: options.slotForm.id,
slots: options.bigSlots.value,
name: options.slotForm.name,
startTime: options.slotForm.startTime,
endTime: options.slotForm.endTime,
dayOfWeeks: options.slotForm.dayOfWeeks,
cutoffMinutes: options.slotForm.cutoffMinutes,
capacity: options.slotForm.capacity,
});
if (validateMessage) {
message.error(validateMessage);
return;
}
// 2. 写回列表并排序。
const nextRecord: PickupSlotDto = {
id: options.slotForm.id || options.createSlotId(),
name: options.slotForm.name.trim(),
startTime: options.slotForm.startTime,
endTime: options.slotForm.endTime,
cutoffMinutes: options.slotForm.cutoffMinutes,
capacity: options.slotForm.capacity,
dayOfWeeks: [...options.slotForm.dayOfWeeks].toSorted((a, b) => a - b),
enabled: options.slotForm.enabled,
reservedCount: 0,
};
options.bigSlots.value =
options.slotDrawerMode.value === 'edit' && options.slotForm.id
? sortSlots(
options.bigSlots.value.map((item) =>
item.id === options.slotForm.id
? { ...item, ...nextRecord, reservedCount: item.reservedCount }
: item,
),
)
: sortSlots([...options.bigSlots.value, nextRecord]);
// 3. 持久化并关闭抽屉。
await options.saveBigSlots();
options.isSlotDrawerOpen.value = false;
}
/** 删除时段并持久化。 */
async function handleDeleteSlot(slotId: string) {
options.bigSlots.value = options.bigSlots.value.filter(
(item) => item.id !== slotId,
);
await options.saveBigSlots();
}
/** 切换时段启用状态并持久化。 */
async function handleToggleSlotEnabled(slotId: string, enabled: boolean) {
options.bigSlots.value = options.bigSlots.value.map((item) =>
item.id === slotId ? { ...item, enabled } : item,
);
await options.saveBigSlots();
}
return {
handleDeleteSlot,
handleSubmitSlot,
handleToggleSlotEnabled,
isSlotDaySelected,
openSlotDrawer,
quickSelectSlotDays,
setSlotCapacity,
setSlotCutoffMinutes,
setSlotDrawerOpen,
setSlotEnabled,
setSlotEndTime,
setSlotName,
setSlotStartTime,
toggleSlotDay,
};
}

View File

@@ -0,0 +1,351 @@
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:自提设置页面主编排。
* 1. 维护页面级状态(门店、模式、设置、抽屉、复制弹窗)。
* 2. 组合数据加载、复制、大时段与精细规则动作。
* 3. 对外暴露视图层可直接消费的状态与方法。
*/
import type { PickupBasicSettingsDto, PickupSlotDto } from '#/api/store-pickup';
import type {
PickupDrawerMode,
PickupSettingsSnapshot,
PickupSlotFormState,
} from '#/views/store/pickup/types';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
ALL_WEEK_DAYS,
DEFAULT_BIG_SLOTS,
DEFAULT_FINE_RULE,
DEFAULT_PICKUP_BASIC_SETTINGS,
DEFAULT_PICKUP_MODE,
FINE_INTERVAL_OPTIONS,
PICKUP_MODE_OPTIONS,
WEEKDAY_OPTIONS,
} from './pickup-page/constants';
import { createCopyActions } from './pickup-page/copy-actions';
import { createDataActions } from './pickup-page/data-actions';
import { createFineRuleActions } from './pickup-page/fine-rule-actions';
import {
calcReservedPercent,
cloneBasicSettings,
cloneBigSlots,
cloneFineRule,
clonePreviewDays,
createSlotId,
formatDayOfWeeksText,
generatePreviewDays,
sortSlots,
} from './pickup-page/helpers';
import { createSlotActions } from './pickup-page/slot-actions';
export function useStorePickupPage() {
// 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false);
const isPageLoading = ref(false);
const isSavingBasic = ref(false);
const isSavingSlots = ref(false);
const isSavingFineRule = ref(false);
const isCopySubmitting = ref(false);
// 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const pickupMode = ref(DEFAULT_PICKUP_MODE);
const basicSettings = reactive<PickupBasicSettingsDto>(
cloneBasicSettings(DEFAULT_PICKUP_BASIC_SETTINGS),
);
const bigSlots = ref<PickupSlotDto[]>(
sortSlots(cloneBigSlots(DEFAULT_BIG_SLOTS)),
);
const fineRule = reactive(cloneFineRule(DEFAULT_FINE_RULE));
const previewDays = ref(generatePreviewDays(fineRule));
const selectedPreviewDate = ref(previewDays.value[0]?.date ?? '');
const snapshot = ref<null | PickupSettingsSnapshot>(null);
// 3. 复制弹窗状态。
const isCopyModalOpen = ref(false);
const copyTargetStoreIds = ref<string[]>([]);
// 4. 大时段抽屉状态。
const isSlotDrawerOpen = ref(false);
const slotDrawerMode = ref<PickupDrawerMode>('create');
const slotForm = reactive<PickupSlotFormState>({
id: '',
name: '',
startTime: '09:00',
endTime: '17:00',
cutoffMinutes: 30,
capacity: 20,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
});
// 5. 页面衍生视图数据。
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 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 selectedPreviewDay = computed(
() =>
previewDays.value.find(
(item) => item.date === selectedPreviewDate.value,
) ?? previewDays.value[0],
);
const slotDrawerTitle = computed(() =>
slotDrawerMode.value === 'edit'
? `编辑时段 - ${slotForm.name}`
: '添加时段',
);
// 6. 数据域动作装配。
const {
loadStoreSettings,
loadStores,
resetFromSnapshot,
saveBasicSettings,
saveBigSlots,
saveFineRule,
} = createDataActions({
basicSettings,
bigSlots,
fineRule,
isPageLoading,
isSavingBasic,
isSavingFineRule,
isSavingSlots,
isStoreLoading,
mode: pickupMode,
previewDays,
selectedStoreId,
snapshot,
stores,
});
const {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
const {
handleDeleteSlot,
handleSubmitSlot,
handleToggleSlotEnabled,
isSlotDaySelected,
openSlotDrawer,
quickSelectSlotDays,
setSlotCapacity,
setSlotCutoffMinutes,
setSlotDrawerOpen,
setSlotEnabled,
setSlotEndTime,
setSlotName,
setSlotStartTime,
toggleSlotDay,
} = createSlotActions({
bigSlots,
createSlotId,
isSlotDrawerOpen,
saveBigSlots,
slotDrawerMode,
slotForm,
});
const {
handleSaveFineRule,
isFineDaySelected,
quickSelectFineDays,
refreshPreviewDays,
setFineDayEndTime,
setFineDayStartTime,
setFineIntervalMinutes,
setFineMinAdvanceHours,
setFineSlotCapacity,
setSelectedPreviewDate,
toggleFineDay,
} = createFineRuleActions({
fineRule,
previewDays,
saveFineRule,
selectedPreviewDate,
});
// 7. 页面字段更新方法。
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setPickupMode(value: 'big' | 'fine') {
pickupMode.value = value;
}
function setAllowSameDayPickup(value: boolean) {
basicSettings.allowSameDayPickup = Boolean(value);
}
function setBookingDays(value: number) {
basicSettings.bookingDays = Math.max(1, Math.floor(Number(value || 1)));
}
function setMaxItemsPerOrder(value: null | number) {
if (value === null || value === undefined) {
basicSettings.maxItemsPerOrder = null;
return;
}
basicSettings.maxItemsPerOrder = Math.max(
0,
Math.floor(Number(value || 0)),
);
}
/** 仅重置基本设置区域,避免影响其他配置。 */
function resetBasicSettings() {
const source =
snapshot.value?.basicSettings ??
cloneBasicSettings(DEFAULT_PICKUP_BASIC_SETTINGS);
basicSettings.allowSameDayPickup = source.allowSameDayPickup;
basicSettings.bookingDays = source.bookingDays;
basicSettings.maxItemsPerOrder = source.maxItemsPerOrder;
}
/** 仅重置精细规则区域,并同步预览。 */
function resetFineRule() {
const source = snapshot.value?.fineRule ?? cloneFineRule(DEFAULT_FINE_RULE);
fineRule.intervalMinutes = source.intervalMinutes;
fineRule.slotCapacity = source.slotCapacity;
fineRule.dayStartTime = source.dayStartTime;
fineRule.dayEndTime = source.dayEndTime;
fineRule.minAdvanceHours = source.minAdvanceHours;
fineRule.dayOfWeeks = [...source.dayOfWeeks];
previewDays.value =
snapshot.value?.previewDays && snapshot.value.previewDays.length > 0
? clonePreviewDays(snapshot.value.previewDays)
: generatePreviewDays(fineRule);
selectedPreviewDate.value = previewDays.value[0]?.date ?? '';
}
// 8. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
pickupMode.value = DEFAULT_PICKUP_MODE;
basicSettings.allowSameDayPickup =
DEFAULT_PICKUP_BASIC_SETTINGS.allowSameDayPickup;
basicSettings.bookingDays = DEFAULT_PICKUP_BASIC_SETTINGS.bookingDays;
basicSettings.maxItemsPerOrder =
DEFAULT_PICKUP_BASIC_SETTINGS.maxItemsPerOrder;
bigSlots.value = sortSlots(cloneBigSlots(DEFAULT_BIG_SLOTS));
Object.assign(fineRule, cloneFineRule(DEFAULT_FINE_RULE));
previewDays.value = generatePreviewDays(fineRule);
selectedPreviewDate.value = previewDays.value[0]?.date ?? '';
snapshot.value = null;
return;
}
await loadStoreSettings(storeId);
selectedPreviewDate.value = previewDays.value[0]?.date ?? '';
});
// 9. 页面首屏初始化。
onMounted(loadStores);
return {
FINE_INTERVAL_OPTIONS,
PICKUP_MODE_OPTIONS,
WEEKDAY_OPTIONS,
basicSettings,
bigSlots,
calcReservedPercent,
copyCandidates,
copyTargetStoreIds,
fineRule,
formatDayOfWeeksText,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteSlot,
handleSaveFineRule,
handleSubmitSlot,
handleToggleSlotEnabled,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isFineDaySelected,
isPageLoading,
isSavingBasic,
isSavingFineRule,
isSavingSlots,
isSlotDaySelected,
isSlotDrawerOpen,
isStoreLoading,
openCopyModal,
openSlotDrawer,
pickupMode,
previewDays,
quickSelectFineDays,
quickSelectSlotDays,
refreshPreviewDays,
resetBasicSettings,
resetFineRule,
resetFromSnapshot,
saveBasicSettings,
selectedPreviewDate,
selectedPreviewDay,
selectedStoreId,
selectedStoreName,
setAllowSameDayPickup,
setBookingDays,
setFineDayEndTime,
setFineDayStartTime,
setFineIntervalMinutes,
setFineMinAdvanceHours,
setFineSlotCapacity,
setMaxItemsPerOrder,
setPickupMode,
setSelectedPreviewDate,
setSelectedStoreId,
setSlotCapacity,
setSlotCutoffMinutes,
setSlotDrawerOpen,
setSlotEnabled,
setSlotEndTime,
setSlotName,
setSlotStartTime,
slotDrawerTitle,
slotForm,
storeOptions,
toggleCopyStore,
toggleFineDay,
toggleSlotDay,
};
}

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
/**
* 文件职责:自提设置页面主视图。
* 1. 组合基础设置、模式切换、大时段/精细规则与预览区块。
* 2. 承接门店维度切换与复制到其他门店弹窗。
*/
import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import PickupBasicSettingsCard from './components/PickupBasicSettingsCard.vue';
import PickupBigSlotSection from './components/PickupBigSlotSection.vue';
import PickupFineRuleSection from './components/PickupFineRuleSection.vue';
import PickupModeSwitch from './components/PickupModeSwitch.vue';
import PickupPreviewSection from './components/PickupPreviewSection.vue';
import PickupSlotDrawer from './components/PickupSlotDrawer.vue';
import { useStorePickupPage } from './composables/useStorePickupPage';
const {
FINE_INTERVAL_OPTIONS,
PICKUP_MODE_OPTIONS,
WEEKDAY_OPTIONS,
basicSettings,
bigSlots,
calcReservedPercent,
copyCandidates,
copyTargetStoreIds,
fineRule,
formatDayOfWeeksText,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteSlot,
handleSaveFineRule,
handleSubmitSlot,
handleToggleSlotEnabled,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isFineDaySelected,
isPageLoading,
isSavingBasic,
isSavingFineRule,
isSavingSlots,
isSlotDaySelected,
isSlotDrawerOpen,
isStoreLoading,
openCopyModal,
openSlotDrawer,
pickupMode,
previewDays,
quickSelectFineDays,
quickSelectSlotDays,
resetBasicSettings,
resetFineRule,
saveBasicSettings,
selectedPreviewDate,
selectedStoreId,
selectedStoreName,
setAllowSameDayPickup,
setBookingDays,
setFineDayEndTime,
setFineDayStartTime,
setFineIntervalMinutes,
setFineMinAdvanceHours,
setFineSlotCapacity,
setMaxItemsPerOrder,
setPickupMode,
setSelectedPreviewDate,
setSelectedStoreId,
setSlotCapacity,
setSlotCutoffMinutes,
setSlotDrawerOpen,
setSlotEnabled,
setSlotEndTime,
setSlotName,
setSlotStartTime,
slotDrawerTitle,
slotForm,
storeOptions,
toggleCopyStore,
toggleFineDay,
toggleSlotDay,
} = useStorePickupPage();
</script>
<template>
<Page title="自提设置" content-class="space-y-4 page-store-pickup">
<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">
<PickupBasicSettingsCard
:settings="basicSettings"
:is-saving="isSavingBasic"
:on-set-allow-same-day-pickup="setAllowSameDayPickup"
:on-set-booking-days="setBookingDays"
:on-set-max-items-per-order="setMaxItemsPerOrder"
@reset="resetBasicSettings"
@save="saveBasicSettings"
/>
<PickupModeSwitch
:mode="pickupMode"
:options="PICKUP_MODE_OPTIONS"
@change="setPickupMode"
/>
<PickupBigSlotSection
v-if="pickupMode === 'big'"
:slots="bigSlots"
:is-saving="isSavingSlots"
:calc-reserved-percent="calcReservedPercent"
:format-day-of-weeks-text="formatDayOfWeeksText"
@add="openSlotDrawer('create')"
@edit="(slot) => openSlotDrawer('edit', slot)"
@delete="handleDeleteSlot"
@toggle-enabled="
({ slotId, enabled }) => handleToggleSlotEnabled(slotId, enabled)
"
/>
<template v-else>
<PickupFineRuleSection
:fine-rule="fineRule"
:interval-options="FINE_INTERVAL_OPTIONS"
:week-day-options="WEEKDAY_OPTIONS"
:is-day-selected="isFineDaySelected"
:is-saving="isSavingFineRule"
:on-set-interval-minutes="setFineIntervalMinutes"
:on-set-slot-capacity="setFineSlotCapacity"
:on-set-day-start-time="setFineDayStartTime"
:on-set-day-end-time="setFineDayEndTime"
:on-set-min-advance-hours="setFineMinAdvanceHours"
:on-toggle-day="toggleFineDay"
:on-quick-select-days="quickSelectFineDays"
@reset="resetFineRule"
@save="handleSaveFineRule"
/>
<PickupPreviewSection
:days="previewDays"
:selected-date="selectedPreviewDate"
@select-date="setSelectedPreviewDate"
/>
</template>
</Spin>
</template>
<PickupSlotDrawer
:open="isSlotDrawerOpen"
:title="slotDrawerTitle"
:form="slotForm"
:is-saving="isSavingSlots"
:week-day-options="WEEKDAY_OPTIONS"
:is-day-selected="isSlotDaySelected"
:on-set-name="setSlotName"
:on-set-start-time="setSlotStartTime"
:on-set-end-time="setSlotEndTime"
:on-set-cutoff-minutes="setSlotCutoffMinutes"
:on-set-capacity="setSlotCapacity"
:on-set-enabled="setSlotEnabled"
:on-toggle-day="toggleSlotDay"
:on-quick-select-days="quickSelectSlotDays"
@update:open="setSlotDrawerOpen"
@submit="handleSubmitSlot"
/>
<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,58 @@
/* 文件职责:自提设置页面基础骨架与通用字段样式。 */
.page-store-pickup {
max-width: 980px;
.pickup-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;
}
.pickup-number-input {
width: 88px;
}
.pickup-time-picker {
width: 100%;
}
.field-input-with-unit {
display: flex;
gap: 6px;
align-items: center;
color: #6b7280;
}
.field-hint {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: #9ca3af;
}
}

View File

@@ -0,0 +1,50 @@
/* 文件职责:自提设置页面“基本设置”区块样式。 */
.page-store-pickup {
.pickup-form-row {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.pickup-form-row:last-of-type {
border-bottom: none;
}
.pickup-label {
flex-shrink: 0;
width: 130px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
}
.pickup-control {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.pickup-unit {
font-size: 12px;
color: #9ca3af;
}
.pickup-hint {
font-size: 12px;
color: #9ca3af;
}
.pickup-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 14px;
margin-top: 6px;
border-top: 1px solid #f3f4f6;
}
}

View File

@@ -0,0 +1,109 @@
/* 文件职责:自提设置页面抽屉样式。 */
.pickup-slot-drawer-wrap {
.ant-drawer-body {
padding: 16px 20px 90px;
}
.ant-drawer-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
.pickup-drawer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.pickup-drawer-field {
margin-bottom: 14px;
}
.drawer-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.drawer-label.required::before {
margin-right: 4px;
color: #ef4444;
content: '*';
}
.pickup-number-input {
width: 96px;
}
.pickup-time-picker {
width: 100%;
}
.field-input-with-unit {
display: flex;
gap: 6px;
align-items: center;
color: #6b7280;
}
.field-hint {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: #9ca3af;
}
.pickup-day-pill-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pickup-day-pill {
display: inline-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;
transition: all 0.2s ease;
}
.pickup-day-pill.selected {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.pickup-quick-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.pickup-quick-actions button {
padding: 0;
font-size: 12px;
color: #1677ff;
cursor: pointer;
background: transparent;
border: none;
}
.pickup-quick-actions button:hover {
text-decoration: underline;
}
}
.pickup-drawer-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}

View File

@@ -0,0 +1,8 @@
/* 文件职责:自提设置页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './basic.less';
@import './mode.less';
@import './slot.less';
@import './preview.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -0,0 +1,30 @@
/* 文件职责:自提设置页面模式切换样式。 */
.page-store-pickup {
.pickup-mode-switch {
display: inline-flex;
gap: 2px;
padding: 3px;
margin-bottom: 16px;
background: #f8f9fb;
border-radius: 8px;
}
.pickup-mode-item {
min-width: 118px;
padding: 6px 18px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px;
transition: all 0.2s ease;
}
.pickup-mode-item.active {
font-weight: 600;
color: #1677ff;
background: #fff;
box-shadow: 0 1px 2px rgb(15 23 42 / 10%);
}
}

View File

@@ -0,0 +1,134 @@
/* 文件职责:自提设置页面时段预览样式。 */
.page-store-pickup {
.pickup-preview-subtitle {
font-size: 12px;
font-weight: 400;
color: #9ca3af;
}
.pickup-preview-day-tabs {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.pickup-preview-day-tab {
display: inline-flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
justify-content: center;
padding: 8px 14px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
}
.pickup-preview-day-tab.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.pickup-preview-day-tab .tab-date {
font-size: 13px;
font-weight: 500;
line-height: 1;
}
.pickup-preview-day-tab .tab-sub {
font-size: 11px;
line-height: 1.2;
opacity: 0.85;
}
.pickup-preview-slot-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pickup-preview-slot-cell {
width: 84px;
padding: 8px 6px;
text-align: center;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
}
.pickup-preview-slot-cell .slot-time {
font-size: 13px;
font-weight: 500;
}
.pickup-preview-slot-cell .slot-status {
margin-top: 2px;
font-size: 11px;
}
.pickup-preview-slot-cell.expired {
color: #b6bbc3;
background: #f8f9fb;
border-color: #e5e7eb;
}
.pickup-preview-slot-cell.available {
color: #16a34a;
background: #f0fdf4;
border-color: #bbf7d0;
}
.pickup-preview-slot-cell.almost {
color: #d97706;
background: #fffbeb;
border-color: #fde68a;
}
.pickup-preview-slot-cell.full {
color: #ef4444;
background: #fef2f2;
border-color: #fecaca;
}
.pickup-preview-legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 14px;
font-size: 12px;
color: #9ca3af;
}
.pickup-preview-legend span {
display: inline-flex;
gap: 4px;
align-items: center;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-dot.expired {
background: #d9d9d9;
}
.legend-dot.available {
background: #b7eb8f;
}
.legend-dot.almost {
background: #ffd591;
}
.legend-dot.full {
background: #ffa39e;
}
}

View File

@@ -0,0 +1,58 @@
/* 文件职责:自提设置页面响应式规则。 */
.page-store-pickup {
@media (max-width: 900px) {
.pickup-slot-table {
min-width: 860px;
}
}
@media (max-width: 768px) {
.pickup-mode-switch {
display: flex;
width: 100%;
}
.pickup-mode-item {
flex: 1;
min-width: 0;
text-align: center;
}
.pickup-form-row {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.pickup-label {
width: auto;
}
.pickup-control {
width: 100%;
}
.pickup-fine-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.pickup-preview-day-tabs {
padding-bottom: 4px;
overflow-x: auto;
}
.pickup-preview-day-tab {
flex-shrink: 0;
}
}
}
@media (max-width: 640px) {
.pickup-slot-drawer-wrap {
.pickup-drawer-grid {
grid-template-columns: 1fr;
gap: 0;
}
}
}

View File

@@ -0,0 +1,168 @@
/* 文件职责:自提设置页面大时段表格与精细规则样式。 */
.page-store-pickup {
.pickup-slot-table-wrap {
overflow-x: auto;
}
.pickup-slot-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.pickup-slot-table th {
padding: 10px 12px;
font-weight: 600;
color: #6b7280;
text-align: left;
white-space: nowrap;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.pickup-slot-table td {
padding: 10px 12px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.pickup-slot-table tbody tr:last-child td {
border-bottom: none;
}
.pickup-slot-table tbody tr:hover td {
background: #f8fbff;
}
.pickup-slot-table .op-column {
width: 120px;
}
.slot-name-cell {
font-weight: 500;
}
.slot-progress {
display: flex;
gap: 6px;
align-items: center;
min-width: 92px;
}
.slot-progress-bar {
width: 64px;
height: 6px;
overflow: hidden;
background: #e5e7eb;
border-radius: 4px;
}
.slot-progress-fill {
height: 100%;
background: #1677ff;
border-radius: 4px;
}
.slot-progress-text {
font-size: 11px;
color: #9ca3af;
white-space: nowrap;
}
.slot-weekday-tag {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #597ef7;
background: #f0f5ff;
border-radius: 6px;
}
.slot-op-cell {
text-align: left;
}
.slot-op-actions {
display: inline-flex;
gap: 2px;
align-items: center;
}
.pickup-fine-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 24px;
margin-bottom: 4px;
}
.pickup-fine-field {
min-width: 0;
}
.pickup-fine-field > label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
}
.pickup-fine-week-wrap {
margin-top: 8px;
}
.pickup-fine-week-wrap > label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
}
.pickup-day-pill-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pickup-day-pill {
display: inline-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;
transition: all 0.2s ease;
}
.pickup-day-pill.selected {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.pickup-quick-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.pickup-quick-actions button {
padding: 0;
font-size: 12px;
color: #1677ff;
cursor: pointer;
background: transparent;
border: none;
}
.pickup-quick-actions button:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,39 @@
/**
* 文件职责:自提设置页面类型定义。
* 1. 声明页面级表单态与弹窗模式。
* 2. 声明快照与视图衍生数据类型。
*/
import type {
PickupBasicSettingsDto,
PickupFineRuleDto,
PickupMode,
PickupPreviewDayDto,
PickupSlotDto,
PickupWeekDay,
} from '#/api/store-pickup';
export type PickupDrawerMode = 'create' | 'edit';
export interface PickupSlotFormState {
capacity: number;
cutoffMinutes: number;
dayOfWeeks: PickupWeekDay[];
enabled: boolean;
endTime: string;
id: string;
name: string;
startTime: string;
}
export interface PickupSettingsSnapshot {
basicSettings: PickupBasicSettingsDto;
bigSlots: PickupSlotDto[];
fineRule: PickupFineRuleDto;
mode: PickupMode;
previewDays: PickupPreviewDayDto[];
}
export interface PickupWeekDayOption {
label: string;
value: PickupWeekDay;
}