From 270c51de590cfdfd8f34429fb8d18b7067ea550c Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sat, 21 Feb 2026 11:52:09 +0800 Subject: [PATCH] feat(project): rebuild schedule supply module with split structure --- apps/web-antd/src/api/product/index.ts | 25 +- apps/web-antd/src/mock/product-extensions.ts | 88 +-- .../components/ScheduleEditorDrawer.vue | 229 ++++++ .../components/ScheduleProductPickerModal.vue | 77 ++ .../schedule/components/ScheduleRuleCard.vue | 121 ++++ .../components/ScheduleTimelineCard.vue | 55 ++ .../composables/schedule-page/constants.ts | 75 ++ .../composables/schedule-page/data-actions.ts | 147 ++++ .../schedule-page/drawer-actions.ts | 244 +++++++ .../composables/schedule-page/rule-actions.ts | 69 ++ .../schedule-page/timeline-utils.ts | 99 +++ .../composables/useProductSchedulePage.ts | 237 ++++++ .../src/views/product/schedule/index.vue | 681 ++++-------------- .../views/product/schedule/styles/base.less | 108 +++ .../views/product/schedule/styles/card.less | 140 ++++ .../views/product/schedule/styles/drawer.less | 314 ++++++++ .../views/product/schedule/styles/index.less | 6 + .../views/product/schedule/styles/layout.less | 108 +++ .../product/schedule/styles/responsive.less | 30 + .../product/schedule/styles/timeline.less | 85 +++ .../src/views/product/schedule/types.ts | 47 ++ 21 files changed, 2368 insertions(+), 617 deletions(-) create mode 100644 apps/web-antd/src/views/product/schedule/components/ScheduleEditorDrawer.vue create mode 100644 apps/web-antd/src/views/product/schedule/components/ScheduleProductPickerModal.vue create mode 100644 apps/web-antd/src/views/product/schedule/components/ScheduleRuleCard.vue create mode 100644 apps/web-antd/src/views/product/schedule/components/ScheduleTimelineCard.vue create mode 100644 apps/web-antd/src/views/product/schedule/composables/schedule-page/constants.ts create mode 100644 apps/web-antd/src/views/product/schedule/composables/schedule-page/data-actions.ts create mode 100644 apps/web-antd/src/views/product/schedule/composables/schedule-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/product/schedule/composables/schedule-page/rule-actions.ts create mode 100644 apps/web-antd/src/views/product/schedule/composables/schedule-page/timeline-utils.ts create mode 100644 apps/web-antd/src/views/product/schedule/composables/useProductSchedulePage.ts create mode 100644 apps/web-antd/src/views/product/schedule/styles/base.less create mode 100644 apps/web-antd/src/views/product/schedule/styles/card.less create mode 100644 apps/web-antd/src/views/product/schedule/styles/drawer.less create mode 100644 apps/web-antd/src/views/product/schedule/styles/index.less create mode 100644 apps/web-antd/src/views/product/schedule/styles/layout.less create mode 100644 apps/web-antd/src/views/product/schedule/styles/responsive.less create mode 100644 apps/web-antd/src/views/product/schedule/styles/timeline.less create mode 100644 apps/web-antd/src/views/product/schedule/types.ts diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 6fd6b72..32aba40 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -424,25 +424,17 @@ export interface ChangeProductLabelStatusDto { storeId: string; } -/** 时段条目。 */ -export interface ProductScheduleSlotDto { - endTime: string; - id: string; - startTime: string; - weekDays: number[]; -} - /** 时段模板。 */ export interface ProductScheduleDto { - description: string; + endTime: string; id: string; name: string; productCount: number; productIds: string[]; - slots: ProductScheduleSlotDto[]; - sort: number; + startTime: string; status: ProductSwitchStatus; updatedAt: string; + weekDays: number[]; } /** 时段查询参数。 */ @@ -454,19 +446,14 @@ export interface ProductScheduleQuery { /** 保存时段参数。 */ export interface SaveProductScheduleDto { - description: string; + endTime: string; id?: string; name: string; productIds: string[]; - slots: Array<{ - endTime: string; - id?: string; - startTime: string; - weekDays: number[]; - }>; - sort: number; + startTime: string; status: ProductSwitchStatus; storeId: string; + weekDays: number[]; } /** 删除时段参数。 */ diff --git a/apps/web-antd/src/mock/product-extensions.ts b/apps/web-antd/src/mock/product-extensions.ts index 76d9cbf..997d194 100644 --- a/apps/web-antd/src/mock/product-extensions.ts +++ b/apps/web-antd/src/mock/product-extensions.ts @@ -87,22 +87,15 @@ interface LabelRecord { updatedAt: string; } -interface ScheduleSlotRecord { - endTime: string; - id: string; - startTime: string; - weekDays: number[]; -} - interface ScheduleRecord { - description: string; + endTime: string; id: string; name: string; productIds: string[]; - slots: ScheduleSlotRecord[]; - sort: number; + startTime: string; status: ProductSwitchStatus; updatedAt: string; + weekDays: number[]; } interface ProductExtensionStoreState { @@ -403,18 +396,13 @@ function toScheduleItem( return { id: item.id, name: item.name, - description: item.description, - sort: item.sort, + startTime: item.startTime, + endTime: item.endTime, + weekDays: [...item.weekDays], status: item.status, productIds, productCount: productIds.length, updatedAt: item.updatedAt, - slots: item.slots.map((slot) => ({ - id: slot.id, - weekDays: [...slot.weekDays], - startTime: slot.startTime, - endTime: slot.endTime, - })), }; } @@ -552,19 +540,12 @@ function createDefaultState(storeId: string): ProductExtensionStoreState { { id: createId('schedule', storeId), name: '午市供应', - description: '适用于午餐时段', - sort: 1, + startTime: '10:30', + endTime: '14:00', + weekDays: [1, 2, 3, 4, 5, 6, 7], status: 'enabled', productIds: products.slice(0, 8).map((item) => item.id), updatedAt: toDateTimeText(new Date()), - slots: [ - { - id: createId('slot', storeId), - weekDays: [1, 2, 3, 4, 5, 6, 7], - startTime: '10:30', - endTime: '14:00', - }, - ], }, ]; @@ -1300,14 +1281,11 @@ Mock.mock( const state = ensureStoreState(storeId); const list = state.schedules - .toSorted((a, b) => a.sort - b.sort) + .toSorted((a, b) => a.name.localeCompare(b.name)) .filter((item) => { if (status && item.status !== status) return false; if (!keyword) return true; - return ( - item.name.toLowerCase().includes(keyword) || - item.description.toLowerCase().includes(keyword) - ); + return item.name.toLowerCase().includes(keyword); }) .map((item) => toScheduleItem(state, item)); @@ -1329,29 +1307,14 @@ Mock.mock( const state = ensureStoreState(storeId); const existingIndex = state.schedules.findIndex((item) => item.id === id); - const fallbackSort = - state.schedules.reduce((max, item) => Math.max(max, item.sort), 0) + 1; const productIds = normalizeIdList(body.productIds).filter((productId) => state.products.some((item) => item.id === productId), ); - - const slotListRaw = Array.isArray(body.slots) ? body.slots : []; - const slots: ScheduleSlotRecord[] = slotListRaw - .map((slot) => { - if (!slot || typeof slot !== 'object') return null; - const current = slot as Record; - return { - id: normalizeText(current.id, createId('slot', storeId)), - weekDays: normalizeWeekDays(current.weekDays), - startTime: normalizeTimeText(current.startTime, '09:00'), - endTime: normalizeTimeText(current.endTime, '21:00'), - }; - }) - .filter((item): item is ScheduleSlotRecord => item !== null) - .toSorted((a, b) => a.startTime.localeCompare(b.startTime)); - - if (slots.length === 0) { - return { code: 400, data: null, message: '至少保留一个时段' }; + const startTime = normalizeTimeText(body.startTime, '09:00'); + const endTime = normalizeTimeText(body.endTime, '21:00'); + const weekDays = normalizeWeekDays(body.weekDays); + if (productIds.length === 0) { + return { code: 400, data: null, message: '请至少关联一个商品' }; } const next: ScheduleRecord = @@ -1359,31 +1322,24 @@ Mock.mock( ? { id: createId('schedule', storeId), name, - description: normalizeText(body.description), - sort: normalizeInt(body.sort, fallbackSort, 1), + startTime, + endTime, + weekDays, status: normalizeSwitchStatus(body.status, 'enabled'), productIds, - slots, updatedAt: toDateTimeText(new Date()), } : { ...state.schedules[existingIndex], name, - description: normalizeText( - body.description, - state.schedules[existingIndex].description, - ), - sort: normalizeInt( - body.sort, - state.schedules[existingIndex].sort, - 1, - ), + startTime, + endTime, + weekDays, status: normalizeSwitchStatus( body.status, state.schedules[existingIndex].status, ), productIds, - slots, updatedAt: toDateTimeText(new Date()), }; diff --git a/apps/web-antd/src/views/product/schedule/components/ScheduleEditorDrawer.vue b/apps/web-antd/src/views/product/schedule/components/ScheduleEditorDrawer.vue new file mode 100644 index 0000000..dd1f820 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/components/ScheduleEditorDrawer.vue @@ -0,0 +1,229 @@ + + + diff --git a/apps/web-antd/src/views/product/schedule/components/ScheduleProductPickerModal.vue b/apps/web-antd/src/views/product/schedule/components/ScheduleProductPickerModal.vue new file mode 100644 index 0000000..5ed0d73 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/components/ScheduleProductPickerModal.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/web-antd/src/views/product/schedule/components/ScheduleRuleCard.vue b/apps/web-antd/src/views/product/schedule/components/ScheduleRuleCard.vue new file mode 100644 index 0000000..6926c17 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/components/ScheduleRuleCard.vue @@ -0,0 +1,121 @@ + + + diff --git a/apps/web-antd/src/views/product/schedule/components/ScheduleTimelineCard.vue b/apps/web-antd/src/views/product/schedule/components/ScheduleTimelineCard.vue new file mode 100644 index 0000000..00ee708 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/components/ScheduleTimelineCard.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/web-antd/src/views/product/schedule/composables/schedule-page/constants.ts b/apps/web-antd/src/views/product/schedule/composables/schedule-page/constants.ts new file mode 100644 index 0000000..385127c --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/composables/schedule-page/constants.ts @@ -0,0 +1,75 @@ +import type { ScheduleEditorForm } from '../../types'; + +/** + * 文件职责:时段供应页面常量。 + */ + +/** 星期选项。 */ +export const WEEK_DAY_OPTIONS = [ + { label: '周一', value: 1 }, + { label: '周二', value: 2 }, + { label: '周三', value: 3 }, + { label: '周四', value: 4 }, + { label: '周五', value: 5 }, + { label: '周六', value: 6 }, + { label: '周日', value: 7 }, +] as const; + +/** 全周。 */ +export const WEEK_DAYS_ALL = [1, 2, 3, 4, 5, 6, 7] as const; + +/** 工作日。 */ +export const WEEK_DAYS_WEEKDAY = [1, 2, 3, 4, 5] as const; + +/** 周末。 */ +export const WEEK_DAYS_WEEKEND = [6, 7] as const; + +/** 时间轴刻度。 */ +export const TIMELINE_AXIS_TICKS = [0, 3, 6, 9, 12, 15, 18, 21, 24] as const; + +/** 规则颜色池。 */ +export const SCHEDULE_COLOR_PALETTE = [ + '#1890ff', + '#52c41a', + '#722ed1', + '#fa8c16', + '#13c2c2', + '#eb2f96', + '#2f54eb', +] as const; + +/** 创建默认编辑表单。 */ +export function createDefaultScheduleForm(): ScheduleEditorForm { + return { + id: '', + name: '', + startTime: '06:00', + endTime: '10:00', + weekDays: [...WEEK_DAYS_ALL], + productIds: [], + status: 'enabled', + }; +} + +/** 归一化星期列表。 */ +export function normalizeWeekDays(weekDays: number[]) { + const next = weekDays + .map(Number) + .filter((item) => Number.isInteger(item) && item >= 1 && item <= 7) + .toSorted((a, b) => a - b); + return [...new Set(next)]; +} + +/** 按规则 ID 生成稳定颜色。 */ +export function resolveScheduleColor(scheduleId: string) { + if (!scheduleId) { + return SCHEDULE_COLOR_PALETTE[0]; + } + + let hash = 0; + for (const char of scheduleId) { + hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0; + } + + return SCHEDULE_COLOR_PALETTE[hash % SCHEDULE_COLOR_PALETTE.length]; +} diff --git a/apps/web-antd/src/views/product/schedule/composables/schedule-page/data-actions.ts b/apps/web-antd/src/views/product/schedule/composables/schedule-page/data-actions.ts new file mode 100644 index 0000000..6c306f2 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/composables/schedule-page/data-actions.ts @@ -0,0 +1,147 @@ +import type { Ref } from 'vue'; + +import type { ProductPickerItemDto, ProductScheduleDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; +import type { ScheduleProductLookup } from '#/views/product/schedule/types'; + +/** + * 文件职责:时段供应数据加载动作。 + */ +import { message } from 'ant-design-vue'; + +import { + getProductScheduleListApi, + searchProductPickerApi, +} from '#/api/product'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + isLoading: Ref; + isPickerLoading: Ref; + isStoreLoading: Ref; + pickerKeyword: Ref; + pickerProducts: Ref; + productLookup: Ref; + rows: Ref; + selectedStoreId: Ref; + stores: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + function mergeProductLookup(list: ProductPickerItemDto[]) { + if (list.length === 0) return; + + const next: ScheduleProductLookup = { + ...options.productLookup.value, + }; + for (const item of list) { + next[item.id] = item; + } + + options.productLookup.value = next; + } + + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + + options.stores.value = result.items ?? []; + if (options.stores.value.length === 0) { + options.selectedStoreId.value = ''; + options.rows.value = []; + options.pickerProducts.value = []; + options.productLookup.value = {}; + return; + } + + const hasSelected = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelected) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadSchedules() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + return; + } + + options.isLoading.value = true; + try { + const list = await getProductScheduleListApi({ + storeId: options.selectedStoreId.value, + }); + options.rows.value = list.toSorted((a, b) => + a.name.localeCompare(b.name), + ); + } catch (error) { + console.error(error); + options.rows.value = []; + message.error('加载时段规则失败'); + } finally { + options.isLoading.value = false; + } + } + + async function loadPickerProducts() { + if (!options.selectedStoreId.value) { + options.pickerProducts.value = []; + return; + } + + options.isPickerLoading.value = true; + try { + const list = await searchProductPickerApi({ + storeId: options.selectedStoreId.value, + keyword: options.pickerKeyword.value.trim() || undefined, + limit: 500, + }); + options.pickerProducts.value = list; + mergeProductLookup(list); + } catch (error) { + console.error(error); + options.pickerProducts.value = []; + message.error('加载商品失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function preloadProductLookup() { + if (!options.selectedStoreId.value) { + options.pickerProducts.value = []; + options.productLookup.value = {}; + return; + } + + try { + const list = await searchProductPickerApi({ + storeId: options.selectedStoreId.value, + limit: 500, + }); + options.pickerProducts.value = list; + mergeProductLookup(list); + } catch (error) { + console.error(error); + } + } + + return { + loadPickerProducts, + loadSchedules, + loadStores, + preloadProductLookup, + }; +} diff --git a/apps/web-antd/src/views/product/schedule/composables/schedule-page/drawer-actions.ts b/apps/web-antd/src/views/product/schedule/composables/schedule-page/drawer-actions.ts new file mode 100644 index 0000000..cb254fb --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/composables/schedule-page/drawer-actions.ts @@ -0,0 +1,244 @@ +import type { Ref } from 'vue'; + +import type { + ProductScheduleCardViewModel, + ScheduleEditorForm, +} from '#/views/product/schedule/types'; + +/** + * 文件职责:时段规则编辑抽屉动作。 + */ +import { message } from 'ant-design-vue'; + +import { saveProductScheduleApi } from '#/api/product'; + +import { + createDefaultScheduleForm, + normalizeWeekDays, + WEEK_DAYS_ALL, + WEEK_DAYS_WEEKDAY, + WEEK_DAYS_WEEKEND, +} from './constants'; +import { isValidTimeText } from './timeline-utils'; + +interface CreateDrawerActionsOptions { + drawerMode: Ref<'create' | 'edit'>; + editingScheduleId: Ref; + form: ScheduleEditorForm; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + isPickerOpen: Ref; + loadPickerProducts: () => Promise; + loadSchedules: () => Promise; + pickerKeyword: Ref; + pickerSelectedIds: Ref; + rows: Ref; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function setPickerOpen(value: boolean) { + options.isPickerOpen.value = value; + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormStartTime(value: string) { + options.form.startTime = value; + } + + function setFormEndTime(value: string) { + options.form.endTime = value; + } + + function toggleFormStatus() { + options.form.status = + options.form.status === 'enabled' ? 'disabled' : 'enabled'; + } + + function setFormWeekDays(value: number[]) { + options.form.weekDays = normalizeWeekDays(value); + } + + function toggleWeekDay(day: number) { + if (options.form.weekDays.includes(day)) { + setFormWeekDays(options.form.weekDays.filter((item) => item !== day)); + return; + } + + setFormWeekDays([...options.form.weekDays, day]); + } + + function selectAllDays() { + setFormWeekDays([...WEEK_DAYS_ALL]); + } + + function selectWeekdays() { + setFormWeekDays([...WEEK_DAYS_WEEKDAY]); + } + + function selectWeekend() { + setFormWeekDays([...WEEK_DAYS_WEEKEND]); + } + + function removeFormProduct(productId: string) { + options.form.productIds = options.form.productIds.filter( + (item) => item !== productId, + ); + } + + function setPickerKeyword(value: string) { + options.pickerKeyword.value = value; + } + + function togglePickerProduct(productId: string) { + if (options.pickerSelectedIds.value.includes(productId)) { + options.pickerSelectedIds.value = options.pickerSelectedIds.value.filter( + (item) => item !== productId, + ); + return; + } + + options.pickerSelectedIds.value = [ + ...options.pickerSelectedIds.value, + productId, + ]; + } + + async function openProductPicker() { + if (!options.selectedStoreId.value) { + return; + } + + options.pickerKeyword.value = ''; + options.pickerSelectedIds.value = [...options.form.productIds]; + await options.loadPickerProducts(); + options.isPickerOpen.value = true; + } + + function submitPicker() { + options.form.productIds = [...options.pickerSelectedIds.value]; + options.isPickerOpen.value = false; + } + + function resetForm() { + const next = createDefaultScheduleForm(); + options.editingScheduleId.value = ''; + options.form.id = ''; + options.form.name = next.name; + options.form.startTime = next.startTime; + options.form.endTime = next.endTime; + options.form.weekDays = [...next.weekDays]; + options.form.productIds = []; + options.form.status = next.status; + } + + function openCreateDrawer() { + options.drawerMode.value = 'create'; + resetForm(); + options.isDrawerOpen.value = true; + } + + function openEditDrawer(item: ProductScheduleCardViewModel) { + options.drawerMode.value = 'edit'; + options.editingScheduleId.value = item.id; + options.form.id = item.id; + options.form.name = item.name; + options.form.startTime = item.startTime; + options.form.endTime = item.endTime; + options.form.weekDays = [...item.weekDays]; + options.form.productIds = [...item.productIds]; + options.form.status = item.status; + options.isDrawerOpen.value = true; + } + + async function submitDrawer() { + if (!options.selectedStoreId.value) return; + + const normalizedName = options.form.name.trim(); + if (!normalizedName) { + message.warning('请输入规则名称'); + return; + } + + if (!isValidTimeText(options.form.startTime)) { + message.warning('开始时间格式不正确'); + return; + } + + if (!isValidTimeText(options.form.endTime)) { + message.warning('结束时间格式不正确'); + return; + } + + if (options.form.startTime === options.form.endTime) { + message.warning('开始和结束时间不能相同'); + return; + } + + const normalizedWeekDays = normalizeWeekDays(options.form.weekDays); + if (normalizedWeekDays.length === 0) { + message.warning('请至少选择一个适用星期'); + return; + } + + const normalizedProductIds = [...new Set(options.form.productIds)]; + if (normalizedProductIds.length === 0) { + message.warning('请至少关联一个商品'); + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveProductScheduleApi({ + storeId: options.selectedStoreId.value, + id: options.editingScheduleId.value || undefined, + name: normalizedName, + startTime: options.form.startTime, + endTime: options.form.endTime, + weekDays: normalizedWeekDays, + productIds: normalizedProductIds, + status: options.form.status, + }); + + message.success( + options.drawerMode.value === 'create' + ? '时段规则已创建' + : '时段规则已更新', + ); + options.isDrawerOpen.value = false; + await options.loadSchedules(); + } catch (error) { + console.error(error); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + openCreateDrawer, + openEditDrawer, + openProductPicker, + removeFormProduct, + selectAllDays, + selectWeekdays, + selectWeekend, + setDrawerOpen, + setFormEndTime, + setFormName, + setFormStartTime, + setPickerKeyword, + setPickerOpen, + submitDrawer, + submitPicker, + toggleFormStatus, + togglePickerProduct, + toggleWeekDay, + }; +} diff --git a/apps/web-antd/src/views/product/schedule/composables/schedule-page/rule-actions.ts b/apps/web-antd/src/views/product/schedule/composables/schedule-page/rule-actions.ts new file mode 100644 index 0000000..915dab3 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/composables/schedule-page/rule-actions.ts @@ -0,0 +1,69 @@ +import type { Ref } from 'vue'; + +import type { ProductScheduleCardViewModel } from '#/views/product/schedule/types'; + +/** + * 文件职责:时段规则卡片动作。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeProductScheduleStatusApi, + deleteProductScheduleApi, +} from '#/api/product'; + +interface CreateRuleActionsOptions { + loadSchedules: () => Promise; + selectedStoreId: Ref; +} + +export function createRuleActions(options: CreateRuleActionsOptions) { + async function setRuleStatus( + item: ProductScheduleCardViewModel, + status: ProductScheduleCardViewModel['status'], + ) { + if (!options.selectedStoreId.value) return; + + try { + const result = await changeProductScheduleStatusApi({ + storeId: options.selectedStoreId.value, + scheduleId: item.id, + status, + }); + item.status = result.status; + item.updatedAt = result.updatedAt; + message.success(status === 'enabled' ? '已启用规则' : '已停用规则'); + } catch (error) { + console.error(error); + } + } + + async function toggleRuleStatus(item: ProductScheduleCardViewModel) { + const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled'; + await setRuleStatus(item, nextStatus); + } + + function removeRule(item: ProductScheduleCardViewModel) { + if (!options.selectedStoreId.value) return; + + Modal.confirm({ + title: `确认删除规则「${item.name}」吗?`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + async onOk() { + await deleteProductScheduleApi({ + storeId: options.selectedStoreId.value, + scheduleId: item.id, + }); + message.success('时段规则已删除'); + await options.loadSchedules(); + }, + }); + } + + return { + removeRule, + toggleRuleStatus, + }; +} diff --git a/apps/web-antd/src/views/product/schedule/composables/schedule-page/timeline-utils.ts b/apps/web-antd/src/views/product/schedule/composables/schedule-page/timeline-utils.ts new file mode 100644 index 0000000..84496af --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/composables/schedule-page/timeline-utils.ts @@ -0,0 +1,99 @@ +import type { + ProductScheduleCardViewModel, + ScheduleTimelineBar, + ScheduleTimelineRow, +} from '../../types'; + +/** + * 文件职责:时段时间轴与时间段计算。 + */ + +function tryParseTimeMinutes(value: string): null | number { + const matched = /^([01]\d|2[0-3]):([0-5]\d)$/.exec((value || '').trim()); + if (!matched) return null; + + const hour = Number(matched[1]); + const minute = Number(matched[2]); + return hour * 60 + minute; +} + +function toPercent(minutes: number) { + return Number(((minutes / 1440) * 100).toFixed(4)); +} + +function toHourLabel(value: string, trimLeadingZero = false) { + const hour = value.slice(0, 2); + if (!trimLeadingZero) return hour; + return String(Number(hour)); +} + +/** 校验时间文本。 */ +export function isValidTimeText(value: string) { + return tryParseTimeMinutes(value) !== null; +} + +/** 是否跨天时段。 */ +export function isCrossDay(startTime: string, endTime: string) { + const start = tryParseTimeMinutes(startTime); + const end = tryParseTimeMinutes(endTime); + if (start === null || end === null) return false; + return end <= start; +} + +/** 卡片显示时间文本。 */ +export function formatTimeRangeText(startTime: string, endTime: string) { + const suffix = isCrossDay(startTime, endTime) ? ' (次日)' : ''; + return `${startTime} ~ ${endTime}${suffix}`; +} + +/** 计算时间条段(支持跨天拆分)。 */ +export function buildTimeSegments(startTime: string, endTime: string) { + const start = tryParseTimeMinutes(startTime); + const end = tryParseTimeMinutes(endTime); + if (start === null || end === null || start === end) { + return [] as ScheduleTimelineBar[]; + } + + if (end > start) { + return [ + { + left: toPercent(start), + width: toPercent(end - start), + label: `${toHourLabel(startTime)}~${toHourLabel(endTime)}`, + }, + ]; + } + + return [ + { + left: toPercent(start), + width: toPercent(1440 - start), + label: `${toHourLabel(startTime)}~24`, + }, + { + left: 0, + width: toPercent(end), + label: `0~${toHourLabel(endTime, true)}`, + }, + ]; +} + +/** 今日星期(1-7)。 */ +export function getTodayWeekDay() { + const day = new Date().getDay(); + return day === 0 ? 7 : day; +} + +/** 构造今日时间轴行。 */ +export function buildTimelineRow( + item: ProductScheduleCardViewModel, + color: string, +): ScheduleTimelineRow { + return { + id: item.id, + name: item.name, + status: item.status, + color, + bars: buildTimeSegments(item.startTime, item.endTime), + }; +} diff --git a/apps/web-antd/src/views/product/schedule/composables/useProductSchedulePage.ts b/apps/web-antd/src/views/product/schedule/composables/useProductSchedulePage.ts new file mode 100644 index 0000000..f50ff75 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/composables/useProductSchedulePage.ts @@ -0,0 +1,237 @@ +import type { ProductPickerItemDto, ProductScheduleDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; +import type { + ScheduleEditorForm, + ScheduleProductChip, + ScheduleProductLookup, +} from '#/views/product/schedule/types'; + +/** + * 文件职责:时段供应页面状态与行为编排。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { + createDefaultScheduleForm, + resolveScheduleColor, +} from './schedule-page/constants'; +import { createDataActions } from './schedule-page/data-actions'; +import { createDrawerActions } from './schedule-page/drawer-actions'; +import { createRuleActions } from './schedule-page/rule-actions'; +import { + buildTimelineRow, + getTodayWeekDay, +} from './schedule-page/timeline-utils'; + +export function useProductSchedulePage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const rows = ref([]); + const isLoading = ref(false); + const keyword = ref(''); + + const isDrawerOpen = ref(false); + const isDrawerSubmitting = ref(false); + const drawerMode = ref<'create' | 'edit'>('create'); + const editingScheduleId = ref(''); + + const form = reactive(createDefaultScheduleForm()); + + const isPickerOpen = ref(false); + const isPickerLoading = ref(false); + const pickerKeyword = ref(''); + const pickerProducts = ref([]); + const pickerSelectedIds = ref([]); + + const productLookup = ref({}); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const filteredRows = computed(() => { + const normalized = keyword.value.trim().toLowerCase(); + if (!normalized) return rows.value; + return rows.value.filter((item) => + item.name.toLowerCase().includes(normalized), + ); + }); + + const ruleCount = computed(() => rows.value.length); + + const enabledCount = computed( + () => rows.value.filter((item) => item.status === 'enabled').length, + ); + + const coveredProductCount = computed(() => { + const idSet = new Set(); + for (const item of rows.value) { + for (const productId of item.productIds) { + idSet.add(productId); + } + } + + return idSet.size; + }); + + const drawerTitle = computed(() => + drawerMode.value === 'create' ? '添加时段规则' : '编辑时段规则', + ); + + const drawerSubmitText = computed(() => '保存'); + + const selectedProducts = computed(() => + form.productIds.map((id) => ({ + id, + name: resolveProductName(id), + })), + ); + + const timelineRows = computed(() => { + const today = getTodayWeekDay(); + return filteredRows.value + .filter((item) => item.weekDays.includes(today)) + .map((item) => buildTimelineRow(item, getRuleColor(item.id))); + }); + + function getRuleColor(scheduleId: string) { + return resolveScheduleColor(scheduleId); + } + + function resolveProductName(productId: string) { + const product = productLookup.value[productId]; + if (product) return product.name; + + const suffix = productId.length > 4 ? productId.slice(-4) : productId; + return `商品${suffix}`; + } + + function getScheduleProductNames(item: ProductScheduleDto) { + return item.productIds.map((id) => resolveProductName(id)); + } + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setKeyword(value: string) { + keyword.value = value; + } + + const { + loadPickerProducts, + loadSchedules, + loadStores, + preloadProductLookup, + } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + rows, + isLoading, + isPickerLoading, + pickerKeyword, + pickerProducts, + productLookup, + }); + + const { + openCreateDrawer, + openEditDrawer, + openProductPicker, + removeFormProduct, + selectAllDays, + selectWeekdays, + selectWeekend, + setDrawerOpen, + setFormEndTime, + setFormName, + setFormStartTime, + setPickerKeyword, + setPickerOpen, + submitDrawer, + submitPicker, + toggleFormStatus, + togglePickerProduct, + toggleWeekDay, + } = createDrawerActions({ + drawerMode, + editingScheduleId, + form, + isDrawerOpen, + isDrawerSubmitting, + isPickerOpen, + pickerKeyword, + pickerSelectedIds, + rows, + selectedStoreId, + loadSchedules, + loadPickerProducts, + }); + + const { removeRule, toggleRuleStatus } = createRuleActions({ + selectedStoreId, + loadSchedules, + }); + + watch(selectedStoreId, () => { + keyword.value = ''; + void Promise.all([loadSchedules(), preloadProductLookup()]); + }); + + onMounted(loadStores); + + return { + coveredProductCount, + drawerSubmitText, + drawerTitle, + enabledCount, + filteredRows, + form, + getRuleColor, + getScheduleProductNames, + isDrawerOpen, + isDrawerSubmitting, + isLoading, + isPickerLoading, + isPickerOpen, + isStoreLoading, + keyword, + loadPickerProducts, + openCreateDrawer, + openEditDrawer, + openProductPicker, + pickerKeyword, + pickerProducts, + pickerSelectedIds, + removeFormProduct, + removeRule, + ruleCount, + selectAllDays, + selectedProducts, + selectedStoreId, + selectWeekdays, + selectWeekend, + setDrawerOpen, + setFormEndTime, + setFormName, + setFormStartTime, + setKeyword, + setPickerKeyword, + setPickerOpen, + setSelectedStoreId, + storeOptions, + submitDrawer, + submitPicker, + timelineRows, + toggleFormStatus, + togglePickerProduct, + toggleRuleStatus, + toggleWeekDay, + }; +} diff --git a/apps/web-antd/src/views/product/schedule/index.vue b/apps/web-antd/src/views/product/schedule/index.vue index 7a77be2..5442182 100644 --- a/apps/web-antd/src/views/product/schedule/index.vue +++ b/apps/web-antd/src/views/product/schedule/index.vue @@ -1,559 +1,176 @@ - diff --git a/apps/web-antd/src/views/product/schedule/styles/base.less b/apps/web-antd/src/views/product/schedule/styles/base.less new file mode 100644 index 0000000..d0b4730 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/base.less @@ -0,0 +1,108 @@ +/** + * 文件职责:时段供应页面基础样式。 + */ +:root { + --g-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --g-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --g-shadow-md: 0 4px 12px rgb(0 0 0 / 7%), 0 1px 3px rgb(0 0 0 / 4%); +} + +.g-action { + padding: 0; + font-size: 13px; + color: #1677ff; + cursor: pointer; + background: none; + border: none; +} + +.g-action + .g-action { + margin-left: 12px; +} + +.g-action-danger { + color: #ef4444; +} + +.g-drawer-mask { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; + background: rgb(0 0 0 / 45%); + opacity: 0; + transition: opacity 0.3s; +} + +.g-drawer-mask.open { + pointer-events: auto; + opacity: 1; +} + +.g-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 1001; + display: flex; + flex-direction: column; + background: #fff; + box-shadow: -6px 0 16px rgb(0 0 0 / 8%); + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1); +} + +.g-drawer.open { + transform: translateX(0); +} + +.g-drawer-hd { + display: flex; + flex-shrink: 0; + align-items: center; + height: 54px; + padding: 0 20px; + border-bottom: 1px solid #f0f0f0; +} + +.g-drawer-title { + flex: 1; + font-size: 16px; + font-weight: 600; + color: #1a1a2e; +} + +.g-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-size: 18px; + color: #999; + cursor: pointer; + background: none; + border: none; + border-radius: 6px; +} + +.g-drawer-close:hover { + color: #333; + background: #f5f5f5; +} + +.g-drawer-bd { + flex: 1; + padding: 20px 24px; + overflow-y: auto; +} + +.g-drawer-ft { + display: flex; + flex-shrink: 0; + gap: 8px; + justify-content: flex-end; + padding: 12px 20px; + border-top: 1px solid #f0f0f0; +} diff --git a/apps/web-antd/src/views/product/schedule/styles/card.less b/apps/web-antd/src/views/product/schedule/styles/card.less new file mode 100644 index 0000000..ecc54c3 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/card.less @@ -0,0 +1,140 @@ +.page-product-schedule { + .g-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + height: 24px; + padding: 0 8px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + } + + .ptm-tag-on { + color: #22c55e; + background: #dcfce7; + border: 1px solid #bbf7d0; + } + + .ptm-tag-off { + color: #9ca3af; + background: #f8f9fb; + border: 1px solid #e5e7eb; + } + + .ptm-card { + padding: 20px; + margin-bottom: 16px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + transition: var(--g-transition); + } + + .ptm-card:hover { + box-shadow: var(--g-shadow-md); + } + + .ptm-card.disabled { + opacity: 0.5; + } + + .ptm-card-hd { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 14px; + } + + .ptm-card-name { + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + } + + .ptm-time-row { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 12px; + } + + .ptm-time-text { + font-size: 22px; + font-weight: 700; + color: #1a1a2e; + letter-spacing: 1px; + } + + .ptm-timebar-wrap { + position: relative; + flex: 1; + height: 18px; + overflow: hidden; + background: #f8f9fb; + border-radius: 9px; + } + + .ptm-timebar-fill { + position: absolute; + top: 0; + bottom: 0; + border-radius: 9px; + opacity: 0.72; + } + + .ptm-days { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; + } + + .ptm-day { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 26px; + font-size: 12px; + color: #9ca3af; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 13px; + } + + .ptm-day.active { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .ptm-products { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 14px; + } + + .ptm-prod-pill { + display: inline-block; + padding: 2px 10px; + font-size: 12px; + color: #4b5563; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 12px; + } + + .ptm-prod-more { + color: #1677ff; + } + + .ptm-card-ft { + display: flex; + gap: 16px; + padding-top: 12px; + border-top: 1px solid #f3f4f6; + } +} diff --git a/apps/web-antd/src/views/product/schedule/styles/drawer.less b/apps/web-antd/src/views/product/schedule/styles/drawer.less new file mode 100644 index 0000000..3e27aa8 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/drawer.less @@ -0,0 +1,314 @@ +.ptm-editor-drawer { + .g-drawer-close .iconify { + width: 16px; + height: 16px; + } + + .g-form-group { + margin-bottom: 16px; + } + + .g-form-label { + display: inline-flex; + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + } + + .g-form-label.required::before { + margin-right: 4px; + color: #ef4444; + content: '*'; + } + + .g-hint { + margin-top: 6px; + font-size: 12px; + color: #9ca3af; + } + + .g-input { + width: 100%; + height: 34px; + padding: 0 10px; + font-size: 13px; + color: #1a1a2e; + outline: none; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: var(--g-transition); + } + + .g-input:focus { + border-color: #1677ff; + box-shadow: 0 0 0 3px rgb(22 119 255 / 12%); + } + + .g-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 16px; + font-size: 13px; + color: #1f1f1f; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + box-shadow: var(--g-shadow-sm); + transition: all var(--g-transition); + } + + .g-btn:hover { + color: #1677ff; + border-color: #1677ff; + box-shadow: var(--g-shadow-md); + } + + .g-btn-primary { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .g-btn-primary:hover { + color: #fff; + opacity: 0.88; + } + + .g-btn-sm { + height: 30px; + padding: 0 12px; + font-size: 12px; + } + + .g-btn:disabled { + cursor: not-allowed; + box-shadow: none; + opacity: 0.6; + } + + .g-toggle { + position: relative; + width: 40px; + height: 22px; + cursor: pointer; + background: #d9d9d9; + border: none; + border-radius: 11px; + transition: all 0.2s; + } + + .g-toggle::before { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + content: ''; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 3px rgb(0 0 0 / 15%); + transition: all 0.2s; + } + + .g-toggle.on { + background: #1677ff; + } + + .g-toggle.on::before { + transform: translateX(18px); + } + + .g-toggle-label { + font-size: 12px; + color: #4b5563; + } + + .ptm-fg-row { + display: flex; + gap: 10px; + align-items: center; + } + + .ptm-fg-row .g-input { + flex: 1; + } + + .ptm-fg-sep { + font-size: 14px; + color: #9ca3af; + } + + .ptm-day-sel { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + + .ptm-day { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 28px; + font-size: 12px; + color: #9ca3af; + cursor: pointer; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 14px; + transition: var(--g-transition); + } + + .ptm-day.active { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .ptm-day-quick { + display: flex; + gap: 6px; + } + + .ptm-prod-search .g-input { + cursor: pointer; + } + + .ptm-prod-selected { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + } + + .ptm-prod-chip { + display: inline-flex; + gap: 4px; + align-items: center; + padding: 4px 10px; + font-size: 12px; + color: #1677ff; + background: #f0f5ff; + border: 1px solid #adc6ff; + border-radius: 14px; + } + + .ptm-prod-chip-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + color: #9ca3af; + cursor: pointer; + background: none; + border: none; + border-radius: 999px; + transition: var(--g-transition); + } + + .ptm-prod-chip-x .iconify { + width: 12px; + height: 12px; + } + + .ptm-prod-chip-x:hover { + color: #ef4444; + background: #fff; + } + + .ptm-prod-empty { + font-size: 12px; + color: #9ca3af; + } + + .ptm-toggle-row { + display: flex; + gap: 10px; + align-items: center; + } +} + +.ptm-picker-search { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.ptm-picker-search .g-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 12px; + font-size: 13px; + color: #1f1f1f; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + transition: var(--g-transition); +} + +.ptm-picker-search .g-btn:hover { + color: #1677ff; + border-color: #1677ff; +} + +.ptm-picker-search .g-btn-sm { + flex-shrink: 0; +} + +.ptm-picker-list { + max-height: 320px; + overflow-y: auto; + border: 1px solid #f0f0f0; + border-radius: 8px; +} + +.ptm-picker-item { + display: grid; + grid-template-columns: auto 1fr 140px auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid #f5f5f5; +} + +.ptm-picker-item:last-child { + border-bottom: none; +} + +.ptm-picker-item:hover { + background: #fafcff; +} + +.ptm-picker-item .name { + font-size: 13px; + color: #1a1a2e; +} + +.ptm-picker-item .spu { + font-size: 12px; + color: #9ca3af; +} + +.ptm-picker-item .price { + font-size: 12px; + font-weight: 600; + color: #1a1a2e; +} + +.ptm-picker-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; +} diff --git a/apps/web-antd/src/views/product/schedule/styles/index.less b/apps/web-antd/src/views/product/schedule/styles/index.less new file mode 100644 index 0000000..ea3620d --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './timeline.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/schedule/styles/layout.less b/apps/web-antd/src/views/product/schedule/styles/layout.less new file mode 100644 index 0000000..c25ff71 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/layout.less @@ -0,0 +1,108 @@ +.page-product-schedule { + .ptm-page { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 980px; + } + + .ptm-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 12px 16px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .ptm-store-select { + width: 220px; + } + + .ptm-store-select .ant-select-selector { + height: 34px !important; + border-color: #e5e7eb !important; + border-radius: 8px !important; + } + + .ptm-store-select .ant-select-selection-item { + line-height: 32px !important; + } + + .ptm-search { + width: 220px; + } + + .ptm-search .ant-input { + height: 34px; + padding-left: 32px; + font-size: 13px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23bbbbbb' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E") + 10px center no-repeat; + border-radius: 8px; + } + + .ptm-spacer { + flex: 1; + } + + .ptm-stats { + display: flex; + gap: 24px; + padding: 10px 16px; + font-size: 13px; + color: #4b5563; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .ptm-stats span { + display: flex; + gap: 6px; + align-items: center; + } + + .ptm-stats strong { + font-weight: 600; + color: #1a1a2e; + } + + .ptm-banner { + display: flex; + gap: 8px; + align-items: center; + padding: 12px 16px; + font-size: 13px; + color: #0050b3; + background: #e6f7ff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .ptm-banner .icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + .ptm-banner .icon .iconify { + width: 16px; + height: 16px; + } + + .ptm-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } +} diff --git a/apps/web-antd/src/views/product/schedule/styles/responsive.less b/apps/web-antd/src/views/product/schedule/styles/responsive.less new file mode 100644 index 0000000..994a4cb --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/responsive.less @@ -0,0 +1,30 @@ +.page-product-schedule { + @media (width <= 1200px) { + .ptm-spacer { + display: none; + } + + .ptm-tl-axis { + margin-left: 72px !important; + } + + .ptm-tl-label { + width: 62px; + } + } + + @media (width <= 768px) { + .ptm-time-row { + flex-direction: column; + align-items: flex-start; + } + + .ptm-time-text { + font-size: 20px; + } + + .ptm-card-ft { + flex-wrap: wrap; + } + } +} diff --git a/apps/web-antd/src/views/product/schedule/styles/timeline.less b/apps/web-antd/src/views/product/schedule/styles/timeline.less new file mode 100644 index 0000000..bcbbba8 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/styles/timeline.less @@ -0,0 +1,85 @@ +.page-product-schedule { + .ptm-tl-card { + padding: 20px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .ptm-tl-title { + padding-left: 10px; + margin-bottom: 16px; + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + border-left: 3px solid #1677ff; + } + + .ptm-tl-axis { + position: relative; + height: 20px; + margin-bottom: 4px; + } + + .ptm-tl-axis span { + position: absolute; + font-size: 10px; + color: #9ca3af; + transform: translateX(-50%); + } + + .ptm-tl-rows { + display: flex; + flex-direction: column; + gap: 8px; + } + + .ptm-tl-row { + display: flex; + gap: 10px; + align-items: center; + } + + .ptm-tl-row.disabled { + opacity: 0.5; + } + + .ptm-tl-label { + flex-shrink: 0; + width: 80px; + font-size: 12px; + color: #4b5563; + text-align: right; + } + + .ptm-tl-track { + position: relative; + flex: 1; + height: 22px; + overflow: hidden; + background: #f8f9fb; + border-radius: 11px; + } + + .ptm-tl-bar { + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 10px; + font-weight: 500; + color: #fff; + white-space: nowrap; + border-radius: 11px; + } + + .ptm-tl-empty { + padding: 20px; + font-size: 13px; + color: #9ca3af; + text-align: center; + } +} diff --git a/apps/web-antd/src/views/product/schedule/types.ts b/apps/web-antd/src/views/product/schedule/types.ts new file mode 100644 index 0000000..6f57340 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/types.ts @@ -0,0 +1,47 @@ +/** + * 文件职责:时段供应页面共享类型定义。 + */ +import type { + ProductPickerItemDto, + ProductScheduleDto, + ProductSwitchStatus, +} from '#/api/product'; + +/** 时段规则编辑表单。 */ +export interface ScheduleEditorForm { + endTime: string; + id: string; + name: string; + productIds: string[]; + startTime: string; + status: ProductSwitchStatus; + weekDays: number[]; +} + +/** 时段规则卡片视图模型。 */ +export type ProductScheduleCardViewModel = ProductScheduleDto; + +/** 已选商品标签。 */ +export interface ScheduleProductChip { + id: string; + name: string; +} + +/** 时间轴条段。 */ +export interface ScheduleTimelineBar { + label: string; + left: number; + width: number; +} + +/** 时间轴行。 */ +export interface ScheduleTimelineRow { + bars: ScheduleTimelineBar[]; + color: string; + id: string; + name: string; + status: ProductSwitchStatus; +} + +/** 商品映射表。 */ +export type ScheduleProductLookup = Record;