diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts index 941828b..5765ce1 100644 --- a/apps/web-antd/src/api/marketing/index.ts +++ b/apps/web-antd/src/api/marketing/index.ts @@ -185,3 +185,4 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { export * from './flash-sale'; export * from './full-reduction'; +export * from './seckill'; diff --git a/apps/web-antd/src/api/marketing/seckill.ts b/apps/web-antd/src/api/marketing/seckill.ts new file mode 100644 index 0000000..a6674bd --- /dev/null +++ b/apps/web-antd/src/api/marketing/seckill.ts @@ -0,0 +1,271 @@ +/** + * 文件职责:营销中心秒杀活动 API 与 DTO 定义。 + * 1. 维护秒杀活动列表、详情、保存、状态切换、删除与选品契约。 + */ +import { requestClient } from '#/api/request'; + +/** 活动展示状态。 */ +export type MarketingSeckillDisplayStatus = 'ended' | 'ongoing' | 'upcoming'; + +/** 活动编辑状态。 */ +export type MarketingSeckillEditorStatus = 'active' | 'completed'; + +/** 秒杀活动类型。 */ +export type MarketingSeckillActivityType = 'hourly' | 'timed'; + +/** 适用渠道。 */ +export type MarketingSeckillChannel = 'delivery' | 'dine_in' | 'pickup'; + +/** 商品状态。 */ +export type MarketingSeckillProductStatus = + | 'off_shelf' + | 'on_sale' + | 'sold_out'; + +/** 场次。 */ +export interface MarketingSeckillSessionDto { + durationMinutes: number; + startTime: string; +} + +/** 秒杀商品。 */ +export interface MarketingSeckillProductDto { + categoryId: string; + categoryName: string; + name: string; + originalPrice: number; + perUserLimit: null | number; + productId: string; + seckillPrice: number; + soldCount: number; + spuCode: string; + status: MarketingSeckillProductStatus; + stockLimit: number; +} + +/** 活动指标。 */ +export interface MarketingSeckillMetricsDto { + conversionRate: number; + dealCount: number; + monthlySeckillSalesCount: number; + participantCount: number; +} + +/** 列表查询参数。 */ +export interface MarketingSeckillListQuery { + keyword?: string; + page: number; + pageSize: number; + status?: '' | MarketingSeckillDisplayStatus; + storeId?: string; +} + +/** 详情查询参数。 */ +export interface MarketingSeckillDetailQuery { + activityId: string; + storeId: string; +} + +/** 保存请求。 */ +export interface SaveMarketingSeckillDto { + activityType: MarketingSeckillActivityType; + channels: MarketingSeckillChannel[]; + endDate?: string; + id?: string; + metrics?: MarketingSeckillMetricsDto; + name: string; + perUserLimit: null | number; + preheatEnabled: boolean; + preheatHours: null | number; + products: Array<{ + perUserLimit: null | number; + productId: string; + seckillPrice: number; + stockLimit: number; + }>; + sessions: MarketingSeckillSessionDto[]; + startDate?: string; + storeId: string; + timeEnd?: string; + timeStart?: string; +} + +/** 状态修改请求。 */ +export interface ChangeMarketingSeckillStatusDto { + activityId: string; + status: MarketingSeckillEditorStatus; + storeId: string; +} + +/** 删除请求。 */ +export interface DeleteMarketingSeckillDto { + activityId: string; + storeId: string; +} + +/** 列表统计。 */ +export interface MarketingSeckillStatsDto { + conversionRate: number; + monthlySeckillSalesCount: number; + ongoingCount: number; + totalCount: number; +} + +/** 列表项。 */ +export interface MarketingSeckillListItemDto { + activityType: MarketingSeckillActivityType; + channels: MarketingSeckillChannel[]; + displayStatus: MarketingSeckillDisplayStatus; + endDate?: string; + id: string; + isDimmed: boolean; + metrics: MarketingSeckillMetricsDto; + name: string; + perUserLimit: null | number; + preheatEnabled: boolean; + preheatHours: null | number; + products: MarketingSeckillProductDto[]; + sessions: MarketingSeckillSessionDto[]; + startDate?: string; + status: MarketingSeckillEditorStatus; + storeIds: string[]; + timeEnd?: string; + timeStart?: string; + updatedAt: string; +} + +/** 列表结果。 */ +export interface MarketingSeckillListResultDto { + items: MarketingSeckillListItemDto[]; + page: number; + pageSize: number; + stats: MarketingSeckillStatsDto; + total: number; +} + +/** 详情数据。 */ +export interface MarketingSeckillDetailDto { + activityType: MarketingSeckillActivityType; + channels: MarketingSeckillChannel[]; + displayStatus: MarketingSeckillDisplayStatus; + endDate?: string; + id: string; + metrics: MarketingSeckillMetricsDto; + name: string; + perUserLimit: null | number; + preheatEnabled: boolean; + preheatHours: null | number; + products: MarketingSeckillProductDto[]; + sessions: MarketingSeckillSessionDto[]; + startDate?: string; + status: MarketingSeckillEditorStatus; + storeIds: string[]; + timeEnd?: string; + timeStart?: string; + updatedAt: string; +} + +/** 选品分类查询参数。 */ +export interface MarketingSeckillPickerCategoryQuery { + storeId: string; +} + +/** 选品分类项。 */ +export interface MarketingSeckillPickerCategoryItemDto { + id: string; + name: string; + productCount: number; +} + +/** 选品商品查询参数。 */ +export interface MarketingSeckillPickerProductQuery { + categoryId?: string; + keyword?: string; + limit?: number; + storeId: string; +} + +/** 选品商品项。 */ +export interface MarketingSeckillPickerProductItemDto { + categoryId: string; + categoryName: string; + id: string; + name: string; + price: number; + spuCode: string; + status: MarketingSeckillProductStatus; + stock: number; +} + +/** 获取列表。 */ +export async function getMarketingSeckillListApi( + params: MarketingSeckillListQuery, +) { + return requestClient.get( + '/marketing/seckill/list', + { + params, + }, + ); +} + +/** 获取详情。 */ +export async function getMarketingSeckillDetailApi( + params: MarketingSeckillDetailQuery, +) { + return requestClient.get( + '/marketing/seckill/detail', + { + params, + }, + ); +} + +/** 保存活动。 */ +export async function saveMarketingSeckillApi(data: SaveMarketingSeckillDto) { + return requestClient.post( + '/marketing/seckill/save', + data, + ); +} + +/** 修改状态。 */ +export async function changeMarketingSeckillStatusApi( + data: ChangeMarketingSeckillStatusDto, +) { + return requestClient.post( + '/marketing/seckill/status', + data, + ); +} + +/** 删除活动。 */ +export async function deleteMarketingSeckillApi( + data: DeleteMarketingSeckillDto, +) { + return requestClient.post('/marketing/seckill/delete', data); +} + +/** 获取选品分类。 */ +export async function getMarketingSeckillPickerCategoriesApi( + params: MarketingSeckillPickerCategoryQuery, +) { + return requestClient.get( + '/marketing/seckill/picker/categories', + { + params, + }, + ); +} + +/** 获取选品商品。 */ +export async function getMarketingSeckillPickerProductsApi( + params: MarketingSeckillPickerProductQuery, +) { + return requestClient.get( + '/marketing/seckill/picker/products', + { + params, + }, + ); +} 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 index 350d238..25ada47 100644 --- a/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleEditorDrawer.vue +++ b/apps/web-antd/src/views/marketing/flash-sale/components/FlashSaleEditorDrawer.vue @@ -64,12 +64,27 @@ const emit = defineEmits<{ (event: 'toggleWeekDay', day: number): void; }>(); -function onDateRangeChange(value: [Dayjs, Dayjs] | null) { - emit('setValidDateRange', value); +type RangePickerValue = [Dayjs, Dayjs] | [string, string] | null; + +function normalizeDayjsRange(value: RangePickerValue): [Dayjs, Dayjs] | null { + if (!value) { + return null; + } + + const [start, end] = value; + if (typeof start === 'string' || typeof end === 'string') { + return null; + } + + return [start, end]; } -function onTimeRangeChange(value: [Dayjs, Dayjs] | null) { - emit('setTimeRange', value); +function onDateRangeChange(value: RangePickerValue) { + emit('setValidDateRange', normalizeDayjsRange(value)); +} + +function onTimeRangeChange(value: RangePickerValue) { + emit('setTimeRange', normalizeDayjsRange(value)); } function parseNullableNumber(value: null | number | string) { diff --git a/apps/web-antd/src/views/marketing/seckill/components/SeckillActivityCard.vue b/apps/web-antd/src/views/marketing/seckill/components/SeckillActivityCard.vue new file mode 100644 index 0000000..ff96cec --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/components/SeckillActivityCard.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/web-antd/src/views/marketing/seckill/components/SeckillEditorDrawer.vue b/apps/web-antd/src/views/marketing/seckill/components/SeckillEditorDrawer.vue new file mode 100644 index 0000000..632237e --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/components/SeckillEditorDrawer.vue @@ -0,0 +1,357 @@ + + + diff --git a/apps/web-antd/src/views/marketing/seckill/components/SeckillProductPickerDrawer.vue b/apps/web-antd/src/views/marketing/seckill/components/SeckillProductPickerDrawer.vue new file mode 100644 index 0000000..cdbc77a --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/components/SeckillProductPickerDrawer.vue @@ -0,0 +1,174 @@ + + + diff --git a/apps/web-antd/src/views/marketing/seckill/components/SeckillStatsCards.vue b/apps/web-antd/src/views/marketing/seckill/components/SeckillStatsCards.vue new file mode 100644 index 0000000..c9ba913 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/components/SeckillStatsCards.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/card-actions.ts b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/card-actions.ts new file mode 100644 index 0000000..621ed98 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/card-actions.ts @@ -0,0 +1,98 @@ +import type { SeckillCardViewModel } from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动卡片行操作。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeMarketingSeckillStatusApi, + deleteMarketingSeckillApi, +} from '#/api/marketing'; + +interface CreateCardActionsOptions { + loadActivities: () => Promise; + resolveOperationStoreId: (preferredStoreIds?: string[]) => string; +} + +export function createCardActions(options: CreateCardActionsOptions) { + function toggleActivityStatus(item: SeckillCardViewModel) { + const operationStoreId = options.resolveOperationStoreId(item.storeIds); + if (!operationStoreId) { + return; + } + + const nextStatus = item.status === 'completed' ? 'active' : 'completed'; + const isEnabling = nextStatus === 'active'; + const feedbackKey = `seckill-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 changeMarketingSeckillStatusApi({ + 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: SeckillCardViewModel) { + const operationStoreId = options.resolveOperationStoreId(item.storeIds); + if (!operationStoreId) { + return; + } + + Modal.confirm({ + title: `确认删除活动「${item.name}」吗?`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + try { + await deleteMarketingSeckillApi({ + 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/seckill/composables/seckill-page/constants.ts b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/constants.ts new file mode 100644 index 0000000..03d9586 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/constants.ts @@ -0,0 +1,169 @@ +import type { + MarketingSeckillActivityType, + MarketingSeckillChannel, + MarketingSeckillDisplayStatus, + MarketingSeckillProductStatus, +} from '#/api/marketing'; +import type { + SeckillEditorForm, + SeckillEditorProductForm, + SeckillEditorSessionForm, + SeckillFilterForm, +} from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动页面常量与默认表单。 + */ + +/** 状态筛选项。 */ +export const SECKILL_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingSeckillDisplayStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '进行中', value: 'ongoing' }, + { label: '未开始', value: 'upcoming' }, + { label: '已结束', value: 'ended' }, +]; + +/** 活动类型选项。 */ +export const SECKILL_ACTIVITY_TYPE_OPTIONS: Array<{ + label: string; + value: MarketingSeckillActivityType; +}> = [ + { label: '限时秒杀', value: 'timed' }, + { label: '整点秒杀', value: 'hourly' }, +]; + +/** 适用渠道选项。 */ +export const SECKILL_CHANNEL_OPTIONS: Array<{ + label: string; + value: MarketingSeckillChannel; +}> = [ + { label: '外卖', value: 'delivery' }, + { label: '自提', value: 'pickup' }, + { label: '堂食', value: 'dine_in' }, +]; + +/** 展示状态文案。 */ +export const SECKILL_STATUS_TEXT_MAP: Record< + MarketingSeckillDisplayStatus, + string +> = { + ongoing: '进行中', + upcoming: '未开始', + ended: '已结束', +}; + +/** 展示状态类。 */ +export const SECKILL_STATUS_CLASS_MAP: Record< + MarketingSeckillDisplayStatus, + string +> = { + ongoing: 'sk-tag-running', + upcoming: 'sk-tag-notstarted', + ended: 'sk-tag-ended', +}; + +/** 商品状态文案。 */ +export const SECKILL_PRODUCT_STATUS_TEXT_MAP: Record< + MarketingSeckillProductStatus, + string +> = { + on_sale: '在售', + off_shelf: '下架', + sold_out: '沽清', +}; + +/** 创建默认筛选表单。 */ +export function createDefaultSeckillFilterForm(): SeckillFilterForm { + return { + status: '', + }; +} + +/** 创建空指标对象。 */ +export function createEmptySeckillMetrics() { + return { + participantCount: 0, + dealCount: 0, + conversionRate: 0, + monthlySeckillSalesCount: 0, + }; +} + +/** 创建默认场次表单项。 */ +export function createDefaultSeckillSessionForm( + session?: Partial, +): SeckillEditorSessionForm { + return { + key: session?.key ?? `${Date.now()}-${Math.random()}`, + startTime: session?.startTime ?? '', + durationMinutes: + session?.durationMinutes === null || + session?.durationMinutes === undefined + ? 60 + : Number(session.durationMinutes), + }; +} + +/** 创建默认商品表单。 */ +export function createDefaultSeckillProductForm( + product?: Partial, +): SeckillEditorProductForm { + 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), + seckillPrice: + product?.seckillPrice === null || product?.seckillPrice === undefined + ? null + : Number(product.seckillPrice), + stockLimit: + product?.stockLimit === null || product?.stockLimit === undefined + ? null + : Number(product.stockLimit), + perUserLimit: + product?.perUserLimit === null || product?.perUserLimit === undefined + ? null + : Number(product.perUserLimit), + soldCount: Number(product?.soldCount ?? 0), + }; +} + +/** 创建默认编辑表单。 */ +export function createDefaultSeckillEditorForm(): SeckillEditorForm { + return { + id: '', + name: '', + activityType: 'timed', + validDateRange: null, + timeRange: null, + sessions: [ + createDefaultSeckillSessionForm({ + startTime: '10:00', + durationMinutes: 60, + }), + createDefaultSeckillSessionForm({ + startTime: '14:00', + durationMinutes: 60, + }), + createDefaultSeckillSessionForm({ + startTime: '18:00', + durationMinutes: 60, + }), + ], + channels: ['delivery', 'pickup', 'dine_in'], + perUserLimit: null, + preheatEnabled: true, + preheatHours: 2, + products: [], + metrics: createEmptySeckillMetrics(), + status: 'active', + storeIds: [], + }; +} diff --git a/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/data-actions.ts b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/data-actions.ts new file mode 100644 index 0000000..0724e5c --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/data-actions.ts @@ -0,0 +1,115 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +import type { + SeckillCardViewModel, + SeckillFilterForm, + SeckillStatsViewModel, +} from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动页面数据读取动作。 + */ +import { message } from 'ant-design-vue'; + +import { getMarketingSeckillListApi } from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + filterForm: SeckillFilterForm; + 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 getMarketingSeckillListApi({ + 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(): SeckillStatsViewModel { + return { + totalCount: 0, + ongoingCount: 0, + monthlySeckillSalesCount: 0, + conversionRate: 0, + }; +} diff --git a/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/drawer-actions.ts new file mode 100644 index 0000000..79f9205 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/drawer-actions.ts @@ -0,0 +1,476 @@ +import type { Ref } from 'vue'; + +import type { + MarketingSeckillActivityType, + MarketingSeckillChannel, +} from '#/api/marketing'; +import type { StoreListItemDto } from '#/api/store'; +import type { + SeckillEditorForm, + SeckillEditorProductForm, +} from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动主编辑抽屉动作。 + */ +import { ref } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { + getMarketingSeckillDetailApi, + saveMarketingSeckillApi, +} from '#/api/marketing'; + +import { + createDefaultSeckillEditorForm, + createDefaultSeckillSessionForm, +} from './constants'; +import { + buildSaveSeckillPayload, + cloneProductForm, + cloneSessionForm, + mapDetailToEditorForm, +} from './helpers'; + +interface CreateDrawerActionsOptions { + form: SeckillEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadActivities: () => Promise; + openPicker: ( + pickerOptions: { + selectedProductIds: string[]; + selectedProducts: SeckillEditorProductForm[]; + storeId: string; + }, + onConfirm: (products: SeckillEditorProductForm[]) => 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: SeckillEditorForm) { + options.form.id = next.id; + options.form.name = next.name; + options.form.activityType = next.activityType; + options.form.validDateRange = next.validDateRange; + options.form.timeRange = next.timeRange; + options.form.sessions = next.sessions.map((item) => cloneSessionForm(item)); + options.form.channels = [...next.channels]; + options.form.perUserLimit = next.perUserLimit; + options.form.preheatEnabled = next.preheatEnabled; + options.form.preheatHours = next.preheatHours; + 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(createDefaultSeckillEditorForm()); + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setActivityType(value: MarketingSeckillActivityType) { + options.form.activityType = value; + if (value === 'hourly' && options.form.sessions.length === 0) { + options.form.sessions = [createDefaultSeckillSessionForm()]; + } + } + + function setFormValidDateRange(value: SeckillEditorForm['validDateRange']) { + options.form.validDateRange = value; + } + + function setFormTimeRange(value: SeckillEditorForm['timeRange']) { + options.form.timeRange = value; + } + + function addSession() { + options.form.sessions = [ + ...options.form.sessions, + createDefaultSeckillSessionForm(), + ]; + } + + function removeSession(index: number) { + options.form.sessions = options.form.sessions.filter( + (_, rowIndex) => rowIndex !== index, + ); + } + + function setSessionStartTime(index: number, value: string) { + const row = options.form.sessions[index]; + if (!row) { + return; + } + row.startTime = value; + options.form.sessions = [...options.form.sessions]; + } + + function setSessionDuration(index: number, value: null | number) { + const row = options.form.sessions[index]; + if (!row) { + return; + } + row.durationMinutes = normalizeNullableInteger(value); + options.form.sessions = [...options.form.sessions]; + } + + function setFormChannels(value: MarketingSeckillChannel[]) { + options.form.channels = [...new Set(value)]; + } + + function toggleChannel(channel: MarketingSeckillChannel) { + 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 setPreheatEnabled(value: boolean) { + options.form.preheatEnabled = value; + if (!value) { + options.form.preheatHours = null; + return; + } + + if (!options.form.preheatHours || options.form.preheatHours <= 0) { + options.form.preheatHours = 1; + } + } + + function setPreheatHours(value: null | number) { + options.form.preheatHours = normalizeNullableInteger(value); + } + + function setProductSeckillPrice(index: number, value: null | number) { + const row = options.form.products[index]; + if (!row) { + return; + } + row.seckillPrice = normalizeNullableNumber(value); + options.form.products = [...options.form.products]; + } + + function setProductStockLimit(index: number, value: null | number) { + const row = options.form.products[index]; + if (!row) { + return; + } + row.stockLimit = normalizeNullableInteger(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 getMarketingSeckillDetailApi({ + 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 saveMarketingSeckillApi( + buildSaveSeckillPayload(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.activityType === 'timed' && + (!options.form.validDateRange || !options.form.timeRange) + ) { + message.warning('请完整设置活动时间'); + return false; + } + + if (options.form.activityType === 'hourly') { + if (options.form.sessions.length === 0) { + message.warning('请至少配置一个场次'); + return false; + } + + const sessionTimeSet = new Set(); + for (const session of options.form.sessions) { + if (!isTimeText(session.startTime)) { + message.warning('场次时间格式必须为 HH:mm'); + return false; + } + + if (!session.durationMinutes || session.durationMinutes <= 0) { + message.warning('场次持续时长必须大于 0'); + return false; + } + + if (sessionTimeSet.has(session.startTime)) { + message.warning('场次时间不能重复'); + return false; + } + sessionTimeSet.add(session.startTime); + } + } + + 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.preheatEnabled && + (!options.form.preheatHours || options.form.preheatHours <= 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.seckillPrice || row.seckillPrice <= 0) { + message.warning(`商品「${row.name}」秒杀价必须大于 0`); + return false; + } + if (row.seckillPrice > row.originalPrice) { + message.warning(`商品「${row.name}」秒杀价不能高于原价`); + return false; + } + if (!row.stockLimit || row.stockLimit <= 0) { + message.warning(`商品「${row.name}」限量必须大于 0`); + return false; + } + if (row.stockLimit < row.soldCount) { + 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; + } + } + + 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 { + addSession, + drawerMode, + openCreateDrawer, + openEditDrawer, + openProductPicker, + removeProduct, + removeSession, + setActivityType, + setDrawerOpen, + setFormChannels, + setFormName, + setFormPerUserLimit, + setFormTimeRange, + setFormValidDateRange, + setPreheatEnabled, + setPreheatHours, + setProductPerUserLimit, + setProductSeckillPrice, + setProductStockLimit, + setSessionDuration, + setSessionStartTime, + submitDrawer, + toggleChannel, + }; +} + +function isTimeText(value: string) { + return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test((value || '').trim()); +} + +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.floor(numeric); +} diff --git a/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/helpers.ts b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/helpers.ts new file mode 100644 index 0000000..ab9c992 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/helpers.ts @@ -0,0 +1,256 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingSeckillDetailDto, + MarketingSeckillEditorStatus, + SaveMarketingSeckillDto, +} from '#/api/marketing'; +import type { + SeckillCardViewModel, + SeckillEditorForm, + SeckillEditorProductForm, + SeckillEditorSessionForm, +} from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动页面纯函数工具。 + */ +import dayjs from 'dayjs'; + +import { + createDefaultSeckillEditorForm, + createDefaultSeckillProductForm, + createDefaultSeckillSessionForm, +} 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; +} + +/** 保留小数并去除尾零。 */ +export function trimDecimal(value: number) { + return Number(value || 0) + .toFixed(2) + .replace(/\.?0+$/, ''); +} + +/** 计算进度百分比。 */ +export function resolveProgressPercent(soldCount: number, stockLimit: number) { + if (stockLimit <= 0) { + return 0; + } + const percent = (soldCount / stockLimit) * 100; + return Math.max(0, Math.min(100, Math.round(percent))); +} + +/** 根据进度计算条颜色。 */ +export function resolveProgressClass(percent: number) { + if (percent >= 100) { + return 'red'; + } + if (percent >= 70) { + return 'orange'; + } + return 'green'; +} + +/** 商品限购文案。 */ +export function resolveProductLimitText(limit: null | number) { + if (!limit || limit <= 0) { + return '不限'; + } + return `${formatInteger(limit)}件/人`; +} + +/** 活动时间文案。 */ +export function resolveCardTimeText(item: SeckillCardViewModel) { + if (item.activityType === 'hourly') { + if (item.sessions.length === 0) { + return '场次未配置'; + } + + const sessionText = item.sessions + .map((session) => `${session.startTime} 场`) + .join(' / '); + return `每天 ${sessionText}`; + } + + if (item.displayStatus === 'upcoming' && item.startDate && item.timeStart) { + return `${item.startDate} ${item.timeStart} 开始`; + } + + if (item.displayStatus === 'ended' && item.startDate && item.endDate) { + return `${item.startDate} ~ ${item.endDate}`; + } + + if (item.timeStart && item.timeEnd) { + return `每天 ${item.timeStart} - ${item.timeEnd}`; + } + + return '时间待设置'; +} + +/** 活动汇总数据。 */ +export function resolveCardSummary(item: SeckillCardViewModel) { + return [ + { + label: '参与人数', + value: `${formatInteger(item.metrics.participantCount)}人`, + }, + { + label: '成交', + value: `${formatInteger(item.metrics.dealCount)}单`, + }, + { + label: '转化率', + value: `${trimDecimal(item.metrics.conversionRate)}%`, + }, + ]; +} + +/** 映射详情到编辑表单。 */ +export function mapDetailToEditorForm( + detail: MarketingSeckillDetailDto, +): SeckillEditorForm { + const form = createDefaultSeckillEditorForm(); + form.id = detail.id; + form.name = detail.name; + form.activityType = detail.activityType; + 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.sessions = detail.sessions.map((item) => + createDefaultSeckillSessionForm({ + startTime: item.startTime, + durationMinutes: item.durationMinutes, + }), + ); + form.channels = [...detail.channels]; + form.perUserLimit = detail.perUserLimit; + form.preheatEnabled = detail.preheatEnabled; + form.preheatHours = detail.preheatHours; + form.products = detail.products.map((item) => + createDefaultSeckillProductForm({ + productId: item.productId, + categoryId: item.categoryId, + categoryName: item.categoryName, + name: item.name, + spuCode: item.spuCode, + status: item.status, + originalPrice: item.originalPrice, + seckillPrice: item.seckillPrice, + stockLimit: item.stockLimit, + perUserLimit: item.perUserLimit, + soldCount: item.soldCount, + }), + ); + form.metrics = { ...detail.metrics }; + form.status = detail.status; + form.storeIds = [...detail.storeIds]; + return form; +} + +/** 构建保存请求。 */ +export function buildSaveSeckillPayload( + form: SeckillEditorForm, + storeId: string, +): SaveMarketingSeckillDto { + const [startDate, endDate] = (form.validDateRange ?? []) as [Dayjs, Dayjs]; + const [timeStart, timeEnd] = (form.timeRange ?? []) as [Dayjs, Dayjs]; + const isTimedActivity = form.activityType === 'timed'; + + return { + id: form.id || undefined, + storeId, + name: form.name.trim(), + activityType: form.activityType, + startDate: + isTimedActivity && form.validDateRange && startDate + ? startDate.format('YYYY-MM-DD') + : undefined, + endDate: + isTimedActivity && form.validDateRange && endDate + ? endDate.format('YYYY-MM-DD') + : undefined, + timeStart: + isTimedActivity && form.timeRange && timeStart + ? timeStart.format('HH:mm') + : undefined, + timeEnd: + isTimedActivity && form.timeRange && timeEnd + ? timeEnd.format('HH:mm') + : undefined, + sessions: + form.activityType === 'hourly' + ? form.sessions + .filter((item) => item.startTime.trim()) + .map((item) => ({ + startTime: item.startTime, + durationMinutes: Number(item.durationMinutes ?? 0), + })) + : [], + channels: [...form.channels], + perUserLimit: form.perUserLimit, + preheatEnabled: form.preheatEnabled, + preheatHours: form.preheatEnabled ? form.preheatHours : null, + products: form.products.map((item) => ({ + productId: item.productId, + seckillPrice: Number(item.seckillPrice || 0), + stockLimit: Number(item.stockLimit || 0), + perUserLimit: item.perUserLimit, + })), + metrics: { ...form.metrics }, + }; +} + +/** 深拷贝商品表单项。 */ +export function cloneProductForm( + product: SeckillEditorProductForm, +): SeckillEditorProductForm { + return { + productId: product.productId, + categoryId: product.categoryId, + categoryName: product.categoryName, + name: product.name, + spuCode: product.spuCode, + status: product.status, + originalPrice: product.originalPrice, + seckillPrice: product.seckillPrice, + stockLimit: product.stockLimit, + perUserLimit: product.perUserLimit, + soldCount: product.soldCount, + }; +} + +/** 深拷贝场次表单项。 */ +export function cloneSessionForm( + session: SeckillEditorSessionForm, +): SeckillEditorSessionForm { + return { + key: session.key, + startTime: session.startTime, + durationMinutes: session.durationMinutes, + }; +} + +/** 是否已结束。 */ +export function isEndedStatus(status: MarketingSeckillEditorStatus) { + return status === 'completed'; +} diff --git a/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/picker-actions.ts b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/picker-actions.ts new file mode 100644 index 0000000..0152936 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/seckill-page/picker-actions.ts @@ -0,0 +1,214 @@ +import type { Ref } from 'vue'; + +import type { + SeckillEditorProductForm, + SeckillPickerCategoryItem, + SeckillPickerProductItem, +} from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动商品选择二级抽屉动作。 + */ +import { message } from 'ant-design-vue'; + +import { + getMarketingSeckillPickerCategoriesApi, + getMarketingSeckillPickerProductsApi, +} from '#/api/marketing'; + +import { createDefaultSeckillProductForm } from './constants'; + +interface OpenPickerOptions { + selectedProducts: SeckillEditorProductForm[]; + 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: SeckillEditorProductForm[]) => 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 getMarketingSeckillPickerCategoriesApi({ + storeId: activeStoreId, + }); + } + + async function loadPickerProducts() { + if (!activeStoreId) { + options.pickerProducts.value = []; + return; + } + options.pickerProducts.value = await getMarketingSeckillPickerProductsApi({ + 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: SeckillEditorProductForm[]) => void, + ) { + if (!pickerOptions.storeId) { + message.warning('请先选择具体门店后再添加商品'); + return; + } + + activeStoreId = pickerOptions.storeId; + onConfirmProducts = onConfirm; + selectedProductSnapshot = new Map( + pickerOptions.selectedProducts.map((item) => [ + item.productId, + createDefaultSeckillProductForm(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, + createDefaultSeckillProductForm({ + productId: item.id, + categoryId: item.categoryId, + categoryName: item.categoryName, + name: item.name, + spuCode: item.spuCode, + status: item.status, + originalPrice: item.price, + seckillPrice: item.price, + stockLimit: null, + perUserLimit: null, + soldCount: 0, + }), + ]), + ); + + const selectedProducts = [...selectedIds] + .map( + (productId) => + currentProductMap.get(productId) ?? + selectedProductSnapshot.get(productId), + ) + .filter(Boolean) as SeckillEditorProductForm[]; + + 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/seckill/composables/useMarketingSeckillPage.ts b/apps/web-antd/src/views/marketing/seckill/composables/useMarketingSeckillPage.ts new file mode 100644 index 0000000..229c132 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/composables/useMarketingSeckillPage.ts @@ -0,0 +1,286 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { + SeckillCardViewModel, + SeckillPickerCategoryItem, + SeckillPickerProductItem, + SeckillStatsViewModel, +} from '#/views/marketing/seckill/types'; + +/** + * 文件职责:秒杀活动页面状态与行为编排。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { createCardActions } from './seckill-page/card-actions'; +import { + createDefaultSeckillEditorForm, + createDefaultSeckillFilterForm, + SECKILL_STATUS_FILTER_OPTIONS, +} from './seckill-page/constants'; +import { + createDataActions, + createEmptyStats, +} from './seckill-page/data-actions'; +import { createDrawerActions } from './seckill-page/drawer-actions'; +import { createPickerActions } from './seckill-page/picker-actions'; + +export function useMarketingSeckillPage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const filterForm = reactive(createDefaultSeckillFilterForm()); + 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(createDefaultSeckillEditorForm()); + + 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 { + addSession, + drawerMode, + openCreateDrawer, + openEditDrawer, + openProductPicker, + removeProduct, + removeSession, + setActivityType, + setDrawerOpen, + setFormChannels, + setFormName, + setFormPerUserLimit, + setFormTimeRange, + setFormValidDateRange, + setPreheatEnabled, + setPreheatHours, + setProductPerUserLimit, + setProductSeckillPrice, + setProductStockLimit, + setSessionDuration, + setSessionStartTime, + submitDrawer, + toggleChannel, + } = 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: '' | SeckillCardViewModel['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 { + addSession, + applyFilters, + drawerSubmitText, + drawerTitle, + filterForm, + form, + SECKILL_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, + reloadPickerList, + removeActivity, + removeProduct, + removeSession, + resetFilters, + rows, + selectedStoreId, + setActivityType, + setDrawerOpen, + setFormChannels, + setFormName, + setFormPerUserLimit, + setFormTimeRange, + setFormValidDateRange, + setKeyword: setKeywordValue, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + setPreheatEnabled, + setPreheatHours, + setProductPerUserLimit, + setProductSeckillPrice, + setProductStockLimit, + setSelectedStoreId, + setSessionDuration, + setSessionStartTime, + setStatusFilter, + stats, + storeNameMap, + storeOptions, + submitDrawer, + submitPicker, + toggleActivityStatus, + toggleAllProducts, + toggleChannel, + togglePickerProduct, + total, + }; +} diff --git a/apps/web-antd/src/views/marketing/seckill/index.vue b/apps/web-antd/src/views/marketing/seckill/index.vue new file mode 100644 index 0000000..f25ba0c --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/index.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/seckill/styles/base.less b/apps/web-antd/src/views/marketing/seckill/styles/base.less new file mode 100644 index 0000000..c25f637 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/styles/base.less @@ -0,0 +1,29 @@ +/** + * 文件职责:秒杀活动页面基础样式变量。 + */ +.page-marketing-seckill { + --sk-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --sk-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --sk-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%); + --sk-border: #e7eaf0; + --sk-text: #1f2937; + --sk-subtext: #6b7280; + --sk-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/seckill/styles/card.less b/apps/web-antd/src/views/marketing/seckill/styles/card.less new file mode 100644 index 0000000..9da7e98 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/styles/card.less @@ -0,0 +1,214 @@ +/** + * 文件职责:秒杀活动卡片样式。 + */ +.page-marketing-seckill { + .sk-card { + padding: 20px; + margin-bottom: 2px; + background: #fff; + border: 1px solid var(--sk-border); + border-radius: 10px; + box-shadow: var(--sk-shadow-sm); + transition: box-shadow var(--sk-transition); + } + + .sk-card:hover { + box-shadow: var(--sk-shadow-md); + } + + .sk-card.ended { + opacity: 0.56; + } + + .sk-card-hd { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 14px; + } + + .sk-card-name { + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + } + + .sk-card-time { + display: inline-flex; + gap: 4px; + align-items: center; + font-size: 12px; + color: var(--sk-muted); + } + + .sk-card-time .iconify { + width: 12px; + height: 12px; + } + + .sk-tag-running { + font-weight: 600; + color: #22c55e; + background: #dcfce7; + border: 1px solid #bbf7d0; + border-radius: 6px; + } + + .sk-tag-ended { + font-weight: 600; + color: #9ca3af; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 6px; + } + + .sk-tag-notstarted { + font-weight: 600; + color: #1677ff; + background: #f0f5ff; + border: 1px solid #adc6ff; + border-radius: 6px; + } + + .sk-prod-list { + margin-bottom: 14px; + } + + .sk-prod-row { + display: flex; + gap: 16px; + align-items: center; + padding: 10px 12px; + font-size: 13px; + border-bottom: 1px solid #f3f4f6; + } + + .sk-prod-row:last-child { + border-bottom: none; + } + + .sk-prod-row:hover { + background: color-mix(in srgb, #1677ff 3%, #fff); + border-radius: 6px; + } + + .sk-prod-name { + flex-shrink: 0; + width: 140px; + font-weight: 500; + color: #1a1a2e; + } + + .sk-prod-prices { + display: flex; + flex-shrink: 0; + gap: 8px; + align-items: baseline; + width: 140px; + } + + .sk-orig-price { + font-size: 12px; + color: var(--sk-muted); + text-decoration: line-through; + } + + .sk-seckill-price { + font-size: 16px; + font-weight: 700; + color: #ef4444; + } + + .sk-progress-wrap { + display: flex; + flex: 1; + gap: 8px; + align-items: center; + min-width: 160px; + } + + .sk-progress { + position: relative; + flex: 1; + height: 16px; + overflow: hidden; + background: #f3f4f6; + border-radius: 8px; + } + + .sk-progress-fill { + height: 100%; + border-radius: 8px; + transition: width 0.3s ease; + } + + .sk-progress-fill.green { + background: linear-gradient(90deg, #52c41a, #73d13d); + } + + .sk-progress-fill.orange { + background: linear-gradient(90deg, #fa8c16, #ffa940); + } + + .sk-progress-fill.red { + background: linear-gradient(90deg, #f5222d, #ff4d4f); + } + + .sk-progress-text { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: #fff; + white-space: nowrap; + text-shadow: 0 1px 2px rgb(0 0 0 / 20%); + } + + .sk-sold-out { + display: inline-flex; + gap: 3px; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: #f5222d; + white-space: nowrap; + background: #fff1f0; + border: 1px solid #ffa39e; + border-radius: 4px; + } + + .sk-sold-out .iconify { + width: 11px; + height: 11px; + } + + .sk-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; + } + + .sk-card-summary strong { + margin-left: 4px; + font-weight: 600; + color: #1a1a2e; + } + + .sk-card-ft { + display: flex; + gap: 16px; + padding-top: 12px; + border-top: 1px solid #f3f4f6; + } +} diff --git a/apps/web-antd/src/views/marketing/seckill/styles/drawer.less b/apps/web-antd/src/views/marketing/seckill/styles/drawer.less new file mode 100644 index 0000000..d858f4d --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/styles/drawer.less @@ -0,0 +1,274 @@ +/** + * 文件职责:秒杀活动主编辑抽屉样式。 + */ +.sk-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; + } + + .sk-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; + } + + .sk-pill-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .sk-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; + } + + .sk-pill:hover { + color: #1677ff; + border-color: #91caff; + } + + .sk-pill.checked { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .sk-range-picker { + width: 100%; + max-width: 360px; + } + + .sk-session-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; + } + + .sk-session-row { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 12px; + background: #f8f9fb; + border: 1px solid #f0f0f0; + border-radius: 6px; + } + + .sk-session-time { + width: 100px; + } + + .sk-session-duration { + width: 88px; + } + + .sk-session-label { + font-size: 12px; + color: #6b7280; + } + + .sk-session-remove { + width: 24px; + height: 24px; + margin-left: auto; + font-size: 14px; + color: #9ca3af; + cursor: pointer; + background: none; + border: none; + border-radius: 6px; + transition: all 0.2s; + } + + .sk-session-remove:hover { + color: #ef4444; + background: #fef2f2; + } + + .sk-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; + } + + .sk-drawer-prod { + margin-bottom: 12px; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + } + + .sk-drawer-prod-hd { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #f8f9fb; + border-bottom: 1px solid #e5e7eb; + } + + .sk-drawer-prod-hd span { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + } + + .sk-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; + } + + .sk-drawer-prod-remove:hover { + color: #ef4444; + background: #fef2f2; + } + + .sk-drawer-prod-bd { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 10px 12px; + } + + .sk-field { + display: flex; + flex-direction: column; + gap: 4px; + } + + .sk-field label { + font-size: 11px; + color: #6b7280; + } + + .sk-field .ant-input, + .sk-field .ant-input-number { + width: 124px; + } + + .sk-limit-row { + display: flex; + gap: 8px; + align-items: center; + } + + .sk-limit-row .ant-input-number { + width: 100px; + } + + .sk-unit { + font-size: 13px; + color: #6b7280; + } + + .sk-preheat-row { + display: flex; + gap: 8px; + align-items: center; + } + + .sk-preheat-row .ant-input-number { + width: 84px; + } + + .sk-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-start; + } + + .sk-drawer-footer .ant-btn { + min-width: 64px; + height: 32px; + border-radius: 6px; + } +} diff --git a/apps/web-antd/src/views/marketing/seckill/styles/index.less b/apps/web-antd/src/views/marketing/seckill/styles/index.less new file mode 100644 index 0000000..0cf68d3 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/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/seckill/styles/layout.less b/apps/web-antd/src/views/marketing/seckill/styles/layout.less new file mode 100644 index 0000000..6804a84 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/styles/layout.less @@ -0,0 +1,91 @@ +/** + * 文件职责:秒杀活动页面布局样式。 + */ +.page-marketing-seckill { + .sk-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .sk-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--sk-border); + border-radius: 10px; + box-shadow: var(--sk-shadow-sm); + } + + .sk-store-select { + width: 220px; + } + + .sk-filter-select { + width: 130px; + } + + .sk-search { + width: 220px; + } + + .sk-store-select .ant-select-selector, + .sk-filter-select .ant-select-selector { + border-radius: 8px !important; + } + + .sk-spacer { + flex: 1; + } + + .sk-stats { + display: flex; + flex-wrap: wrap; + gap: 24px; + padding: 10px 16px; + font-size: 13px; + color: #4b5563; + background: #fff; + border: 1px solid var(--sk-border); + border-radius: 10px; + box-shadow: var(--sk-shadow-sm); + } + + .sk-stats span { + display: inline-flex; + gap: 6px; + align-items: center; + } + + .sk-stats strong { + font-weight: 600; + color: #1a1a2e; + } + + .sk-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .sk-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px solid var(--sk-border); + border-radius: 10px; + box-shadow: var(--sk-shadow-sm); + } + + .sk-pagination { + display: flex; + justify-content: flex-end; + padding: 12px 4px 2px; + margin-top: 12px; + } +} diff --git a/apps/web-antd/src/views/marketing/seckill/styles/picker.less b/apps/web-antd/src/views/marketing/seckill/styles/picker.less new file mode 100644 index 0000000..95f604f --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/styles/picker.less @@ -0,0 +1,145 @@ +/** + * 文件职责:秒杀活动商品选择二级抽屉样式。 + */ +.sk-product-picker-drawer { + .ant-drawer-body { + padding: 0; + } + + .ant-drawer-footer { + padding: 0; + } + + .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/seckill/styles/responsive.less b/apps/web-antd/src/views/marketing/seckill/styles/responsive.less new file mode 100644 index 0000000..c663062 --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/styles/responsive.less @@ -0,0 +1,48 @@ +/** + * 文件职责:秒杀活动页面响应式样式。 + */ +.page-marketing-seckill { + @media (width <= 1200px) { + .sk-toolbar { + flex-wrap: wrap; + } + + .sk-spacer { + display: none; + } + } + + @media (width <= 768px) { + .sk-card { + padding: 14px; + } + + .sk-card-hd { + gap: 8px; + } + + .sk-prod-row { + flex-wrap: wrap; + gap: 10px; + align-items: flex-start; + } + + .sk-prod-name, + .sk-prod-prices { + width: auto; + } + + .sk-progress-wrap { + width: 100%; + min-width: 0; + } + + .sk-card-summary { + gap: 10px 16px; + } + + .sk-stats { + gap: 12px 18px; + } + } +} diff --git a/apps/web-antd/src/views/marketing/seckill/types.ts b/apps/web-antd/src/views/marketing/seckill/types.ts new file mode 100644 index 0000000..4d03faa --- /dev/null +++ b/apps/web-antd/src/views/marketing/seckill/types.ts @@ -0,0 +1,79 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingSeckillActivityType, + MarketingSeckillChannel, + MarketingSeckillDisplayStatus, + MarketingSeckillEditorStatus, + MarketingSeckillListItemDto, + MarketingSeckillMetricsDto, + MarketingSeckillPickerCategoryItemDto, + MarketingSeckillPickerProductItemDto, + MarketingSeckillProductStatus, + MarketingSeckillSessionDto, + MarketingSeckillStatsDto, +} from '#/api/marketing'; + +/** + * 文件职责:秒杀活动页面类型定义。 + */ + +/** 列表筛选表单。 */ +export interface SeckillFilterForm { + status: '' | MarketingSeckillDisplayStatus; +} + +/** 抽屉场次表单项。 */ +export interface SeckillEditorSessionForm { + durationMinutes: null | number; + key: string; + startTime: string; +} + +/** 抽屉商品表单项。 */ +export interface SeckillEditorProductForm { + categoryId: string; + categoryName: string; + name: string; + originalPrice: number; + perUserLimit: null | number; + productId: string; + seckillPrice: null | number; + soldCount: number; + spuCode: string; + status: MarketingSeckillProductStatus; + stockLimit: null | number; +} + +/** 主编辑抽屉表单。 */ +export interface SeckillEditorForm { + activityType: MarketingSeckillActivityType; + channels: MarketingSeckillChannel[]; + id: string; + metrics: MarketingSeckillMetricsDto; + name: string; + perUserLimit: null | number; + preheatEnabled: boolean; + preheatHours: null | number; + products: SeckillEditorProductForm[]; + sessions: SeckillEditorSessionForm[]; + status: MarketingSeckillEditorStatus; + storeIds: string[]; + timeRange: [Dayjs, Dayjs] | null; + validDateRange: [Dayjs, Dayjs] | null; +} + +/** 列表卡片视图模型。 */ +export type SeckillCardViewModel = MarketingSeckillListItemDto; + +/** 统计视图模型。 */ +export type SeckillStatsViewModel = MarketingSeckillStatsDto; + +/** 选品分类项。 */ +export type SeckillPickerCategoryItem = MarketingSeckillPickerCategoryItemDto; + +/** 选品商品项。 */ +export type SeckillPickerProductItem = MarketingSeckillPickerProductItemDto; + +/** 场次规则项。 */ +export type SeckillSessionItem = MarketingSeckillSessionDto;