feat: 完成门店配置拆分并新增配送与自提设置页面
This commit is contained in:
82
apps/web-antd/src/api/store-delivery/index.ts
Normal file
82
apps/web-antd/src/api/store-delivery/index.ts
Normal 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);
|
||||
}
|
||||
138
apps/web-antd/src/api/store-pickup/index.ts
Normal file
138
apps/web-antd/src/api/store-pickup/index.ts
Normal 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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Mock 数据入口,仅在开发环境下使用
|
||||
import './store';
|
||||
import './store-hours';
|
||||
import './store-pickup';
|
||||
|
||||
console.warn('[Mock] Mock 数据已启用');
|
||||
|
||||
582
apps/web-antd/src/mock/store-pickup.ts
Normal file
582
apps/web-antd/src/mock/store-pickup.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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: '自提设置',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
330
apps/web-antd/src/views/store/components/CopyToStoresModal.vue
Normal file
330
apps/web-antd/src/views/store/components/CopyToStoresModal.vue
Normal 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>
|
||||
101
apps/web-antd/src/views/store/components/StoreScopeToolbar.vue
Normal file
101
apps/web-antd/src/views/store/components/StoreScopeToolbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
195
apps/web-antd/src/views/store/delivery/index.vue
Normal file
195
apps/web-antd/src/views/store/delivery/index.vue
Normal 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>
|
||||
10
apps/web-antd/src/views/store/delivery/styles/base.less
Normal file
10
apps/web-antd/src/views/store/delivery/styles/base.less
Normal file
@@ -0,0 +1,10 @@
|
||||
/* 文件职责:配送设置页面基础骨架样式。 */
|
||||
.page-store-delivery {
|
||||
max-width: 980px;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
}
|
||||
38
apps/web-antd/src/views/store/delivery/styles/common.less
Normal file
38
apps/web-antd/src/views/store/delivery/styles/common.less
Normal 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;
|
||||
}
|
||||
}
|
||||
85
apps/web-antd/src/views/store/delivery/styles/drawer.less
Normal file
85
apps/web-antd/src/views/store/delivery/styles/drawer.less
Normal 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;
|
||||
}
|
||||
8
apps/web-antd/src/views/store/delivery/styles/index.less
Normal file
8
apps/web-antd/src/views/store/delivery/styles/index.less
Normal 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';
|
||||
167
apps/web-antd/src/views/store/delivery/styles/mode.less
Normal file
167
apps/web-antd/src/views/store/delivery/styles/mode.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
62
apps/web-antd/src/views/store/delivery/styles/tier.less
Normal file
62
apps/web-antd/src/views/store/delivery/styles/tier.less
Normal 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;
|
||||
}
|
||||
}
|
||||
59
apps/web-antd/src/views/store/delivery/styles/zone.less
Normal file
59
apps/web-antd/src/views/store/delivery/styles/zone.less
Normal 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;
|
||||
}
|
||||
}
|
||||
40
apps/web-antd/src/views/store/delivery/types.ts
Normal file
40
apps/web-antd/src/views/store/delivery/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,4 @@
|
||||
@import './week.less';
|
||||
@import './holiday.less';
|
||||
@import './drawer.less';
|
||||
@import './copy-modal.less';
|
||||
@import './responsive.less';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -126,4 +126,6 @@ function handleExport() {
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style src="./styles/index.less"></style>
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
205
apps/web-antd/src/views/store/pickup/index.vue
Normal file
205
apps/web-antd/src/views/store/pickup/index.vue
Normal 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>
|
||||
58
apps/web-antd/src/views/store/pickup/styles/base.less
Normal file
58
apps/web-antd/src/views/store/pickup/styles/base.less
Normal 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;
|
||||
}
|
||||
}
|
||||
50
apps/web-antd/src/views/store/pickup/styles/basic.less
Normal file
50
apps/web-antd/src/views/store/pickup/styles/basic.less
Normal 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;
|
||||
}
|
||||
}
|
||||
109
apps/web-antd/src/views/store/pickup/styles/drawer.less
Normal file
109
apps/web-antd/src/views/store/pickup/styles/drawer.less
Normal 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;
|
||||
}
|
||||
8
apps/web-antd/src/views/store/pickup/styles/index.less
Normal file
8
apps/web-antd/src/views/store/pickup/styles/index.less
Normal 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';
|
||||
30
apps/web-antd/src/views/store/pickup/styles/mode.less
Normal file
30
apps/web-antd/src/views/store/pickup/styles/mode.less
Normal 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%);
|
||||
}
|
||||
}
|
||||
134
apps/web-antd/src/views/store/pickup/styles/preview.less
Normal file
134
apps/web-antd/src/views/store/pickup/styles/preview.less
Normal 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;
|
||||
}
|
||||
}
|
||||
58
apps/web-antd/src/views/store/pickup/styles/responsive.less
Normal file
58
apps/web-antd/src/views/store/pickup/styles/responsive.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
apps/web-antd/src/views/store/pickup/styles/slot.less
Normal file
168
apps/web-antd/src/views/store/pickup/styles/slot.less
Normal 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;
|
||||
}
|
||||
}
|
||||
39
apps/web-antd/src/views/store/pickup/types.ts
Normal file
39
apps/web-antd/src/views/store/pickup/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user