From d81b6701482572b697fe5436a2886a67ec8d04b2 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 2 Mar 2026 11:10:17 +0800 Subject: [PATCH] feat(@vben/web-antd): implement marketing flash sale page --- apps/web-antd/src/api/marketing/flash-sale.ts | 266 +++++++++++ apps/web-antd/src/api/marketing/index.ts | 1 + .../components/FlashSaleActivityCard.vue | 135 ++++++ .../components/FlashSaleEditorDrawer.vue | 346 ++++++++++++++ .../FlashSaleProductPickerModal.vue | 173 +++++++ .../components/FlashSaleStatsCards.vue | 35 ++ .../flash-sale-page/card-actions.ts | 98 ++++ .../composables/flash-sale-page/constants.ts | 160 +++++++ .../flash-sale-page/data-actions.ts | 115 +++++ .../flash-sale-page/drawer-actions.ts | 421 ++++++++++++++++++ .../composables/flash-sale-page/helpers.ts | 258 +++++++++++ .../flash-sale-page/picker-actions.ts | 215 +++++++++ .../composables/useMarketingFlashSalePage.ts | 280 ++++++++++++ .../src/views/marketing/flash-sale/index.vue | 204 +++++++++ .../marketing/flash-sale/styles/base.less | 29 ++ .../marketing/flash-sale/styles/card.less | 163 +++++++ .../marketing/flash-sale/styles/drawer.less | 256 +++++++++++ .../marketing/flash-sale/styles/index.less | 6 + .../marketing/flash-sale/styles/layout.less | 91 ++++ .../marketing/flash-sale/styles/picker.less | 146 ++++++ .../flash-sale/styles/responsive.less | 37 ++ .../src/views/marketing/flash-sale/types.ts | 68 +++ 22 files changed, 3503 insertions(+) create mode 100644 apps/web-antd/src/api/marketing/flash-sale.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleActivityCard.vue create mode 100644 apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleEditorDrawer.vue create mode 100644 apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleProductPickerModal.vue create mode 100644 apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleStatsCards.vue create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/card-actions.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/constants.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/data-actions.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/helpers.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/picker-actions.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/composables/useMarketingFlashSalePage.ts create mode 100644 apps/web-antd/src/views/marketing/flash-sale/index.vue create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/base.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/card.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/drawer.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/index.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/layout.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/picker.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/styles/responsive.less create mode 100644 apps/web-antd/src/views/marketing/flash-sale/types.ts diff --git a/apps/web-antd/src/api/marketing/flash-sale.ts b/apps/web-antd/src/api/marketing/flash-sale.ts new file mode 100644 index 0000000..5698fea --- /dev/null +++ b/apps/web-antd/src/api/marketing/flash-sale.ts @@ -0,0 +1,266 @@ +/** + * 文件职责:营销中心限时折扣 API 与 DTO 定义。 + * 1. 维护限时折扣列表、详情、保存、状态切换、删除和选品契约。 + */ +import { requestClient } from '#/api/request'; + +/** 活动展示状态。 */ +export type MarketingFlashSaleDisplayStatus = 'ended' | 'ongoing' | 'upcoming'; + +/** 活动编辑状态。 */ +export type MarketingFlashSaleEditorStatus = 'active' | 'completed'; + +/** 活动周期。 */ +export type MarketingFlashSaleCycleType = 'once' | 'recurring'; + +/** 周期日期模式。 */ +export type MarketingFlashSaleRecurringDateMode = 'fixed' | 'long_term'; + +/** 适用渠道。 */ +export type MarketingFlashSaleChannel = 'delivery' | 'dine_in' | 'pickup'; + +/** 商品状态。 */ +export type MarketingFlashSaleProductStatus = + | 'off_shelf' + | 'on_sale' + | 'sold_out'; + +/** 限时折扣商品。 */ +export interface MarketingFlashSaleProductDto { + categoryId: string; + categoryName: string; + discountPrice: number; + name: string; + originalPrice: number; + perUserLimit: null | number; + productId: string; + soldCount: number; + spuCode: string; + status: MarketingFlashSaleProductStatus; +} + +/** 活动指标。 */ +export interface MarketingFlashSaleMetricsDto { + activitySalesCount: number; + discountTotalAmount: number; + loopedWeeks: number; + monthlyDiscountSalesCount: number; +} + +/** 列表查询参数。 */ +export interface MarketingFlashSaleListQuery { + keyword?: string; + page: number; + pageSize: number; + status?: '' | MarketingFlashSaleDisplayStatus; + storeId?: string; +} + +/** 详情查询参数。 */ +export interface MarketingFlashSaleDetailQuery { + activityId: string; + storeId: string; +} + +/** 保存请求。 */ +export interface SaveMarketingFlashSaleDto { + channels: MarketingFlashSaleChannel[]; + cycleType: MarketingFlashSaleCycleType; + endDate?: string; + id?: string; + metrics?: MarketingFlashSaleMetricsDto; + name: string; + perUserLimit: null | number; + products: Array<{ + discountPrice: number; + perUserLimit: null | number; + productId: string; + }>; + recurringDateMode: MarketingFlashSaleRecurringDateMode; + startDate?: string; + storeId: string; + storeIds: string[]; + timeEnd?: string; + timeStart?: string; + weekDays: number[]; +} + +/** 状态修改请求。 */ +export interface ChangeMarketingFlashSaleStatusDto { + activityId: string; + status: MarketingFlashSaleEditorStatus; + storeId: string; +} + +/** 删除请求。 */ +export interface DeleteMarketingFlashSaleDto { + activityId: string; + storeId: string; +} + +/** 列表统计。 */ +export interface MarketingFlashSaleStatsDto { + monthlyDiscountSalesCount: number; + ongoingCount: number; + participatingProductCount: number; + totalCount: number; +} + +/** 列表项。 */ +export interface MarketingFlashSaleListItemDto { + channels: MarketingFlashSaleChannel[]; + cycleType: MarketingFlashSaleCycleType; + displayStatus: MarketingFlashSaleDisplayStatus; + endDate?: string; + id: string; + isDimmed: boolean; + metrics: MarketingFlashSaleMetricsDto; + name: string; + perUserLimit: null | number; + products: MarketingFlashSaleProductDto[]; + recurringDateMode: MarketingFlashSaleRecurringDateMode; + startDate?: string; + status: MarketingFlashSaleEditorStatus; + storeIds: string[]; + timeEnd?: string; + timeStart?: string; + updatedAt: string; + weekDays: number[]; +} + +/** 列表结果。 */ +export interface MarketingFlashSaleListResultDto { + items: MarketingFlashSaleListItemDto[]; + page: number; + pageSize: number; + stats: MarketingFlashSaleStatsDto; + total: number; +} + +/** 详情数据。 */ +export interface MarketingFlashSaleDetailDto { + channels: MarketingFlashSaleChannel[]; + cycleType: MarketingFlashSaleCycleType; + displayStatus: MarketingFlashSaleDisplayStatus; + endDate?: string; + id: string; + metrics: MarketingFlashSaleMetricsDto; + name: string; + perUserLimit: null | number; + products: MarketingFlashSaleProductDto[]; + recurringDateMode: MarketingFlashSaleRecurringDateMode; + startDate?: string; + status: MarketingFlashSaleEditorStatus; + storeIds: string[]; + timeEnd?: string; + timeStart?: string; + updatedAt: string; + weekDays: number[]; +} + +/** 选品分类查询参数。 */ +export interface MarketingFlashSalePickerCategoryQuery { + storeId: string; +} + +/** 选品分类项。 */ +export interface MarketingFlashSalePickerCategoryItemDto { + id: string; + name: string; + productCount: number; +} + +/** 选品商品查询参数。 */ +export interface MarketingFlashSalePickerProductQuery { + categoryId?: string; + keyword?: string; + limit?: number; + storeId: string; +} + +/** 选品商品项。 */ +export interface MarketingFlashSalePickerProductItemDto { + categoryId: string; + categoryName: string; + id: string; + name: string; + price: number; + spuCode: string; + status: MarketingFlashSaleProductStatus; + stock: number; +} + +/** 获取列表。 */ +export async function getMarketingFlashSaleListApi( + params: MarketingFlashSaleListQuery, +) { + return requestClient.get( + '/marketing/flash-sale/list', + { + params, + }, + ); +} + +/** 获取详情。 */ +export async function getMarketingFlashSaleDetailApi( + params: MarketingFlashSaleDetailQuery, +) { + return requestClient.get( + '/marketing/flash-sale/detail', + { + params, + }, + ); +} + +/** 保存活动。 */ +export async function saveMarketingFlashSaleApi( + data: SaveMarketingFlashSaleDto, +) { + return requestClient.post( + '/marketing/flash-sale/save', + data, + ); +} + +/** 修改状态。 */ +export async function changeMarketingFlashSaleStatusApi( + data: ChangeMarketingFlashSaleStatusDto, +) { + return requestClient.post( + '/marketing/flash-sale/status', + data, + ); +} + +/** 删除活动。 */ +export async function deleteMarketingFlashSaleApi( + data: DeleteMarketingFlashSaleDto, +) { + return requestClient.post('/marketing/flash-sale/delete', data); +} + +/** 获取选品分类。 */ +export async function getMarketingFlashSalePickerCategoriesApi( + params: MarketingFlashSalePickerCategoryQuery, +) { + return requestClient.get( + '/marketing/flash-sale/picker/categories', + { + params, + }, + ); +} + +/** 获取选品商品。 */ +export async function getMarketingFlashSalePickerProductsApi( + params: MarketingFlashSalePickerProductQuery, +) { + return requestClient.get( + '/marketing/flash-sale/picker/products', + { + params, + }, + ); +} diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts index fd7f2fc..941828b 100644 --- a/apps/web-antd/src/api/marketing/index.ts +++ b/apps/web-antd/src/api/marketing/index.ts @@ -183,4 +183,5 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { return requestClient.post('/marketing/coupon/delete', data); } +export * from './flash-sale'; export * from './full-reduction'; diff --git a/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleActivityCard.vue b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleActivityCard.vue new file mode 100644 index 0000000..5c43937 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleActivityCard.vue @@ -0,0 +1,135 @@ + + + diff --git a/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleEditorDrawer.vue b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleEditorDrawer.vue new file mode 100644 index 0000000..350d238 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleEditorDrawer.vue @@ -0,0 +1,346 @@ + + + diff --git a/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleProductPickerModal.vue b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleProductPickerModal.vue new file mode 100644 index 0000000..2e894b7 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleProductPickerModal.vue @@ -0,0 +1,173 @@ + + + diff --git a/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleStatsCards.vue b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleStatsCards.vue new file mode 100644 index 0000000..711a11e --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleStatsCards.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/card-actions.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/card-actions.ts new file mode 100644 index 0000000..6e3378d --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/card-actions.ts @@ -0,0 +1,98 @@ +import type { FlashSaleCardViewModel } from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣卡片行操作。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeMarketingFlashSaleStatusApi, + deleteMarketingFlashSaleApi, +} from '#/api/marketing'; + +interface CreateCardActionsOptions { + loadActivities: () => Promise; + resolveOperationStoreId: (preferredStoreIds?: string[]) => string; +} + +export function createCardActions(options: CreateCardActionsOptions) { + function toggleActivityStatus(item: FlashSaleCardViewModel) { + const operationStoreId = options.resolveOperationStoreId(item.storeIds); + if (!operationStoreId) { + return; + } + + const nextStatus = item.status === 'completed' ? 'active' : 'completed'; + const isEnabling = nextStatus === 'active'; + const feedbackKey = `flash-sale-status-${item.id}`; + + Modal.confirm({ + title: isEnabling + ? `确认启用活动「${item.name}」吗?` + : `确认停用活动「${item.name}」吗?`, + content: isEnabling + ? '启用后活动将恢复为进行中状态。' + : '停用后活动将结束,可后续再次启用。', + okText: isEnabling ? '确认启用' : '确认停用', + cancelText: '取消', + async onOk() { + try { + message.loading({ + key: feedbackKey, + duration: 0, + content: isEnabling ? '正在启用活动...' : '正在停用活动...', + }); + await changeMarketingFlashSaleStatusApi({ + storeId: operationStoreId, + activityId: item.id, + status: nextStatus, + }); + message.success({ + key: feedbackKey, + content: isEnabling ? '活动已启用' : '活动已停用', + }); + await options.loadActivities(); + } catch (error) { + console.error(error); + message.error({ + key: feedbackKey, + content: isEnabling + ? '启用失败,请稍后重试' + : '停用失败,请稍后重试', + }); + } + }, + }); + } + + function removeActivity(item: FlashSaleCardViewModel) { + const operationStoreId = options.resolveOperationStoreId(item.storeIds); + if (!operationStoreId) { + return; + } + + Modal.confirm({ + title: `确认删除活动「${item.name}」吗?`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + try { + await deleteMarketingFlashSaleApi({ + storeId: operationStoreId, + activityId: item.id, + }); + message.success('活动已删除'); + await options.loadActivities(); + } catch (error) { + console.error(error); + message.error('删除失败,请稍后重试'); + } + }, + }); + } + + return { + removeActivity, + toggleActivityStatus, + }; +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/constants.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/constants.ts new file mode 100644 index 0000000..efcb1c8 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/constants.ts @@ -0,0 +1,160 @@ +import type { + MarketingFlashSaleChannel, + MarketingFlashSaleCycleType, + MarketingFlashSaleDisplayStatus, + MarketingFlashSaleProductStatus, + MarketingFlashSaleRecurringDateMode, +} from '#/api/marketing'; +import type { + FlashSaleEditorForm, + FlashSaleEditorProductForm, + FlashSaleFilterForm, +} from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣页面常量与默认表单。 + */ + +/** 状态筛选项。 */ +export const FLASH_SALE_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingFlashSaleDisplayStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '进行中', value: 'ongoing' }, + { label: '已结束', value: 'ended' }, + { label: '未开始', value: 'upcoming' }, +]; + +/** 活动周期选项。 */ +export const FLASH_SALE_CYCLE_OPTIONS: Array<{ + label: string; + value: MarketingFlashSaleCycleType; +}> = [ + { label: '一次性活动', value: 'once' }, + { label: '周期循环', value: 'recurring' }, +]; + +/** 周期日期模式选项。 */ +export const FLASH_SALE_RECURRING_DATE_MODE_OPTIONS: Array<{ + label: string; + value: MarketingFlashSaleRecurringDateMode; +}> = [ + { label: '长期有效', value: 'long_term' }, + { label: '指定日期区间', value: 'fixed' }, +]; + +/** 适用渠道选项。 */ +export const FLASH_SALE_CHANNEL_OPTIONS: Array<{ + label: string; + value: MarketingFlashSaleChannel; +}> = [ + { label: '外卖配送', value: 'delivery' }, + { label: '到店自取', value: 'pickup' }, + { label: '堂食点餐', value: 'dine_in' }, +]; + +/** 周循环星期选项。 */ +export const FLASH_SALE_WEEKDAY_OPTIONS: Array<{ + label: string; + shortLabel: string; + value: number; +}> = [ + { label: '周一', shortLabel: '一', value: 1 }, + { label: '周二', shortLabel: '二', value: 2 }, + { label: '周三', shortLabel: '三', value: 3 }, + { label: '周四', shortLabel: '四', value: 4 }, + { label: '周五', shortLabel: '五', value: 5 }, + { label: '周六', shortLabel: '六', value: 6 }, + { label: '周日', shortLabel: '日', value: 7 }, +]; + +/** 展示状态文案。 */ +export const FLASH_SALE_STATUS_TEXT_MAP: Record< + MarketingFlashSaleDisplayStatus, + string +> = { + ongoing: '进行中', + upcoming: '未开始', + ended: '已结束', +}; + +/** 展示状态类。 */ +export const FLASH_SALE_STATUS_CLASS_MAP: Record< + MarketingFlashSaleDisplayStatus, + string +> = { + ongoing: 'fs-tag-running', + upcoming: 'fs-tag-notstarted', + ended: 'fs-tag-ended', +}; + +/** 商品状态文案。 */ +export const FLASH_SALE_PRODUCT_STATUS_TEXT_MAP: Record< + MarketingFlashSaleProductStatus, + string +> = { + on_sale: '在售', + off_shelf: '下架', + sold_out: '沽清', +}; + +/** 创建默认筛选表单。 */ +export function createDefaultFlashSaleFilterForm(): FlashSaleFilterForm { + return { + status: '', + }; +} + +/** 创建空指标对象。 */ +export function createEmptyFlashSaleMetrics() { + return { + activitySalesCount: 0, + discountTotalAmount: 0, + loopedWeeks: 0, + monthlyDiscountSalesCount: 0, + }; +} + +/** 创建默认商品表单。 */ +export function createDefaultFlashSaleProductForm( + product?: Partial, +): FlashSaleEditorProductForm { + return { + productId: product?.productId ?? '', + categoryId: product?.categoryId ?? '', + categoryName: product?.categoryName ?? '', + name: product?.name ?? '', + spuCode: product?.spuCode ?? '', + status: product?.status ?? 'off_shelf', + originalPrice: Number(product?.originalPrice ?? 0), + discountPrice: + product?.discountPrice === null || product?.discountPrice === undefined + ? null + : Number(product.discountPrice), + perUserLimit: + product?.perUserLimit === null || product?.perUserLimit === undefined + ? null + : Number(product.perUserLimit), + soldCount: Number(product?.soldCount ?? 0), + }; +} + +/** 创建默认编辑表单。 */ +export function createDefaultFlashSaleEditorForm(): FlashSaleEditorForm { + return { + id: '', + name: '', + cycleType: 'once', + recurringDateMode: 'fixed', + validDateRange: null, + timeRange: null, + weekDays: [4], + channels: ['delivery', 'pickup'], + perUserLimit: null, + products: [], + metrics: createEmptyFlashSaleMetrics(), + status: 'active', + storeIds: [], + }; +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/data-actions.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/data-actions.ts new file mode 100644 index 0000000..733e59b --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/data-actions.ts @@ -0,0 +1,115 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +import type { + FlashSaleCardViewModel, + FlashSaleFilterForm, + FlashSaleStatsViewModel, +} from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣页面数据读取动作。 + */ +import { message } from 'ant-design-vue'; + +import { getMarketingFlashSaleListApi } from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + filterForm: FlashSaleFilterForm; + isLoading: Ref; + isStoreLoading: Ref; + keyword: Ref; + page: Ref; + pageSize: Ref; + rows: Ref; + selectedStoreId: Ref; + stats: Ref; + stores: Ref; + total: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + 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.total.value = 0; + options.stats.value = createEmptyStats(); + return; + } + + if (!options.selectedStoreId.value) { + return; + } + + const hasSelectedStore = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelectedStore) { + options.selectedStoreId.value = ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadActivities() { + if (options.stores.value.length === 0) { + options.rows.value = []; + options.total.value = 0; + options.stats.value = createEmptyStats(); + return; + } + + options.isLoading.value = true; + try { + const result = await getMarketingFlashSaleListApi({ + storeId: options.selectedStoreId.value || undefined, + status: options.filterForm.status, + keyword: options.keyword.value.trim() || undefined, + page: options.page.value, + pageSize: options.pageSize.value, + }); + + options.rows.value = result.items ?? []; + options.total.value = result.total; + options.page.value = result.page; + options.pageSize.value = result.pageSize; + options.stats.value = result.stats; + } catch (error) { + console.error(error); + options.rows.value = []; + options.total.value = 0; + options.stats.value = createEmptyStats(); + message.error('加载限时折扣活动失败'); + } finally { + options.isLoading.value = false; + } + } + + return { + loadActivities, + loadStores, + }; +} + +export function createEmptyStats(): FlashSaleStatsViewModel { + return { + totalCount: 0, + ongoingCount: 0, + participatingProductCount: 0, + monthlyDiscountSalesCount: 0, + }; +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/drawer-actions.ts new file mode 100644 index 0000000..b13b79c --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/drawer-actions.ts @@ -0,0 +1,421 @@ +import type { Ref } from 'vue'; + +import type { + MarketingFlashSaleChannel, + MarketingFlashSaleCycleType, + MarketingFlashSaleRecurringDateMode, +} from '#/api/marketing'; +import type { StoreListItemDto } from '#/api/store'; +import type { + FlashSaleEditorForm, + FlashSaleEditorProductForm, +} from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣主编辑抽屉动作。 + */ +import { ref } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { + getMarketingFlashSaleDetailApi, + saveMarketingFlashSaleApi, +} from '#/api/marketing'; + +import { createDefaultFlashSaleEditorForm } from './constants'; +import { + buildSaveFlashSalePayload, + cloneProductForm, + mapDetailToEditorForm, + resolveDiscountRateLabel, +} from './helpers'; + +interface CreateDrawerActionsOptions { + form: FlashSaleEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadActivities: () => Promise; + openPicker: ( + pickerOptions: { + selectedProductIds: string[]; + selectedProducts: FlashSaleEditorProductForm[]; + storeId: string; + }, + onConfirm: (products: FlashSaleEditorProductForm[]) => void, + ) => Promise; + resolveOperationStoreId: (preferredStoreIds?: string[]) => string; + selectedStoreId: Ref; + stores: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + const drawerMode = ref<'create' | 'edit'>('create'); + + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function applyForm(next: FlashSaleEditorForm) { + options.form.id = next.id; + options.form.name = next.name; + options.form.cycleType = next.cycleType; + options.form.recurringDateMode = next.recurringDateMode; + options.form.validDateRange = next.validDateRange; + options.form.timeRange = next.timeRange; + options.form.weekDays = [...next.weekDays]; + options.form.channels = [...next.channels]; + options.form.perUserLimit = next.perUserLimit; + options.form.products = next.products.map((item) => cloneProductForm(item)); + options.form.metrics = { ...next.metrics }; + options.form.status = next.status; + options.form.storeIds = [...next.storeIds]; + } + + function resetForm() { + applyForm(createDefaultFlashSaleEditorForm()); + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormCycleType(value: MarketingFlashSaleCycleType) { + options.form.cycleType = value; + if (value === 'once') { + options.form.recurringDateMode = 'fixed'; + options.form.weekDays = []; + } else if (options.form.weekDays.length === 0) { + options.form.weekDays = [4]; + } + } + + function setFormRecurringDateMode( + value: MarketingFlashSaleRecurringDateMode, + ) { + options.form.recurringDateMode = value; + if (value === 'long_term' && options.form.validDateRange) { + // 长期模式允许保留日期范围;仅在用户清空时不强制校验。 + } + } + + function setFormValidDateRange(value: FlashSaleEditorForm['validDateRange']) { + options.form.validDateRange = value; + } + + function setFormTimeRange(value: FlashSaleEditorForm['timeRange']) { + options.form.timeRange = value; + } + + function toggleWeekDay(day: number) { + if (options.form.weekDays.includes(day)) { + options.form.weekDays = options.form.weekDays.filter( + (item) => item !== day, + ); + return; + } + options.form.weekDays = [...options.form.weekDays, day].toSorted( + (first, second) => first - second, + ); + } + + function quickSelectWeekDays(mode: 'all' | 'weekday' | 'weekend') { + if (mode === 'all') { + options.form.weekDays = [1, 2, 3, 4, 5, 6, 7]; + return; + } + if (mode === 'weekday') { + options.form.weekDays = [1, 2, 3, 4, 5]; + return; + } + options.form.weekDays = [6, 7]; + } + + function setFormChannels(value: MarketingFlashSaleChannel[]) { + options.form.channels = [...new Set(value)]; + } + + function toggleChannel(channel: MarketingFlashSaleChannel) { + if (options.form.channels.includes(channel)) { + setFormChannels(options.form.channels.filter((item) => item !== channel)); + return; + } + setFormChannels([...options.form.channels, channel]); + } + + function setFormPerUserLimit(value: null | number) { + options.form.perUserLimit = normalizeNullableInteger(value); + } + + function setProductDiscountPrice(index: number, value: null | number) { + const row = options.form.products[index]; + if (!row) { + return; + } + row.discountPrice = normalizeNullableNumber(value); + options.form.products = [...options.form.products]; + } + + function setProductPerUserLimit(index: number, value: null | number) { + const row = options.form.products[index]; + if (!row) { + return; + } + row.perUserLimit = normalizeNullableInteger(value); + options.form.products = [...options.form.products]; + } + + function removeProduct(index: number) { + options.form.products = options.form.products.filter( + (_, rowIndex) => rowIndex !== index, + ); + } + + async function openProductPicker() { + const operationStoreId = resolveScopeStoreId(); + if (!operationStoreId) { + message.warning('请选择具体门店后再添加商品'); + return; + } + + await options.openPicker( + { + storeId: operationStoreId, + selectedProducts: options.form.products.map((item) => + cloneProductForm(item), + ), + selectedProductIds: options.form.products.map((item) => item.productId), + }, + (products) => { + const currentById = new Map( + options.form.products.map((item) => [item.productId, item]), + ); + const merged = products.map((item) => { + const existing = currentById.get(item.productId); + return existing ? cloneProductForm(existing) : cloneProductForm(item); + }); + options.form.products = merged; + }, + ); + } + + async function openCreateDrawer() { + if (!options.selectedStoreId.value) { + message.warning('请选择具体门店后再创建活动'); + return; + } + + resetForm(); + options.form.storeIds = [options.selectedStoreId.value]; + drawerMode.value = 'create'; + options.isDrawerOpen.value = true; + } + + async function openEditDrawer( + activityId: string, + preferredStoreIds: string[] = [], + ) { + const operationStoreId = options.resolveOperationStoreId(preferredStoreIds); + if (!operationStoreId) { + return; + } + + options.isDrawerLoading.value = true; + try { + const detail = await getMarketingFlashSaleDetailApi({ + storeId: operationStoreId, + activityId, + }); + const mapped = mapDetailToEditorForm(detail); + if (mapped.storeIds.length === 0) { + mapped.storeIds = [operationStoreId]; + } + applyForm(mapped); + drawerMode.value = 'edit'; + options.isDrawerOpen.value = true; + } catch (error) { + console.error(error); + message.error('加载活动详情失败'); + } finally { + options.isDrawerLoading.value = false; + } + } + + async function submitDrawer() { + const operationStoreId = options.resolveOperationStoreId( + options.form.storeIds, + ); + if (!operationStoreId) { + message.warning('请选择可操作门店'); + return; + } + + if (!validateBeforeSubmit(operationStoreId)) { + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveMarketingFlashSaleApi( + buildSaveFlashSalePayload(options.form, operationStoreId), + ); + message.success( + drawerMode.value === 'create' ? '活动已创建' : '活动已更新', + ); + options.isDrawerOpen.value = false; + await options.loadActivities(); + } catch (error) { + console.error(error); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + function validateBeforeSubmit(operationStoreId: string) { + const normalizedName = options.form.name.trim(); + if (!normalizedName) { + message.warning('请输入活动名称'); + return false; + } + if (normalizedName.length > 64) { + message.warning('活动名称长度不能超过 64 个字符'); + return false; + } + + if (options.form.storeIds.length === 0) { + message.warning('活动门店不能为空'); + return false; + } + if (!options.form.storeIds.includes(operationStoreId)) { + message.warning('活动门店必须包含当前操作门店'); + return false; + } + + if (options.form.cycleType === 'once' && !options.form.validDateRange) { + message.warning('请选择活动时间'); + return false; + } + + if (options.form.cycleType === 'recurring') { + if (options.form.weekDays.length === 0) { + message.warning('请选择循环日期'); + return false; + } + if ( + options.form.recurringDateMode === 'fixed' && + !options.form.validDateRange + ) { + message.warning('请选择周期活动日期区间'); + return false; + } + } + + if (options.form.channels.length === 0) { + message.warning('请至少选择一个适用渠道'); + return false; + } + + if (options.form.perUserLimit !== null && options.form.perUserLimit <= 0) { + message.warning('每人限购必须大于 0'); + return false; + } + + if (options.form.products.length === 0) { + message.warning('请至少添加一个折扣商品'); + return false; + } + + for (const row of options.form.products) { + if (!row.productId) { + message.warning('商品数据异常,请重新选择商品'); + return false; + } + if (!row.discountPrice || row.discountPrice <= 0) { + message.warning(`商品「${row.name}」折扣价必须大于 0`); + return false; + } + if (row.discountPrice > row.originalPrice) { + message.warning(`商品「${row.name}」折扣价不能高于原价`); + return false; + } + if (row.perUserLimit !== null && row.perUserLimit <= 0) { + message.warning(`商品「${row.name}」限购必须大于 0`); + return false; + } + if ( + options.form.perUserLimit && + row.perUserLimit && + row.perUserLimit > options.form.perUserLimit + ) { + message.warning(`商品「${row.name}」限购不能大于活动每人限购`); + return false; + } + + if ( + resolveDiscountRateLabel(row.originalPrice, row.discountPrice) === '-' + ) { + message.warning(`商品「${row.name}」折扣价配置不合法`); + return false; + } + } + + return true; + } + + function resolveScopeStoreId() { + if (options.selectedStoreId.value) { + return options.selectedStoreId.value; + } + + if (options.form.storeIds.length > 0) { + return options.form.storeIds[0] ?? ''; + } + + return options.stores.value[0]?.id ?? ''; + } + + return { + drawerMode, + openCreateDrawer, + openEditDrawer, + openProductPicker, + quickSelectWeekDays, + removeProduct, + setDrawerOpen, + setFormChannels, + setFormCycleType, + setFormName, + setFormPerUserLimit, + setFormRecurringDateMode, + setFormTimeRange, + setFormValidDateRange, + setProductDiscountPrice, + setProductPerUserLimit, + submitDrawer, + toggleChannel, + toggleWeekDay, + }; +} + +function normalizeNullableNumber(value: null | number) { + if (value === null || value === undefined) { + return null; + } + const numeric = Number(value); + if (Number.isNaN(numeric)) { + return null; + } + return Number(numeric.toFixed(2)); +} + +function normalizeNullableInteger(value: null | number) { + if (value === null || value === undefined) { + return null; + } + const numeric = Number(value); + if (Number.isNaN(numeric)) { + return null; + } + return Math.max(0, Math.floor(numeric)); +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/helpers.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/helpers.ts new file mode 100644 index 0000000..7d8e201 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/helpers.ts @@ -0,0 +1,258 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingFlashSaleDetailDto, + MarketingFlashSaleEditorStatus, + SaveMarketingFlashSaleDto, +} from '#/api/marketing'; +import type { + FlashSaleCardViewModel, + FlashSaleEditorForm, + FlashSaleEditorProductForm, +} from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣页面纯函数工具。 + */ +import dayjs from 'dayjs'; + +import { + createDefaultFlashSaleEditorForm, + createDefaultFlashSaleProductForm, + FLASH_SALE_WEEKDAY_OPTIONS, +} from './constants'; + +/** 格式化整数。 */ +export function formatInteger(value: number) { + return Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 0, + }).format(value || 0); +} + +/** 格式化金额。 */ +export function formatCurrency(value: number, withSymbol = true) { + const result = trimDecimal(Number(value || 0)); + return withSymbol ? `¥${result}` : result; +} + +/** 去除末尾 0。 */ +export function trimDecimal(value: number) { + return Number(value || 0) + .toFixed(2) + .replace(/\.?0+$/, ''); +} + +/** 计算折扣文案。 */ +export function resolveDiscountRateLabel( + originalPrice: number, + discountPrice: null | number, +) { + if (!discountPrice || discountPrice <= 0 || originalPrice <= 0) { + return '-'; + } + if (discountPrice > originalPrice) { + return '-'; + } + const rate = Number(((discountPrice / originalPrice) * 10).toFixed(1)); + return `${trimDecimal(rate)}折`; +} + +/** 商品限购文案。 */ +export function resolveProductLimitText(limit: null | number) { + if (!limit || limit <= 0) { + return '不限'; + } + return `${formatInteger(limit)}件/人`; +} + +/** 循环星期文案。 */ +export function resolveWeekDaysText(weekDays: number[]) { + if (weekDays.length === 0) { + return ''; + } + + const sorted = [...new Set(weekDays)] + .filter((item) => item >= 1 && item <= 7) + .toSorted((first, second) => first - second); + + if (sorted.length === 7) { + return '每天'; + } + + const weekday = [1, 2, 3, 4, 5]; + const weekend = [6, 7]; + if (weekday.every((item) => sorted.includes(item)) && sorted.length === 5) { + return '工作日'; + } + if (weekend.every((item) => sorted.includes(item)) && sorted.length === 2) { + return '周末'; + } + + const labels = sorted + .map( + (item) => + FLASH_SALE_WEEKDAY_OPTIONS.find((option) => option.value === item) + ?.label ?? '', + ) + .filter(Boolean); + + return labels.join('、'); +} + +/** 卡片周期角标文案。 */ +export function resolveRecurringBadgeText(item: FlashSaleCardViewModel) { + if (item.cycleType !== 'recurring') { + return ''; + } + const weekText = resolveWeekDaysText(item.weekDays); + return weekText ? `每${weekText}` : '周期循环'; +} + +/** 卡片日期范围文案。 */ +export function resolveDateRangeText(item: FlashSaleCardViewModel) { + if ( + item.cycleType === 'recurring' && + item.recurringDateMode === 'long_term' && + (!item.startDate || !item.endDate) + ) { + return '长期有效'; + } + + if (item.startDate && item.endDate) { + return `${item.startDate} ~ ${item.endDate}`; + } + return '长期有效'; +} + +/** 卡片时段文案。 */ +export function resolveTimeRangeText(item: FlashSaleCardViewModel) { + if (item.timeStart && item.timeEnd) { + return `${item.timeStart} - ${item.timeEnd}`; + } + return '全天有效'; +} + +/** 卡片汇总指标。 */ +export function resolveCardSummary(item: FlashSaleCardViewModel) { + const summary = [ + { + label: '活动销量', + value: `${formatInteger(item.metrics.activitySalesCount)}单`, + }, + { + label: '折扣总额', + value: formatCurrency(item.metrics.discountTotalAmount), + }, + ]; + + if (item.cycleType === 'recurring') { + summary.push({ + label: '已循环', + value: `${formatInteger(item.metrics.loopedWeeks)}周`, + }); + } + + return summary; +} + +/** 详情映射编辑表单。 */ +export function mapDetailToEditorForm( + detail: MarketingFlashSaleDetailDto, +): FlashSaleEditorForm { + const form = createDefaultFlashSaleEditorForm(); + form.id = detail.id; + form.name = detail.name; + form.cycleType = detail.cycleType; + form.recurringDateMode = detail.recurringDateMode; + form.validDateRange = + detail.startDate && detail.endDate + ? [dayjs(detail.startDate), dayjs(detail.endDate)] + : null; + form.timeRange = + detail.timeStart && detail.timeEnd + ? [ + dayjs(`2000-01-01 ${detail.timeStart}`), + dayjs(`2000-01-01 ${detail.timeEnd}`), + ] + : null; + form.weekDays = [...detail.weekDays]; + form.channels = [...detail.channels]; + form.perUserLimit = detail.perUserLimit; + form.products = detail.products.map((item) => + createDefaultFlashSaleProductForm({ + productId: item.productId, + categoryId: item.categoryId, + categoryName: item.categoryName, + name: item.name, + spuCode: item.spuCode, + status: item.status, + originalPrice: item.originalPrice, + discountPrice: item.discountPrice, + perUserLimit: item.perUserLimit, + soldCount: item.soldCount, + }), + ); + form.metrics = { ...detail.metrics }; + form.status = detail.status; + form.storeIds = [...detail.storeIds]; + return form; +} + +/** 构建保存请求。 */ +export function buildSaveFlashSalePayload( + form: FlashSaleEditorForm, + storeId: string, +): SaveMarketingFlashSaleDto { + const [startDate, endDate] = (form.validDateRange ?? []) as [Dayjs, Dayjs]; + const [timeStart, timeEnd] = (form.timeRange ?? []) as [Dayjs, Dayjs]; + + return { + id: form.id || undefined, + storeId, + name: form.name.trim(), + cycleType: form.cycleType, + recurringDateMode: form.recurringDateMode, + startDate: + form.validDateRange && startDate + ? startDate.format('YYYY-MM-DD') + : undefined, + endDate: + form.validDateRange && endDate ? endDate.format('YYYY-MM-DD') : undefined, + timeStart: + form.timeRange && timeStart ? timeStart.format('HH:mm') : undefined, + timeEnd: form.timeRange && timeEnd ? timeEnd.format('HH:mm') : undefined, + weekDays: form.cycleType === 'recurring' ? [...form.weekDays] : [], + channels: [...form.channels], + perUserLimit: form.perUserLimit, + storeIds: [...form.storeIds], + products: form.products.map((item) => ({ + productId: item.productId, + discountPrice: Number(item.discountPrice || 0), + perUserLimit: item.perUserLimit, + })), + metrics: { ...form.metrics }, + }; +} + +/** 深拷贝商品表单项。 */ +export function cloneProductForm( + product: FlashSaleEditorProductForm, +): FlashSaleEditorProductForm { + return { + productId: product.productId, + categoryId: product.categoryId, + categoryName: product.categoryName, + name: product.name, + spuCode: product.spuCode, + status: product.status, + originalPrice: product.originalPrice, + discountPrice: product.discountPrice, + perUserLimit: product.perUserLimit, + soldCount: product.soldCount, + }; +} + +/** 是否已结束。 */ +export function isEndedStatus(status: MarketingFlashSaleEditorStatus) { + return status === 'completed'; +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/picker-actions.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/picker-actions.ts new file mode 100644 index 0000000..137f2aa --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/flash-sale-page/picker-actions.ts @@ -0,0 +1,215 @@ +import type { Ref } from 'vue'; + +import type { + FlashSaleEditorProductForm, + FlashSalePickerCategoryItem, + FlashSalePickerProductItem, +} from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣商品选择弹窗动作。 + */ +import { message } from 'ant-design-vue'; + +import { + getMarketingFlashSalePickerCategoriesApi, + getMarketingFlashSalePickerProductsApi, +} from '#/api/marketing'; + +import { createDefaultFlashSaleProductForm } from './constants'; + +interface OpenPickerOptions { + selectedProducts: FlashSaleEditorProductForm[]; + selectedProductIds: string[]; + storeId: string; +} + +interface CreatePickerActionsOptions { + isPickerLoading: Ref; + isPickerOpen: Ref; + pickerCategories: Ref; + pickerCategoryFilterId: Ref; + pickerKeyword: Ref; + pickerProducts: Ref; + pickerSelectedProductIds: Ref; +} + +export function createPickerActions(options: CreatePickerActionsOptions) { + let activeStoreId = ''; + let selectedProductSnapshot = new Map(); + let onConfirmProducts: + | ((products: FlashSaleEditorProductForm[]) => void) + | null = null; + + function setPickerOpen(value: boolean) { + options.isPickerOpen.value = value; + if (!value) { + onConfirmProducts = null; + } + } + + function setPickerKeyword(value: string) { + options.pickerKeyword.value = value; + } + + function setPickerCategoryFilterId(value: string) { + options.pickerCategoryFilterId.value = value; + } + + function setPickerSelectedProductIds(value: string[]) { + options.pickerSelectedProductIds.value = [...new Set(value)]; + } + + function togglePickerProduct(productId: string) { + if (options.pickerSelectedProductIds.value.includes(productId)) { + options.pickerSelectedProductIds.value = + options.pickerSelectedProductIds.value.filter( + (item) => item !== productId, + ); + return; + } + options.pickerSelectedProductIds.value = [ + ...options.pickerSelectedProductIds.value, + productId, + ]; + } + + function toggleAllProducts(checked: boolean) { + if (!checked) { + const visibleIds = new Set( + options.pickerProducts.value.map((item) => item.id), + ); + options.pickerSelectedProductIds.value = + options.pickerSelectedProductIds.value.filter( + (item) => !visibleIds.has(item), + ); + return; + } + + const merged = new Set(options.pickerSelectedProductIds.value); + for (const item of options.pickerProducts.value) { + merged.add(item.id); + } + options.pickerSelectedProductIds.value = [...merged]; + } + + async function loadPickerCategories() { + if (!activeStoreId) { + options.pickerCategories.value = []; + return; + } + options.pickerCategories.value = + await getMarketingFlashSalePickerCategoriesApi({ + storeId: activeStoreId, + }); + } + + async function loadPickerProducts() { + if (!activeStoreId) { + options.pickerProducts.value = []; + return; + } + options.pickerProducts.value = await getMarketingFlashSalePickerProductsApi( + { + storeId: activeStoreId, + categoryId: options.pickerCategoryFilterId.value || undefined, + keyword: options.pickerKeyword.value.trim() || undefined, + limit: 500, + }, + ); + } + + async function reloadPickerList() { + if (!options.isPickerOpen.value) { + return; + } + + options.isPickerLoading.value = true; + try { + await Promise.all([loadPickerCategories(), loadPickerProducts()]); + } catch (error) { + console.error(error); + options.pickerCategories.value = []; + options.pickerProducts.value = []; + message.error('加载可选商品失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function openPicker( + pickerOptions: OpenPickerOptions, + onConfirm: (products: FlashSaleEditorProductForm[]) => void, + ) { + if (!pickerOptions.storeId) { + message.warning('请先选择具体门店后再添加商品'); + return; + } + + activeStoreId = pickerOptions.storeId; + onConfirmProducts = onConfirm; + selectedProductSnapshot = new Map( + pickerOptions.selectedProducts.map((item) => [ + item.productId, + createDefaultFlashSaleProductForm(item), + ]), + ); + options.pickerKeyword.value = ''; + options.pickerCategoryFilterId.value = ''; + options.pickerSelectedProductIds.value = [ + ...pickerOptions.selectedProductIds, + ]; + options.isPickerOpen.value = true; + + await reloadPickerList(); + } + + function submitPicker() { + const selectedIds = new Set(options.pickerSelectedProductIds.value); + const currentProductMap = new Map( + options.pickerProducts.value.map((item) => [ + item.id, + createDefaultFlashSaleProductForm({ + productId: item.id, + categoryId: item.categoryId, + categoryName: item.categoryName, + name: item.name, + spuCode: item.spuCode, + status: item.status, + originalPrice: item.price, + discountPrice: item.price, + perUserLimit: null, + soldCount: 0, + }), + ]), + ); + + const selectedProducts = [...selectedIds] + .map( + (productId) => + currentProductMap.get(productId) ?? + selectedProductSnapshot.get(productId), + ) + .filter(Boolean) as FlashSaleEditorProductForm[]; + + if (selectedProducts.length === 0) { + message.warning('请至少选择一个商品'); + return; + } + + onConfirmProducts?.(selectedProducts); + setPickerOpen(false); + } + + return { + openPicker, + reloadPickerList, + setPickerCategoryFilterId, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + submitPicker, + toggleAllProducts, + togglePickerProduct, + }; +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/composables/useMarketingFlashSalePage.ts b/apps/web-antd/src/views/marketing/flash-sale/composables/useMarketingFlashSalePage.ts new file mode 100644 index 0000000..4fc158b --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/composables/useMarketingFlashSalePage.ts @@ -0,0 +1,280 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { + FlashSaleCardViewModel, + FlashSalePickerCategoryItem, + FlashSalePickerProductItem, + FlashSaleStatsViewModel, +} from '#/views/marketing/flash-sale/types'; + +/** + * 文件职责:限时折扣页面状态与行为编排。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { createCardActions } from './flash-sale-page/card-actions'; +import { + createDefaultFlashSaleEditorForm, + createDefaultFlashSaleFilterForm, + FLASH_SALE_STATUS_FILTER_OPTIONS, +} from './flash-sale-page/constants'; +import { + createDataActions, + createEmptyStats, +} from './flash-sale-page/data-actions'; +import { createDrawerActions } from './flash-sale-page/drawer-actions'; +import { createPickerActions } from './flash-sale-page/picker-actions'; + +export function useMarketingFlashSalePage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const filterForm = reactive(createDefaultFlashSaleFilterForm()); + const keyword = ref(''); + + const rows = ref([]); + const stats = ref(createEmptyStats()); + const page = ref(1); + const pageSize = ref(4); + const total = ref(0); + const isLoading = ref(false); + + const isDrawerOpen = ref(false); + const isDrawerLoading = ref(false); + const isDrawerSubmitting = ref(false); + const form = reactive(createDefaultFlashSaleEditorForm()); + + const isPickerOpen = ref(false); + const isPickerLoading = ref(false); + const pickerKeyword = ref(''); + const pickerCategoryFilterId = ref(''); + const pickerSelectedProductIds = ref([]); + const pickerCategories = ref([]); + const pickerProducts = ref([]); + + const storeOptions = computed(() => [ + { label: '全部门店', value: '' }, + ...stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ]); + + const storeNameMap = computed>(() => + Object.fromEntries(stores.value.map((item) => [item.id, item.name])), + ); + + const hasStore = computed(() => stores.value.length > 0); + + function resolveOperationStoreId(preferredStoreIds: string[] = []) { + if (selectedStoreId.value) { + return selectedStoreId.value; + } + + for (const storeId of preferredStoreIds) { + if (stores.value.some((item) => item.id === storeId)) { + return storeId; + } + } + + if (preferredStoreIds.length > 0) { + return preferredStoreIds[0] ?? ''; + } + + return stores.value[0]?.id ?? ''; + } + + const { loadActivities, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + filterForm, + keyword, + rows, + stats, + isLoading, + page, + pageSize, + total, + }); + + const { + openPicker, + reloadPickerList, + setPickerCategoryFilterId, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + submitPicker, + toggleAllProducts, + togglePickerProduct, + } = createPickerActions({ + isPickerLoading, + isPickerOpen, + pickerCategories, + pickerCategoryFilterId, + pickerKeyword, + pickerProducts, + pickerSelectedProductIds, + }); + + const { + drawerMode, + openCreateDrawer, + openEditDrawer, + openProductPicker, + quickSelectWeekDays, + removeProduct, + setDrawerOpen, + setFormChannels, + setFormCycleType, + setFormName, + setFormPerUserLimit, + setFormRecurringDateMode, + setFormTimeRange, + setFormValidDateRange, + setProductDiscountPrice, + setProductPerUserLimit, + submitDrawer, + toggleChannel, + toggleWeekDay, + } = createDrawerActions({ + form, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + loadActivities, + openPicker, + resolveOperationStoreId, + selectedStoreId, + stores, + }); + + const { removeActivity, toggleActivityStatus } = createCardActions({ + loadActivities, + resolveOperationStoreId, + }); + + const drawerTitle = computed(() => + drawerMode.value === 'create' ? '创建限时折扣' : '编辑限时折扣', + ); + const drawerSubmitText = computed(() => '保存'); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setKeywordValue(value: string) { + keyword.value = value; + } + + function setStatusFilter( + value: '' | FlashSaleCardViewModel['displayStatus'], + ) { + filterForm.status = value; + } + + async function applyFilters() { + page.value = 1; + await loadActivities(); + } + + async function resetFilters() { + filterForm.status = ''; + keyword.value = ''; + page.value = 1; + await loadActivities(); + } + + async function handlePageChange(nextPage: number, nextPageSize: number) { + page.value = nextPage; + pageSize.value = nextPageSize; + await loadActivities(); + } + + async function handlePickerSearch() { + await reloadPickerList(); + } + + async function handlePickerCategoryFilterChange(value: string) { + setPickerCategoryFilterId(value); + await reloadPickerList(); + } + + watch(selectedStoreId, () => { + page.value = 1; + filterForm.status = ''; + keyword.value = ''; + void loadActivities(); + }); + + onMounted(async () => { + await loadStores(); + await loadActivities(); + }); + + return { + applyFilters, + drawerSubmitText, + drawerTitle, + filterForm, + form, + FLASH_SALE_STATUS_FILTER_OPTIONS, + handlePageChange, + handlePickerCategoryFilterChange, + handlePickerSearch, + hasStore, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + isLoading, + isPickerLoading, + isPickerOpen, + isStoreLoading, + keyword, + openCreateDrawer, + openEditDrawer, + openProductPicker, + page, + pageSize, + pickerCategories, + pickerCategoryFilterId, + pickerKeyword, + pickerProducts, + pickerSelectedProductIds, + quickSelectWeekDays, + reloadPickerList, + removeActivity, + removeProduct, + resetFilters, + rows, + selectedStoreId, + setDrawerOpen, + setFormChannels, + setFormCycleType, + setFormName, + setFormPerUserLimit, + setFormRecurringDateMode, + setFormTimeRange, + setFormValidDateRange, + setKeyword: setKeywordValue, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + setProductDiscountPrice, + setProductPerUserLimit, + setSelectedStoreId, + setStatusFilter, + stats, + storeNameMap, + storeOptions, + submitDrawer, + submitPicker, + toggleActivityStatus, + toggleAllProducts, + toggleChannel, + togglePickerProduct, + toggleWeekDay, + total, + }; +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/index.vue b/apps/web-antd/src/views/marketing/flash-sale/index.vue new file mode 100644 index 0000000..42082c2 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/index.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/base.less b/apps/web-antd/src/views/marketing/flash-sale/styles/base.less new file mode 100644 index 0000000..96fb404 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/base.less @@ -0,0 +1,29 @@ +/** + * 文件职责:限时折扣页面基础样式变量。 + */ +.page-marketing-flash-sale { + --fs-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --fs-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --fs-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%); + --fs-border: #e7eaf0; + --fs-text: #1f2937; + --fs-subtext: #6b7280; + --fs-muted: #9ca3af; + + .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; + } +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/card.less b/apps/web-antd/src/views/marketing/flash-sale/styles/card.less new file mode 100644 index 0000000..b5d49f6 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/card.less @@ -0,0 +1,163 @@ +/** + * 文件职责:限时折扣活动卡片样式。 + */ +.page-marketing-flash-sale { + .fs-card { + padding: 20px; + margin-bottom: 2px; + background: #fff; + border: 1px solid var(--fs-border); + border-radius: 10px; + box-shadow: var(--fs-shadow-sm); + transition: box-shadow var(--fs-transition); + } + + .fs-card:hover { + box-shadow: var(--fs-shadow-md); + } + + .fs-card.ended { + opacity: 0.56; + } + + .fs-card-hd { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 14px; + } + + .fs-card-name { + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + } + + .fs-card-time { + display: inline-flex; + gap: 4px; + align-items: center; + font-size: 12px; + color: var(--fs-muted); + } + + .fs-card-time .iconify, + .fs-recur-badge .iconify { + width: 12px; + height: 12px; + } + + .fs-tag-running { + font-weight: 600; + color: #22c55e; + background: #dcfce7; + border: 1px solid #bbf7d0; + border-radius: 6px; + } + + .fs-tag-ended { + font-weight: 600; + color: #9ca3af; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 6px; + } + + .fs-tag-notstarted { + font-weight: 600; + color: #1677ff; + background: #f0f5ff; + border: 1px solid #adc6ff; + border-radius: 6px; + } + + .fs-recur-badge { + display: inline-flex; + gap: 3px; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: #1677ff; + background: #f0f5ff; + border: 1px solid #adc6ff; + border-radius: 4px; + } + + .fs-prod-table { + width: 100%; + margin-bottom: 14px; + font-size: 13px; + border-collapse: collapse; + } + + .fs-prod-table th { + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-align: left; + background: #f8f9fb; + border-bottom: 1px solid #e5e7eb; + } + + .fs-prod-table td { + padding: 8px 12px; + color: #1a1a2e; + border-bottom: 1px solid #f3f4f6; + } + + .fs-prod-table tr:last-child td { + border-bottom: none; + } + + .fs-prod-table tr:hover td { + background: color-mix(in srgb, #1677ff 3%, #fff); + } + + .fs-orig-price { + font-size: 12px; + color: var(--fs-muted); + text-decoration: line-through; + } + + .fs-disc-price { + font-weight: 600; + color: #ef4444; + } + + .fs-disc-rate { + display: inline-block; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + color: #ef4444; + background: #fff1f0; + border-radius: 4px; + } + + .fs-card-summary { + display: flex; + flex-wrap: wrap; + gap: 24px; + padding: 10px 12px; + margin-bottom: 12px; + font-size: 12px; + color: #4b5563; + background: #f8f9fb; + border-radius: 8px; + } + + .fs-card-summary strong { + font-weight: 600; + color: #1a1a2e; + } + + .fs-card-ft { + display: flex; + gap: 16px; + padding-top: 12px; + border-top: 1px solid #f3f4f6; + } +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/drawer.less b/apps/web-antd/src/views/marketing/flash-sale/styles/drawer.less new file mode 100644 index 0000000..8aec8a4 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/drawer.less @@ -0,0 +1,256 @@ +/** + * 文件职责:限时折扣主编辑抽屉样式。 + */ +.fs-editor-drawer { + .ant-drawer-header { + min-height: 54px; + padding: 0 18px; + border-bottom: 1px solid #f0f0f0; + } + + .ant-drawer-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; + } + + .ant-drawer-body { + padding: 14px 16px 12px; + } + + .ant-drawer-footer { + padding: 10px 16px; + border-top: 1px solid #f0f0f0; + } + + .fs-editor-form { + .ant-form-item { + margin-bottom: 14px; + } + + .ant-form-item-label { + padding-bottom: 6px; + } + + .ant-form-item-label > label { + font-size: 13px; + font-weight: 500; + color: #374151; + } + } + + .ant-input, + .ant-input-number, + .ant-picker, + .ant-select-selector { + border-color: #e5e7eb !important; + border-radius: 6px !important; + } + + .ant-input, + .ant-input-number-input, + .ant-picker-input > input { + font-size: 13px; + } + + .ant-input { + height: 34px; + padding: 0 10px; + } + + .ant-input-number { + height: 34px; + } + + .ant-input-number-input { + height: 32px; + } + + .ant-picker { + height: 34px; + padding: 0 10px; + } + + .ant-input-number:focus-within, + .ant-picker-focused, + .ant-input:focus { + border-color: #1677ff !important; + box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important; + } + + .fs-pill-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .fs-pill { + height: 32px; + padding: 0 14px; + font-size: 13px; + line-height: 30px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: all 0.2s; + } + + .fs-pill:hover { + color: #1677ff; + border-color: #91caff; + } + + .fs-pill.checked { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .fs-range-picker { + width: 100%; + max-width: 360px; + } + + .fs-day-sel { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + + .fs-day { + min-width: 46px; + height: 28px; + padding: 0 10px; + font-size: 12px; + color: #9ca3af; + cursor: pointer; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 14px; + transition: all 0.2s; + } + + .fs-day.active { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .fs-day-quick { + display: flex; + gap: 8px; + margin-bottom: 8px; + } + + .fs-product-empty { + padding: 14px 12px; + margin-bottom: 10px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #f8f9fb; + border: 1px dashed #e5e7eb; + border-radius: 8px; + } + + .fs-drawer-prod { + margin-bottom: 12px; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + } + + .fs-drawer-prod-hd { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #f8f9fb; + border-bottom: 1px solid #e5e7eb; + } + + .fs-drawer-prod-hd span { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + } + + .fs-drawer-prod-remove { + width: 24px; + height: 24px; + font-size: 14px; + color: #9ca3af; + cursor: pointer; + background: none; + border: none; + border-radius: 6px; + transition: all 0.2s; + } + + .fs-drawer-prod-remove:hover { + color: #ef4444; + background: #fef2f2; + } + + .fs-drawer-prod-bd { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 10px 12px; + } + + .fs-field { + display: flex; + flex-direction: column; + gap: 4px; + } + + .fs-field label { + font-size: 11px; + color: #6b7280; + } + + .fs-field .ant-input, + .fs-field .ant-input-number { + width: 120px; + } + + .fs-auto-rate { + min-width: 46px; + font-size: 12px; + font-weight: 600; + line-height: 34px; + color: #ef4444; + } + + .fs-limit-row { + display: flex; + gap: 8px; + align-items: center; + } + + .fs-limit-row .ant-input-number { + width: 100px; + } + + .fs-unit { + font-size: 13px; + color: #6b7280; + } + + .fs-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-start; + } + + .fs-drawer-footer .ant-btn { + min-width: 64px; + height: 32px; + border-radius: 6px; + } +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/index.less b/apps/web-antd/src/views/marketing/flash-sale/styles/index.less new file mode 100644 index 0000000..0cf68d3 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './picker.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/layout.less b/apps/web-antd/src/views/marketing/flash-sale/styles/layout.less new file mode 100644 index 0000000..290b850 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/layout.less @@ -0,0 +1,91 @@ +/** + * 文件职责:限时折扣页面布局样式。 + */ +.page-marketing-flash-sale { + .fs-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .fs-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--fs-border); + border-radius: 10px; + box-shadow: var(--fs-shadow-sm); + } + + .fs-store-select { + width: 220px; + } + + .fs-filter-select { + width: 130px; + } + + .fs-search { + width: 220px; + } + + .fs-store-select .ant-select-selector, + .fs-filter-select .ant-select-selector { + border-radius: 8px !important; + } + + .fs-spacer { + flex: 1; + } + + .fs-stats { + display: flex; + flex-wrap: wrap; + gap: 24px; + padding: 10px 16px; + font-size: 13px; + color: #4b5563; + background: #fff; + border: 1px solid var(--fs-border); + border-radius: 10px; + box-shadow: var(--fs-shadow-sm); + } + + .fs-stats span { + display: inline-flex; + gap: 6px; + align-items: center; + } + + .fs-stats strong { + font-weight: 600; + color: #1a1a2e; + } + + .fs-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .fs-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px solid var(--fs-border); + border-radius: 10px; + box-shadow: var(--fs-shadow-sm); + } + + .fs-pagination { + display: flex; + justify-content: flex-end; + padding: 12px 4px 2px; + margin-top: 12px; + } +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/picker.less b/apps/web-antd/src/views/marketing/flash-sale/styles/picker.less new file mode 100644 index 0000000..8710bbd --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/picker.less @@ -0,0 +1,146 @@ +/** + * 文件职责:限时折扣商品选择弹窗样式。 + */ +.fs-product-picker-modal { + .ant-modal-body { + padding: 0; + } + + .ant-modal-content { + overflow: hidden; + border-radius: 10px; + } + + .pp-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + } + + .pp-search { + flex: 1; + min-width: 220px; + max-width: 360px; + } + + .pp-cat-select { + width: 150px; + } + + .pp-selected-count { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #1677ff; + } + + .pp-body { + max-height: 56vh; + overflow: auto; + } + + .pp-empty { + padding: 32px 0; + } + + .pp-table { + width: 100%; + font-size: 13px; + border-collapse: collapse; + } + + .pp-table th { + position: sticky; + top: 0; + z-index: 2; + padding: 9px 14px; + font-size: 12px; + font-weight: 500; + color: #6b7280; + text-align: left; + background: #f8f9fb; + border-bottom: 1px solid #e5e7eb; + } + + .pp-table td { + padding: 9px 14px; + color: #1a1a2e; + border-bottom: 1px solid #f3f4f6; + } + + .pp-table tbody tr { + cursor: pointer; + } + + .pp-table tbody tr:hover td { + background: color-mix(in srgb, #1677ff 3%, #fff); + } + + .pp-table tbody tr.pp-checked td { + background: color-mix(in srgb, #1677ff 8%, #fff); + } + + .pp-col-check { + width: 44px; + } + + .pp-prod-name { + font-weight: 500; + } + + .pp-prod-spu { + margin-top: 2px; + font-size: 11px; + color: #9ca3af; + } + + .pp-prod-status { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 11px; + border-radius: 999px; + } + + .pp-prod-status.on { + color: #166534; + background: #dcfce7; + } + + .pp-prod-status.sold { + color: #92400e; + background: #fef3c7; + } + + .pp-prod-status.off { + color: #475569; + background: #e2e8f0; + } + + .pp-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-top: 1px solid #f0f0f0; + } + + .pp-footer-info { + font-size: 12px; + color: #6b7280; + } + + .pp-footer-info strong { + color: #1677ff; + } + + .pp-footer-btns { + display: inline-flex; + gap: 8px; + align-items: center; + } +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/styles/responsive.less b/apps/web-antd/src/views/marketing/flash-sale/styles/responsive.less new file mode 100644 index 0000000..6eb6e7b --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/styles/responsive.less @@ -0,0 +1,37 @@ +/** + * 文件职责:限时折扣页面响应式样式。 + */ +.page-marketing-flash-sale { + @media (width <= 1200px) { + .fs-toolbar { + flex-wrap: wrap; + } + + .fs-spacer { + display: none; + } + } + + @media (width <= 768px) { + .fs-card { + padding: 14px; + } + + .fs-card-hd { + gap: 8px; + } + + .fs-card-summary { + gap: 10px 16px; + } + + .fs-prod-table { + font-size: 12px; + } + + .fs-prod-table th, + .fs-prod-table td { + padding: 7px 8px; + } + } +} diff --git a/apps/web-antd/src/views/marketing/flash-sale/types.ts b/apps/web-antd/src/views/marketing/flash-sale/types.ts new file mode 100644 index 0000000..38ebb38 --- /dev/null +++ b/apps/web-antd/src/views/marketing/flash-sale/types.ts @@ -0,0 +1,68 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingFlashSaleChannel, + MarketingFlashSaleCycleType, + MarketingFlashSaleDisplayStatus, + MarketingFlashSaleEditorStatus, + MarketingFlashSaleListItemDto, + MarketingFlashSaleMetricsDto, + MarketingFlashSalePickerCategoryItemDto, + MarketingFlashSalePickerProductItemDto, + MarketingFlashSaleProductStatus, + MarketingFlashSaleRecurringDateMode, + MarketingFlashSaleStatsDto, +} from '#/api/marketing'; + +/** + * 文件职责:限时折扣页面类型定义。 + */ + +/** 列表筛选表单。 */ +export interface FlashSaleFilterForm { + status: '' | MarketingFlashSaleDisplayStatus; +} + +/** 抽屉商品表单项。 */ +export interface FlashSaleEditorProductForm { + categoryId: string; + categoryName: string; + discountPrice: null | number; + name: string; + originalPrice: number; + perUserLimit: null | number; + productId: string; + soldCount: number; + spuCode: string; + status: MarketingFlashSaleProductStatus; +} + +/** 主编辑抽屉表单。 */ +export interface FlashSaleEditorForm { + channels: MarketingFlashSaleChannel[]; + cycleType: MarketingFlashSaleCycleType; + id: string; + metrics: MarketingFlashSaleMetricsDto; + name: string; + perUserLimit: null | number; + products: FlashSaleEditorProductForm[]; + recurringDateMode: MarketingFlashSaleRecurringDateMode; + status: MarketingFlashSaleEditorStatus; + storeIds: string[]; + timeRange: [Dayjs, Dayjs] | null; + validDateRange: [Dayjs, Dayjs] | null; + weekDays: number[]; +} + +/** 列表卡片视图模型。 */ +export type FlashSaleCardViewModel = MarketingFlashSaleListItemDto; + +/** 统计视图模型。 */ +export type FlashSaleStatsViewModel = MarketingFlashSaleStatsDto; + +/** 选品分类项。 */ +export type FlashSalePickerCategoryItem = + MarketingFlashSalePickerCategoryItemDto; + +/** 选品商品项。 */ +export type FlashSalePickerProductItem = MarketingFlashSalePickerProductItemDto;