From 8d1325edf0d47839b04724856adf272cbdd6a1d8 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 16 Feb 2026 14:39:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E9=97=A8=E5=BA=97?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=8B=86=E5=88=86=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=85=8D=E9=80=81=E4=B8=8E=E8=87=AA=E6=8F=90=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/store-delivery/index.ts | 82 +++ apps/web-antd/src/api/store-pickup/index.ts | 138 +++++ apps/web-antd/src/mock/index.ts | 1 + apps/web-antd/src/mock/store-pickup.ts | 582 ++++++++++++++++++ .../src/router/routes/modules/store.ts | 18 + .../store/components/CopyToStoresModal.vue | 330 ++++++++++ .../store/components/StoreScopeToolbar.vue | 101 +++ .../components/DeliveryCommonSettingsCard.vue | 130 ++++ .../delivery/components/DeliveryModeCard.vue | 89 +++ .../components/DeliveryTierDrawer.vue | 161 +++++ .../components/DeliveryZoneDrawer.vue | 160 +++++ .../components/PolygonZoneSection.vue | 83 +++ .../delivery/components/RadiusTierSection.vue | 86 +++ .../composables/delivery-page/constants.ts | 96 +++ .../composables/delivery-page/copy-actions.ts | 81 +++ .../composables/delivery-page/data-actions.ts | 205 ++++++ .../composables/delivery-page/helpers.ts | 82 +++ .../composables/delivery-page/tier-actions.ts | 172 ++++++ .../composables/delivery-page/zone-actions.ts | 154 +++++ .../composables/useStoreDeliveryPage.ts | 327 ++++++++++ .../src/views/store/delivery/index.vue | 195 ++++++ .../src/views/store/delivery/styles/base.less | 10 + .../views/store/delivery/styles/common.less | 38 ++ .../views/store/delivery/styles/drawer.less | 85 +++ .../views/store/delivery/styles/index.less | 8 + .../src/views/store/delivery/styles/mode.less | 167 +++++ .../store/delivery/styles/responsive.less | 47 ++ .../src/views/store/delivery/styles/tier.less | 62 ++ .../src/views/store/delivery/styles/zone.less | 59 ++ .../src/views/store/delivery/types.ts | 40 ++ .../store/hours/components/CopyStoreModal.vue | 107 ---- apps/web-antd/src/views/store/hours/index.vue | 52 +- .../src/views/store/hours/styles/base.less | 18 - .../views/store/hours/styles/copy-modal.less | 185 ------ .../src/views/store/hours/styles/drawer.less | 19 +- .../src/views/store/hours/styles/index.less | 1 - .../views/store/hours/styles/responsive.less | 13 - apps/web-antd/src/views/store/list/index.vue | 4 +- .../src/views/store/list/styles/drawer.less | 2 +- .../src/views/store/list/styles/table.less | 2 +- .../components/PickupBasicSettingsCard.vue | 98 +++ .../components/PickupBigSlotSection.vue | 111 ++++ .../components/PickupFineRuleSection.vue | 174 ++++++ .../pickup/components/PickupModeSwitch.vue | 34 + .../components/PickupPreviewSection.vue | 96 +++ .../pickup/components/PickupSlotDrawer.vue | 204 ++++++ .../composables/pickup-page/constants.ts | 117 ++++ .../composables/pickup-page/copy-actions.ts | 76 +++ .../composables/pickup-page/data-actions.ts | 263 ++++++++ .../pickup-page/fine-rule-actions.ts | 140 +++++ .../pickup/composables/pickup-page/helpers.ts | 291 +++++++++ .../composables/pickup-page/slot-actions.ts | 189 ++++++ .../pickup/composables/useStorePickupPage.ts | 351 +++++++++++ .../web-antd/src/views/store/pickup/index.vue | 205 ++++++ .../src/views/store/pickup/styles/base.less | 58 ++ .../src/views/store/pickup/styles/basic.less | 50 ++ .../src/views/store/pickup/styles/drawer.less | 109 ++++ .../src/views/store/pickup/styles/index.less | 8 + .../src/views/store/pickup/styles/mode.less | 30 + .../views/store/pickup/styles/preview.less | 134 ++++ .../views/store/pickup/styles/responsive.less | 58 ++ .../src/views/store/pickup/styles/slot.less | 168 +++++ apps/web-antd/src/views/store/pickup/types.ts | 39 ++ 63 files changed, 6827 insertions(+), 368 deletions(-) create mode 100644 apps/web-antd/src/api/store-delivery/index.ts create mode 100644 apps/web-antd/src/api/store-pickup/index.ts create mode 100644 apps/web-antd/src/mock/store-pickup.ts create mode 100644 apps/web-antd/src/views/store/components/CopyToStoresModal.vue create mode 100644 apps/web-antd/src/views/store/components/StoreScopeToolbar.vue create mode 100644 apps/web-antd/src/views/store/delivery/components/DeliveryCommonSettingsCard.vue create mode 100644 apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue create mode 100644 apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue create mode 100644 apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue create mode 100644 apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue create mode 100644 apps/web-antd/src/views/store/delivery/components/RadiusTierSection.vue create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/constants.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/copy-actions.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/data-actions.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/helpers.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/tier-actions.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/zone-actions.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/useStoreDeliveryPage.ts create mode 100644 apps/web-antd/src/views/store/delivery/index.vue create mode 100644 apps/web-antd/src/views/store/delivery/styles/base.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/common.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/drawer.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/index.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/mode.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/responsive.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/tier.less create mode 100644 apps/web-antd/src/views/store/delivery/styles/zone.less create mode 100644 apps/web-antd/src/views/store/delivery/types.ts delete mode 100644 apps/web-antd/src/views/store/hours/components/CopyStoreModal.vue delete mode 100644 apps/web-antd/src/views/store/hours/styles/copy-modal.less create mode 100644 apps/web-antd/src/views/store/pickup/components/PickupBasicSettingsCard.vue create mode 100644 apps/web-antd/src/views/store/pickup/components/PickupBigSlotSection.vue create mode 100644 apps/web-antd/src/views/store/pickup/components/PickupFineRuleSection.vue create mode 100644 apps/web-antd/src/views/store/pickup/components/PickupModeSwitch.vue create mode 100644 apps/web-antd/src/views/store/pickup/components/PickupPreviewSection.vue create mode 100644 apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue create mode 100644 apps/web-antd/src/views/store/pickup/composables/pickup-page/constants.ts create mode 100644 apps/web-antd/src/views/store/pickup/composables/pickup-page/copy-actions.ts create mode 100644 apps/web-antd/src/views/store/pickup/composables/pickup-page/data-actions.ts create mode 100644 apps/web-antd/src/views/store/pickup/composables/pickup-page/fine-rule-actions.ts create mode 100644 apps/web-antd/src/views/store/pickup/composables/pickup-page/helpers.ts create mode 100644 apps/web-antd/src/views/store/pickup/composables/pickup-page/slot-actions.ts create mode 100644 apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts create mode 100644 apps/web-antd/src/views/store/pickup/index.vue create mode 100644 apps/web-antd/src/views/store/pickup/styles/base.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/basic.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/drawer.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/index.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/mode.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/preview.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/responsive.less create mode 100644 apps/web-antd/src/views/store/pickup/styles/slot.less create mode 100644 apps/web-antd/src/views/store/pickup/types.ts diff --git a/apps/web-antd/src/api/store-delivery/index.ts b/apps/web-antd/src/api/store-delivery/index.ts new file mode 100644 index 0000000..060a635 --- /dev/null +++ b/apps/web-antd/src/api/store-delivery/index.ts @@ -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('/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); +} diff --git a/apps/web-antd/src/api/store-pickup/index.ts b/apps/web-antd/src/api/store-pickup/index.ts new file mode 100644 index 0000000..f5566b4 --- /dev/null +++ b/apps/web-antd/src/api/store-pickup/index.ts @@ -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('/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); +} diff --git a/apps/web-antd/src/mock/index.ts b/apps/web-antd/src/mock/index.ts index facc498..84e3a1e 100644 --- a/apps/web-antd/src/mock/index.ts +++ b/apps/web-antd/src/mock/index.ts @@ -1,5 +1,6 @@ // Mock 数据入口,仅在开发环境下使用 import './store'; import './store-hours'; +import './store-pickup'; console.warn('[Mock] Mock 数据已启用'); diff --git a/apps/web-antd/src/mock/store-pickup.ts b/apps/web-antd/src/mock/store-pickup.ts new file mode 100644 index 0000000..a056243 --- /dev/null +++ b/apps/web-antd/src/mock/store-pickup.ts @@ -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 = { + 0: '周一', + 1: '周二', + 2: '周三', + 3: '周四', + 4: '周五', + 5: '周六', + 6: '周日', +}; + +const storePickupMap = new Map(); + +/** 解析 URL 查询参数。 */ +function parseUrlParams(url: string) { + const parsed = new URL(url, 'http://localhost'); + const params: Record = {}; + 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, + }, + }; +}); diff --git a/apps/web-antd/src/router/routes/modules/store.ts b/apps/web-antd/src/router/routes/modules/store.ts index ee0714a..d866238 100644 --- a/apps/web-antd/src/router/routes/modules/store.ts +++ b/apps/web-antd/src/router/routes/modules/store.ts @@ -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: '自提设置', + }, + }, ], }, ]; diff --git a/apps/web-antd/src/views/store/components/CopyToStoresModal.vue b/apps/web-antd/src/views/store/components/CopyToStoresModal.vue new file mode 100644 index 0000000..079bc76 --- /dev/null +++ b/apps/web-antd/src/views/store/components/CopyToStoresModal.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/apps/web-antd/src/views/store/components/StoreScopeToolbar.vue b/apps/web-antd/src/views/store/components/StoreScopeToolbar.vue new file mode 100644 index 0000000..3b3c00b --- /dev/null +++ b/apps/web-antd/src/views/store/components/StoreScopeToolbar.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryCommonSettingsCard.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryCommonSettingsCard.vue new file mode 100644 index 0000000..07c4ad6 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryCommonSettingsCard.vue @@ -0,0 +1,130 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue new file mode 100644 index 0000000..3eaff47 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue @@ -0,0 +1,89 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue new file mode 100644 index 0000000..81a97a8 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue @@ -0,0 +1,161 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue new file mode 100644 index 0000000..9ebd9a3 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue @@ -0,0 +1,160 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue b/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue new file mode 100644 index 0000000..b731103 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue @@ -0,0 +1,83 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/components/RadiusTierSection.vue b/apps/web-antd/src/views/store/delivery/components/RadiusTierSection.vue new file mode 100644 index 0000000..2459643 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/RadiusTierSection.vue @@ -0,0 +1,86 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/composables/delivery-page/constants.ts b/apps/web-antd/src/views/store/delivery/composables/delivery-page/constants.ts new file mode 100644 index 0000000..c76bbe4 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/delivery-page/constants.ts @@ -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, +}; diff --git a/apps/web-antd/src/views/store/delivery/composables/delivery-page/copy-actions.ts b/apps/web-antd/src/views/store/delivery/composables/delivery-page/copy-actions.ts new file mode 100644 index 0000000..981e25c --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/delivery-page/copy-actions.ts @@ -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; + copyTargetStoreIds: Ref; + isCopyModalOpen: Ref; + isCopySubmitting: Ref; + selectedStoreId: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/store/delivery/composables/delivery-page/data-actions.ts b/apps/web-antd/src/views/store/delivery/composables/delivery-page/data-actions.ts new file mode 100644 index 0000000..e10123e --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/delivery-page/data-actions.ts @@ -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; + isSettingsLoading: Ref; + isStoreLoading: Ref; + mode: Ref; + polygonZones: Ref; + radiusTiers: Ref; + selectedStoreId: Ref; + snapshot: Ref; + stores: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/store/delivery/composables/delivery-page/helpers.ts b/apps/web-antd/src/views/store/delivery/composables/delivery-page/helpers.ts new file mode 100644 index 0000000..b7d24d7 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/delivery-page/helpers.ts @@ -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'; +} diff --git a/apps/web-antd/src/views/store/delivery/composables/delivery-page/tier-actions.ts b/apps/web-antd/src/views/store/delivery/composables/delivery-page/tier-actions.ts new file mode 100644 index 0000000..f4f18f3 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/delivery-page/tier-actions.ts @@ -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; + radiusTiers: Ref; + sortRadiusTiers: (source: RadiusTierDto[]) => RadiusTierDto[]; + tierDrawerMode: Ref; + 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, + }; +} diff --git a/apps/web-antd/src/views/store/delivery/composables/delivery-page/zone-actions.ts b/apps/web-antd/src/views/store/delivery/composables/delivery-page/zone-actions.ts new file mode 100644 index 0000000..ae6bfc1 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/delivery-page/zone-actions.ts @@ -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; + polygonZones: Ref; + sortPolygonZones: (source: PolygonZoneDto[]) => PolygonZoneDto[]; + zoneDrawerMode: Ref; + 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, + }; +} diff --git a/apps/web-antd/src/views/store/delivery/composables/useStoreDeliveryPage.ts b/apps/web-antd/src/views/store/delivery/composables/useStoreDeliveryPage.ts new file mode 100644 index 0000000..9234698 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/composables/useStoreDeliveryPage.ts @@ -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([]); + const selectedStoreId = ref(''); + const deliveryMode = ref(DEFAULT_DELIVERY_MODE); + const radiusTiers = ref( + cloneRadiusTiers(DEFAULT_RADIUS_TIERS), + ); + const polygonZones = ref( + clonePolygonZones(DEFAULT_POLYGON_ZONES), + ); + const generalSettings = reactive( + cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS), + ); + + // 3. 页面弹窗与抽屉状态。 + const isCopyModalOpen = ref(false); + const copyTargetStoreIds = ref([]); + const snapshot = ref(null); + + const isTierDrawerOpen = ref(false); + const tierDrawerMode = ref('create'); + const tierForm = reactive({ + id: '', + minDistance: 0, + maxDistance: 1, + deliveryFee: 5, + etaMinutes: 30, + minOrderAmount: 20, + color: getTierColorByIndex(0), + }); + + const isZoneDrawerOpen = ref(false); + const zoneDrawerMode = ref('create'); + const zoneForm = reactive({ + 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, + }; +} diff --git a/apps/web-antd/src/views/store/delivery/index.vue b/apps/web-antd/src/views/store/delivery/index.vue new file mode 100644 index 0000000..c2aba30 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/index.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/apps/web-antd/src/views/store/delivery/styles/base.less b/apps/web-antd/src/views/store/delivery/styles/base.less new file mode 100644 index 0000000..2b87efe --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/base.less @@ -0,0 +1,10 @@ +/* 文件职责:配送设置页面基础骨架样式。 */ +.page-store-delivery { + max-width: 980px; + + .section-title { + font-size: 14px; + font-weight: 600; + color: #1a1a2e; + } +} diff --git a/apps/web-antd/src/views/store/delivery/styles/common.less b/apps/web-antd/src/views/store/delivery/styles/common.less new file mode 100644 index 0000000..662f695 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/common.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/delivery/styles/drawer.less b/apps/web-antd/src/views/store/delivery/styles/drawer.less new file mode 100644 index 0000000..67eb3df --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/drawer.less @@ -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; +} diff --git a/apps/web-antd/src/views/store/delivery/styles/index.less b/apps/web-antd/src/views/store/delivery/styles/index.less new file mode 100644 index 0000000..242022d --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/index.less @@ -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'; diff --git a/apps/web-antd/src/views/store/delivery/styles/mode.less b/apps/web-antd/src/views/store/delivery/styles/mode.less new file mode 100644 index 0000000..d0195f7 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/mode.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/delivery/styles/responsive.less b/apps/web-antd/src/views/store/delivery/styles/responsive.less new file mode 100644 index 0000000..3a76465 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/responsive.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/delivery/styles/tier.less b/apps/web-antd/src/views/store/delivery/styles/tier.less new file mode 100644 index 0000000..9f9ab5b --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/tier.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/delivery/styles/zone.less b/apps/web-antd/src/views/store/delivery/styles/zone.less new file mode 100644 index 0000000..ecff786 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/styles/zone.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/delivery/types.ts b/apps/web-antd/src/views/store/delivery/types.ts new file mode 100644 index 0000000..7642c73 --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/types.ts @@ -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[]; +} diff --git a/apps/web-antd/src/views/store/hours/components/CopyStoreModal.vue b/apps/web-antd/src/views/store/hours/components/CopyStoreModal.vue deleted file mode 100644 index f61bdd2..0000000 --- a/apps/web-antd/src/views/store/hours/components/CopyStoreModal.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/apps/web-antd/src/views/store/hours/index.vue b/apps/web-antd/src/views/store/hours/index.vue index dd447e9..ef174d4 100644 --- a/apps/web-antd/src/views/store/hours/index.vue +++ b/apps/web-antd/src/views/store/hours/index.vue @@ -1,10 +1,11 @@ + + diff --git a/apps/web-antd/src/views/store/pickup/components/PickupBigSlotSection.vue b/apps/web-antd/src/views/store/pickup/components/PickupBigSlotSection.vue new file mode 100644 index 0000000..99649b9 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/components/PickupBigSlotSection.vue @@ -0,0 +1,111 @@ + + + diff --git a/apps/web-antd/src/views/store/pickup/components/PickupFineRuleSection.vue b/apps/web-antd/src/views/store/pickup/components/PickupFineRuleSection.vue new file mode 100644 index 0000000..db14dc0 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/components/PickupFineRuleSection.vue @@ -0,0 +1,174 @@ + + + diff --git a/apps/web-antd/src/views/store/pickup/components/PickupModeSwitch.vue b/apps/web-antd/src/views/store/pickup/components/PickupModeSwitch.vue new file mode 100644 index 0000000..e88f46c --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/components/PickupModeSwitch.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/web-antd/src/views/store/pickup/components/PickupPreviewSection.vue b/apps/web-antd/src/views/store/pickup/components/PickupPreviewSection.vue new file mode 100644 index 0000000..d1ca2f1 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/components/PickupPreviewSection.vue @@ -0,0 +1,96 @@ + + + diff --git a/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue b/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue new file mode 100644 index 0000000..17f077e --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue @@ -0,0 +1,204 @@ + + + diff --git a/apps/web-antd/src/views/store/pickup/composables/pickup-page/constants.ts b/apps/web-antd/src/views/store/pickup/composables/pickup-page/constants.ts new file mode 100644 index 0000000..94960b6 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/pickup-page/constants.ts @@ -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], +}; diff --git a/apps/web-antd/src/views/store/pickup/composables/pickup-page/copy-actions.ts b/apps/web-antd/src/views/store/pickup/composables/pickup-page/copy-actions.ts new file mode 100644 index 0000000..92bffe7 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/pickup-page/copy-actions.ts @@ -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; + copyTargetStoreIds: Ref; + isCopyModalOpen: Ref; + isCopySubmitting: Ref; + selectedStoreId: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/store/pickup/composables/pickup-page/data-actions.ts b/apps/web-antd/src/views/store/pickup/composables/pickup-page/data-actions.ts new file mode 100644 index 0000000..45fb0c2 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/pickup-page/data-actions.ts @@ -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; + fineRule: PickupFineRuleDto; + isPageLoading: Ref; + isSavingBasic: Ref; + isSavingFineRule: Ref; + isSavingSlots: Ref; + isStoreLoading: Ref; + mode: Ref; + previewDays: Ref; + selectedStoreId: Ref; + snapshot: Ref; + stores: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/store/pickup/composables/pickup-page/fine-rule-actions.ts b/apps/web-antd/src/views/store/pickup/composables/pickup-page/fine-rule-actions.ts new file mode 100644 index 0000000..577e55f --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/pickup-page/fine-rule-actions.ts @@ -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; + saveFineRule: () => Promise; + selectedPreviewDate: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/store/pickup/composables/pickup-page/helpers.ts b/apps/web-antd/src/views/store/pickup/composables/pickup-page/helpers.ts new file mode 100644 index 0000000..4c30133 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/pickup-page/helpers.ts @@ -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 ?? ''; +} diff --git a/apps/web-antd/src/views/store/pickup/composables/pickup-page/slot-actions.ts b/apps/web-antd/src/views/store/pickup/composables/pickup-page/slot-actions.ts new file mode 100644 index 0000000..7916967 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/pickup-page/slot-actions.ts @@ -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; + createSlotId: () => string; + isSlotDrawerOpen: Ref; + saveBigSlots: () => Promise; + slotDrawerMode: Ref; + 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, + }; +} diff --git a/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts b/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts new file mode 100644 index 0000000..617884c --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts @@ -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([]); + const selectedStoreId = ref(''); + const pickupMode = ref(DEFAULT_PICKUP_MODE); + const basicSettings = reactive( + cloneBasicSettings(DEFAULT_PICKUP_BASIC_SETTINGS), + ); + const bigSlots = ref( + 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); + + // 3. 复制弹窗状态。 + const isCopyModalOpen = ref(false); + const copyTargetStoreIds = ref([]); + + // 4. 大时段抽屉状态。 + const isSlotDrawerOpen = ref(false); + const slotDrawerMode = ref('create'); + const slotForm = reactive({ + 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, + }; +} diff --git a/apps/web-antd/src/views/store/pickup/index.vue b/apps/web-antd/src/views/store/pickup/index.vue new file mode 100644 index 0000000..ace1a43 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/index.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/apps/web-antd/src/views/store/pickup/styles/base.less b/apps/web-antd/src/views/store/pickup/styles/base.less new file mode 100644 index 0000000..4eb4040 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/base.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/pickup/styles/basic.less b/apps/web-antd/src/views/store/pickup/styles/basic.less new file mode 100644 index 0000000..83b7c0f --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/basic.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/pickup/styles/drawer.less b/apps/web-antd/src/views/store/pickup/styles/drawer.less new file mode 100644 index 0000000..2d45193 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/drawer.less @@ -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; +} diff --git a/apps/web-antd/src/views/store/pickup/styles/index.less b/apps/web-antd/src/views/store/pickup/styles/index.less new file mode 100644 index 0000000..ed01c24 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/index.less @@ -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'; diff --git a/apps/web-antd/src/views/store/pickup/styles/mode.less b/apps/web-antd/src/views/store/pickup/styles/mode.less new file mode 100644 index 0000000..1f5f0c5 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/mode.less @@ -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%); + } +} diff --git a/apps/web-antd/src/views/store/pickup/styles/preview.less b/apps/web-antd/src/views/store/pickup/styles/preview.less new file mode 100644 index 0000000..6b2361e --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/preview.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/pickup/styles/responsive.less b/apps/web-antd/src/views/store/pickup/styles/responsive.less new file mode 100644 index 0000000..5894d00 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/responsive.less @@ -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; + } + } +} diff --git a/apps/web-antd/src/views/store/pickup/styles/slot.less b/apps/web-antd/src/views/store/pickup/styles/slot.less new file mode 100644 index 0000000..bd5a057 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/styles/slot.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/store/pickup/types.ts b/apps/web-antd/src/views/store/pickup/types.ts new file mode 100644 index 0000000..0fb3b56 --- /dev/null +++ b/apps/web-antd/src/views/store/pickup/types.ts @@ -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; +}