diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index e948a16..0d81707 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -26,7 +26,7 @@ "#/*": "./src/*" }, "dependencies": { - "@microsoft/signalr": "^8.0.7", + "@microsoft/signalr": "catalog:", "@vben/access": "workspace:*", "@vben/common-ui": "workspace:*", "@vben/constants": "workspace:*", diff --git a/apps/web-antd/src/api/marketing/full-reduction.ts b/apps/web-antd/src/api/marketing/full-reduction.ts new file mode 100644 index 0000000..1ac82f9 --- /dev/null +++ b/apps/web-antd/src/api/marketing/full-reduction.ts @@ -0,0 +1,240 @@ +/** + * 文件职责:营销中心满减活动 API 与 DTO 定义。 + * 1. 维护满减活动列表、详情、保存、状态切换与删除契约。 + */ +import { requestClient } from '#/api/request'; + +/** 活动类型。 */ +export type MarketingFullReductionActivityType = + | 'gift' + | 'reduce' + | 'second_half'; + +/** 展示状态。 */ +export type MarketingFullReductionDisplayStatus = + | 'ended' + | 'ongoing' + | 'upcoming'; + +/** 编辑状态。 */ +export type MarketingFullReductionEditorStatus = 'active' | 'completed'; + +/** 适用渠道。 */ +export type MarketingFullReductionChannel = 'delivery' | 'dine_in' | 'pickup'; + +/** 门店范围。 */ +export type MarketingFullReductionStoreScopeMode = 'all' | 'stores'; + +/** 商品范围。 */ +export type MarketingFullReductionScopeType = 'all' | 'category' | 'product'; + +/** 满赠赠品类型。 */ +export type MarketingFullReductionGiftScopeType = 'same_lowest' | 'specified'; + +/** 第二份折扣类型。 */ +export type MarketingFullReductionSecondHalfDiscountType = + | 'free' + | 'half' + | 'seventy' + | 'sixty'; + +/** 满减阶梯规则。 */ +export interface MarketingFullReductionTierRuleDto { + meetAmount: number; + reduceAmount: number; +} + +/** 商品范围规则。 */ +export interface MarketingFullReductionScopeRuleDto { + categoryIds: string[]; + productIds: string[]; + scopeType: MarketingFullReductionScopeType; +} + +/** 满赠规则。 */ +export interface MarketingFullReductionGiftRuleDto { + applicableScope: MarketingFullReductionScopeRuleDto; + buyQuantity: number; + giftQuantity: number; + giftScope: MarketingFullReductionScopeRuleDto; + giftScopeType: MarketingFullReductionGiftScopeType; +} + +/** 第二份半价规则。 */ +export interface MarketingFullReductionSecondHalfRuleDto { + applicableScope: MarketingFullReductionScopeRuleDto; + discountType: MarketingFullReductionSecondHalfDiscountType; +} + +/** 活动指标。 */ +export interface MarketingFullReductionMetricsDto { + attachRateIncreasePercent: number; + averageTicketIncrease: number; + discountTotalAmount: number; + drivenSalesAmount: number; + giftedCount: number; + monthlyDrivenSalesAmount: number; + participatingOrderCount: number; + ticketIncreaseAmount: number; +} + +/** 列表查询参数。 */ +export interface MarketingFullReductionListQuery { + activityType?: '' | MarketingFullReductionActivityType; + keyword?: string; + page: number; + pageSize: number; + status?: '' | MarketingFullReductionDisplayStatus; + storeId?: string; +} + +/** 详情查询参数。 */ +export interface MarketingFullReductionDetailQuery { + activityId: string; + storeId: string; +} + +/** 保存请求。 */ +export interface SaveMarketingFullReductionDto { + activityType: MarketingFullReductionActivityType; + channels: MarketingFullReductionChannel[]; + description?: string; + endDate: string; + giftRule?: MarketingFullReductionGiftRuleDto; + id?: string; + metrics?: MarketingFullReductionMetricsDto; + name: string; + reduceTiers: MarketingFullReductionTierRuleDto[]; + scopeStoreId: string; + secondHalfRule?: MarketingFullReductionSecondHalfRuleDto; + stackWithCoupon: boolean; + startDate: string; + storeId: string; + storeIds?: string[]; + storeScopeMode: MarketingFullReductionStoreScopeMode; +} + +/** 状态修改请求。 */ +export interface ChangeMarketingFullReductionStatusDto { + activityId: string; + status: MarketingFullReductionEditorStatus; + storeId: string; +} + +/** 删除请求。 */ +export interface DeleteMarketingFullReductionDto { + activityId: string; + storeId: string; +} + +/** 统计数据。 */ +export interface MarketingFullReductionStatsDto { + averageTicketIncrease: number; + monthlyDrivenSalesAmount: number; + ongoingCount: number; + totalCount: number; +} + +/** 列表项。 */ +export interface MarketingFullReductionListItemDto { + activityType: MarketingFullReductionActivityType; + channels: MarketingFullReductionChannel[]; + description?: string; + displayStatus: MarketingFullReductionDisplayStatus; + endDate: string; + giftRule?: MarketingFullReductionGiftRuleDto; + id: string; + isDimmed: boolean; + metrics: MarketingFullReductionMetricsDto; + name: string; + reduceTiers: MarketingFullReductionTierRuleDto[]; + scopeStoreId: string; + secondHalfRule?: MarketingFullReductionSecondHalfRuleDto; + stackWithCoupon: boolean; + startDate: string; + storeIds: string[]; + storeScopeMode: MarketingFullReductionStoreScopeMode; + updatedAt: string; +} + +/** 列表结果。 */ +export interface MarketingFullReductionListResultDto { + items: MarketingFullReductionListItemDto[]; + page: number; + pageSize: number; + stats: MarketingFullReductionStatsDto; + total: number; +} + +/** 详情数据。 */ +export interface MarketingFullReductionDetailDto { + activityType: MarketingFullReductionActivityType; + channels: MarketingFullReductionChannel[]; + description?: string; + displayStatus: MarketingFullReductionDisplayStatus; + endDate: string; + giftRule?: MarketingFullReductionGiftRuleDto; + id: string; + metrics: MarketingFullReductionMetricsDto; + name: string; + reduceTiers: MarketingFullReductionTierRuleDto[]; + scopeStoreId: string; + secondHalfRule?: MarketingFullReductionSecondHalfRuleDto; + stackWithCoupon: boolean; + startDate: string; + status: MarketingFullReductionEditorStatus; + storeIds: string[]; + storeScopeMode: MarketingFullReductionStoreScopeMode; + updatedAt: string; +} + +/** 获取列表。 */ +export async function getMarketingFullReductionListApi( + params: MarketingFullReductionListQuery, +) { + return requestClient.get( + '/marketing/full-reduction/list', + { + params, + }, + ); +} + +/** 获取详情。 */ +export async function getMarketingFullReductionDetailApi( + params: MarketingFullReductionDetailQuery, +) { + return requestClient.get( + '/marketing/full-reduction/detail', + { + params, + }, + ); +} + +/** 保存活动。 */ +export async function saveMarketingFullReductionApi( + data: SaveMarketingFullReductionDto, +) { + return requestClient.post( + '/marketing/full-reduction/save', + data, + ); +} + +/** 修改状态。 */ +export async function changeMarketingFullReductionStatusApi( + data: ChangeMarketingFullReductionStatusDto, +) { + return requestClient.post( + '/marketing/full-reduction/status', + data, + ); +} + +/** 删除活动。 */ +export async function deleteMarketingFullReductionApi( + data: DeleteMarketingFullReductionDto, +) { + return requestClient.post('/marketing/full-reduction/delete', data); +} diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts index 335091a..fd7f2fc 100644 --- a/apps/web-antd/src/api/marketing/index.ts +++ b/apps/web-antd/src/api/marketing/index.ts @@ -182,3 +182,5 @@ export async function changeMarketingCouponStatusApi( export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { return requestClient.post('/marketing/coupon/delete', data); } + +export * from './full-reduction'; diff --git a/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionActivityCard.vue b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionActivityCard.vue new file mode 100644 index 0000000..c8dd544 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionActivityCard.vue @@ -0,0 +1,124 @@ + + + diff --git a/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionEditorDrawer.vue b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionEditorDrawer.vue new file mode 100644 index 0000000..40c22c6 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionEditorDrawer.vue @@ -0,0 +1,449 @@ + + + diff --git a/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionScopePickerDrawer.vue b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionScopePickerDrawer.vue new file mode 100644 index 0000000..626f367 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionScopePickerDrawer.vue @@ -0,0 +1,352 @@ + + + diff --git a/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionStatsCards.vue b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionStatsCards.vue new file mode 100644 index 0000000..d98875a --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/components/FullReductionStatsCards.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/card-actions.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/card-actions.ts new file mode 100644 index 0000000..db1a81d --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/card-actions.ts @@ -0,0 +1,82 @@ +import type { FullReductionCardViewModel } from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动卡片行操作。 + * 1. 封装停用(完成)与删除动作。 + * 2. 统一确认弹窗与反馈提示。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeMarketingFullReductionStatusApi, + deleteMarketingFullReductionApi, +} from '#/api/marketing'; + +interface CreateCardActionsOptions { + loadActivities: () => Promise; + resolveOperationStoreId: (preferredStoreIds?: string[]) => string; +} + +export function createCardActions(options: CreateCardActionsOptions) { + function disableActivity(item: FullReductionCardViewModel) { + const operationStoreId = options.resolveOperationStoreId(item.storeIds); + if (!operationStoreId) return; + + Modal.confirm({ + title: `确认停用活动「${item.name}」吗?`, + content: '停用后活动将直接结束,且不可恢复。', + okText: '确认停用', + cancelText: '取消', + async onOk() { + const feedbackKey = `full-reduction-status-${item.id}`; + try { + message.loading({ + key: feedbackKey, + duration: 0, + content: '正在停用活动...', + }); + await changeMarketingFullReductionStatusApi({ + storeId: operationStoreId, + activityId: item.id, + status: 'completed', + }); + message.success({ + key: feedbackKey, + content: '活动已停用', + }); + await options.loadActivities(); + } catch (error) { + console.error(error); + message.error({ + key: feedbackKey, + content: '停用失败,请稍后重试', + }); + } + }, + }); + } + + function removeActivity(item: FullReductionCardViewModel) { + const operationStoreId = options.resolveOperationStoreId(item.storeIds); + if (!operationStoreId) return; + + Modal.confirm({ + title: `确认删除活动「${item.name}」吗?`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + await deleteMarketingFullReductionApi({ + storeId: operationStoreId, + activityId: item.id, + }); + message.success('活动已删除'); + await options.loadActivities(); + }, + }); + } + + return { + disableActivity, + removeActivity, + }; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/constants.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/constants.ts new file mode 100644 index 0000000..0c24004 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/constants.ts @@ -0,0 +1,233 @@ +import type { + MarketingFullReductionActivityType, + MarketingFullReductionChannel, + MarketingFullReductionDisplayStatus, + MarketingFullReductionGiftScopeType, + MarketingFullReductionSecondHalfDiscountType, + MarketingFullReductionStoreScopeMode, +} from '#/api/marketing'; +import type { + FullReductionEditorForm, + FullReductionFilterForm, + FullReductionGiftRuleForm, + FullReductionScopeForm, + FullReductionSecondHalfRuleForm, + FullReductionTierForm, +} from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动页面常量与默认表单构造。 + */ + +/** 活动类型筛选项。 */ +export const FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingFullReductionActivityType; +}> = [ + { label: '全部类型', value: '' }, + { label: '满减', value: 'reduce' }, + { label: '满赠', value: 'gift' }, + { label: '第二份半价', value: 'second_half' }, +]; + +/** 活动类型单选项。 */ +export const FULL_REDUCTION_EDITOR_ACTIVITY_OPTIONS: Array<{ + label: string; + value: MarketingFullReductionActivityType; +}> = [ + { label: '满减', value: 'reduce' }, + { label: '满赠', value: 'gift' }, + { label: '第二份半价', value: 'second_half' }, +]; + +/** 展示状态筛选项。 */ +export const FULL_REDUCTION_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingFullReductionDisplayStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '进行中', value: 'ongoing' }, + { label: '未开始', value: 'upcoming' }, + { label: '已结束', value: 'ended' }, +]; + +/** 渠道选项。 */ +export const FULL_REDUCTION_CHANNEL_OPTIONS: Array<{ + label: string; + value: MarketingFullReductionChannel; +}> = [ + { label: '外卖', value: 'delivery' }, + { label: '自提', value: 'pickup' }, + { label: '堂食', value: 'dine_in' }, +]; + +/** 门店范围选项。 */ +export const FULL_REDUCTION_STORE_SCOPE_OPTIONS: Array<{ + label: string; + value: MarketingFullReductionStoreScopeMode; +}> = [ + { label: '全部门店', value: 'all' }, + { label: '指定门店', value: 'stores' }, +]; + +/** 满赠赠品范围类型。 */ +export const FULL_REDUCTION_GIFT_SCOPE_OPTIONS: Array<{ + label: string; + value: MarketingFullReductionGiftScopeType; +}> = [ + { label: '同商品(价低者)', value: 'same_lowest' }, + { label: '指定赠品', value: 'specified' }, +]; + +/** 第二份优惠折扣类型。 */ +export const FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS: Array<{ + label: string; + value: MarketingFullReductionSecondHalfDiscountType; +}> = [ + { label: '5折', value: 'half' }, + { label: '6折', value: 'sixty' }, + { label: '7折', value: 'seventy' }, + { label: '免费', value: 'free' }, +]; + +/** 叠加规则选项。 */ +export const FULL_REDUCTION_STACK_OPTIONS: Array<{ + label: string; + value: boolean; +}> = [ + { label: '不可叠加优惠券', value: false }, + { label: '可叠加优惠券', value: true }, +]; + +/** 活动类型文案。 */ +export const FULL_REDUCTION_ACTIVITY_TEXT_MAP: Record< + MarketingFullReductionActivityType, + string +> = { + reduce: '满减', + gift: '满赠', + second_half: '第二份半价', +}; + +/** 活动类型标签样式类。 */ +export const FULL_REDUCTION_ACTIVITY_TAG_CLASS_MAP: Record< + MarketingFullReductionActivityType, + string +> = { + reduce: 'mfr-type-reduce', + gift: 'mfr-type-gift', + second_half: 'mfr-type-second-half', +}; + +/** 活动状态文案。 */ +export const FULL_REDUCTION_STATUS_TEXT_MAP: Record< + MarketingFullReductionDisplayStatus, + string +> = { + ongoing: '进行中', + upcoming: '未开始', + ended: '已结束', +}; + +/** 活动状态徽标样式类。 */ +export const FULL_REDUCTION_STATUS_TAG_CLASS_MAP: Record< + MarketingFullReductionDisplayStatus, + string +> = { + ongoing: 'mfr-status-ongoing', + upcoming: 'mfr-status-upcoming', + ended: 'mfr-status-ended', +}; + +/** 渠道文案。 */ +export const FULL_REDUCTION_CHANNEL_TEXT_MAP: Record< + MarketingFullReductionChannel, + string +> = { + delivery: '外卖', + pickup: '自提', + dine_in: '堂食', +}; + +/** 构建默认筛选表单。 */ +export function createDefaultFullReductionFilterForm(): FullReductionFilterForm { + return { + activityType: '', + status: '', + }; +} + +/** 构建空指标对象。 */ +export function createEmptyFullReductionMetrics() { + return { + participatingOrderCount: 0, + discountTotalAmount: 0, + ticketIncreaseAmount: 0, + giftedCount: 0, + drivenSalesAmount: 0, + attachRateIncreasePercent: 0, + monthlyDrivenSalesAmount: 0, + averageTicketIncrease: 0, + }; +} + +/** 构建默认商品范围。 */ +export function createDefaultScopeForm( + scopeType: FullReductionScopeForm['scopeType'] = 'all', +): FullReductionScopeForm { + return { + scopeType, + categoryIds: [], + productIds: [], + }; +} + +/** 构建默认阶梯行。 */ +export function createDefaultTierForm( + meetAmount: null | number = null, + reduceAmount: null | number = null, +): FullReductionTierForm { + return { + meetAmount, + reduceAmount, + }; +} + +/** 构建默认满赠规则。 */ +export function createDefaultGiftRuleForm(): FullReductionGiftRuleForm { + return { + buyQuantity: 2, + giftQuantity: 1, + giftScopeType: 'same_lowest', + applicableScope: createDefaultScopeForm('all'), + giftScope: createDefaultScopeForm('all'), + }; +} + +/** 构建默认第二份半价规则。 */ +export function createDefaultSecondHalfRuleForm(): FullReductionSecondHalfRuleForm { + return { + discountType: 'half', + applicableScope: createDefaultScopeForm('category'), + }; +} + +/** 构建默认编辑表单。 */ +export function createDefaultFullReductionEditorForm(): FullReductionEditorForm { + return { + id: '', + name: '', + activityType: 'reduce', + reduceTiers: [createDefaultTierForm(30, 5)], + giftRule: createDefaultGiftRuleForm(), + secondHalfRule: createDefaultSecondHalfRuleForm(), + validDateRange: null, + channels: ['delivery', 'pickup', 'dine_in'], + storeScopeMode: 'all', + storeIds: [], + scopeStoreId: '', + stackWithCoupon: false, + description: '', + metrics: createEmptyFullReductionMetrics(), + }; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/data-actions.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/data-actions.ts new file mode 100644 index 0000000..7e51dea --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/data-actions.ts @@ -0,0 +1,118 @@ +import type { Ref } from 'vue'; + +import type { MarketingFullReductionStatsDto } from '#/api/marketing'; +import type { StoreListItemDto } from '#/api/store'; +import type { + FullReductionCardViewModel, + FullReductionFilterForm, +} from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动页面数据读取动作。 + * 1. 加载门店与活动列表(支持全部门店视角)。 + * 2. 维护分页、统计与加载状态。 + */ +import { message } from 'ant-design-vue'; + +import { getMarketingFullReductionListApi } from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + filterForm: FullReductionFilterForm; + 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 getMarketingFullReductionListApi({ + storeId: options.selectedStoreId.value || undefined, + activityType: options.filterForm.activityType, + 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(): MarketingFullReductionStatsDto { + return { + totalCount: 0, + ongoingCount: 0, + monthlyDrivenSalesAmount: 0, + averageTicketIncrease: 0, + }; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/drawer-actions.ts new file mode 100644 index 0000000..83b4553 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/drawer-actions.ts @@ -0,0 +1,564 @@ +import type { Ref } from 'vue'; + +import type { + MarketingFullReductionActivityType, + MarketingFullReductionChannel, + MarketingFullReductionGiftScopeType, + MarketingFullReductionSecondHalfDiscountType, + MarketingFullReductionStoreScopeMode, +} from '#/api/marketing'; +import type { StoreListItemDto } from '#/api/store'; +import type { + FullReductionEditorForm, + FullReductionPickerTarget, + FullReductionScopeForm, +} from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动编辑抽屉动作。 + * 1. 管理新增/编辑抽屉与字段更新。 + * 2. 负责详情加载、范围选择回填、表单校验与保存提交。 + */ +import { ref } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { + getMarketingFullReductionDetailApi, + saveMarketingFullReductionApi, +} from '#/api/marketing'; + +import { + createDefaultFullReductionEditorForm, + createDefaultScopeForm, + createDefaultTierForm, +} from './constants'; +import { + buildSaveFullReductionPayload, + cloneScopeForm, + mapDetailToEditorForm, +} from './helpers'; + +interface CreateDrawerActionsOptions { + form: FullReductionEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadActivities: () => Promise; + openPicker: ( + params: { + allowAll: boolean; + initialScope: FullReductionScopeForm; + scopeStoreId: string; + target: FullReductionPickerTarget; + title: string; + }, + onConfirm: (scope: FullReductionScopeForm) => 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: FullReductionEditorForm) { + options.form.id = next.id; + options.form.name = next.name; + options.form.activityType = next.activityType; + options.form.reduceTiers = next.reduceTiers.map((item) => ({ + meetAmount: item.meetAmount, + reduceAmount: item.reduceAmount, + })); + options.form.giftRule = { + buyQuantity: next.giftRule.buyQuantity, + giftQuantity: next.giftRule.giftQuantity, + giftScopeType: next.giftRule.giftScopeType, + applicableScope: cloneScopeForm(next.giftRule.applicableScope), + giftScope: cloneScopeForm(next.giftRule.giftScope), + }; + options.form.secondHalfRule = { + discountType: next.secondHalfRule.discountType, + applicableScope: cloneScopeForm(next.secondHalfRule.applicableScope), + }; + options.form.validDateRange = next.validDateRange; + options.form.channels = [...next.channels]; + options.form.storeScopeMode = next.storeScopeMode; + options.form.storeIds = [...next.storeIds]; + options.form.scopeStoreId = next.scopeStoreId; + options.form.stackWithCoupon = next.stackWithCoupon; + options.form.description = next.description; + options.form.metrics = { ...next.metrics }; + } + + function resetForm() { + applyForm(createDefaultFullReductionEditorForm()); + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormActivityType(value: MarketingFullReductionActivityType) { + options.form.activityType = value; + } + + function addReduceTier() { + options.form.reduceTiers = [ + ...options.form.reduceTiers, + createDefaultTierForm(), + ]; + } + + function removeReduceTier(index: number) { + if (options.form.reduceTiers.length <= 1) { + return; + } + options.form.reduceTiers = options.form.reduceTiers.filter( + (_, rowIndex) => rowIndex !== index, + ); + } + + function setReduceTierMeetAmount(index: number, value: null | number) { + const next = [...options.form.reduceTiers]; + const row = next[index]; + if (!row) return; + + row.meetAmount = normalizeNullableNumber(value); + options.form.reduceTiers = next; + } + + function setReduceTierReduceAmount(index: number, value: null | number) { + const next = [...options.form.reduceTiers]; + const row = next[index]; + if (!row) return; + + row.reduceAmount = normalizeNullableNumber(value); + options.form.reduceTiers = next; + } + + function setGiftBuyQuantity(value: null | number) { + options.form.giftRule.buyQuantity = normalizeNullableInteger(value); + } + + function setGiftGiftQuantity(value: null | number) { + options.form.giftRule.giftQuantity = normalizeNullableInteger(value); + } + + function setGiftScopeType(value: MarketingFullReductionGiftScopeType) { + options.form.giftRule.giftScopeType = value; + if (value === 'same_lowest') { + options.form.giftRule.giftScope = createDefaultScopeForm('all'); + return; + } + + if (options.form.giftRule.giftScope.scopeType === 'all') { + options.form.giftRule.giftScope = createDefaultScopeForm('product'); + } + } + + function setSecondHalfDiscountType( + value: MarketingFullReductionSecondHalfDiscountType, + ) { + options.form.secondHalfRule.discountType = value; + } + + function setFormValidDateRange( + value: FullReductionEditorForm['validDateRange'], + ) { + options.form.validDateRange = value; + } + + function setFormChannels(value: MarketingFullReductionChannel[]) { + options.form.channels = [...value]; + } + + function setFormStoreScopeMode(value: MarketingFullReductionStoreScopeMode) { + options.form.storeScopeMode = value; + if (value === 'all') { + options.form.storeIds = []; + return; + } + + if ( + options.selectedStoreId.value && + !options.form.storeIds.includes(options.selectedStoreId.value) + ) { + options.form.storeIds = [ + ...options.form.storeIds, + options.selectedStoreId.value, + ]; + } + } + + function setFormStoreIds(value: string[]) { + options.form.storeIds = normalizeStoreIds(value); + } + + function setFormStackWithCoupon(value: boolean) { + options.form.stackWithCoupon = value; + } + + function setFormDescription(value: string) { + options.form.description = value; + } + + async function openGiftApplicablePicker() { + await options.openPicker( + { + title: '选择适用商品范围', + target: 'giftApplicable', + allowAll: true, + initialScope: options.form.giftRule.applicableScope, + scopeStoreId: resolveScopeStoreId(), + }, + (scope) => { + options.form.giftRule.applicableScope = scope; + }, + ); + } + + async function openGiftScopePicker() { + await options.openPicker( + { + title: '选择指定赠品范围', + target: 'giftScope', + allowAll: false, + initialScope: options.form.giftRule.giftScope, + scopeStoreId: resolveScopeStoreId(), + }, + (scope) => { + options.form.giftRule.giftScope = scope; + }, + ); + } + + async function openSecondHalfApplicablePicker() { + await options.openPicker( + { + title: '选择第二份半价适用范围', + target: 'secondHalfApplicable', + allowAll: false, + initialScope: options.form.secondHalfRule.applicableScope, + scopeStoreId: resolveScopeStoreId(), + }, + (scope) => { + options.form.secondHalfRule.applicableScope = scope; + }, + ); + } + + async function openCreateDrawer() { + resetForm(); + options.form.scopeStoreId = resolveScopeStoreId(); + 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 getMarketingFullReductionDetailApi({ + storeId: operationStoreId, + activityId, + }); + + const mapped = mapDetailToEditorForm(detail); + mapped.scopeStoreId = resolveScopeStoreId(); + 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.storeScopeMode === 'stores' ? options.form.storeIds : [], + ); + if (!operationStoreId) { + return; + } + + if (!validateBeforeSubmit(operationStoreId)) { + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveMarketingFullReductionApi( + buildSaveFullReductionPayload( + options.form, + operationStoreId, + resolveScopeStoreId(), + ), + ); + 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) { + if (!options.form.name.trim()) { + message.warning('请输入活动名称'); + return false; + } + + if (options.form.name.trim().length > 64) { + message.warning('活动名称长度不能超过 64 个字符'); + return false; + } + + if (!options.form.validDateRange) { + message.warning('请选择活动时间'); + return false; + } + + if (options.form.channels.length === 0) { + message.warning('请至少选择一个适用渠道'); + return false; + } + + if ( + options.form.storeScopeMode === 'stores' && + options.form.storeIds.length === 0 + ) { + message.warning('请选择至少一个适用门店'); + return false; + } + + if ( + options.form.storeScopeMode === 'stores' && + !options.form.storeIds.includes(operationStoreId) + ) { + message.warning('指定门店需包含当前门店(用于选品基准)'); + return false; + } + + if (options.form.description.trim().length > 512) { + message.warning('活动说明长度不能超过 512 个字符'); + return false; + } + + if (options.form.activityType === 'reduce' && !validateReduceRules()) { + return false; + } + + if (options.form.activityType === 'gift' && !validateGiftRules()) { + return false; + } + + if ( + options.form.activityType === 'second_half' && + !validateSecondHalfRules() + ) { + return false; + } + + return true; + } + + function validateReduceRules() { + if (options.form.reduceTiers.length === 0) { + message.warning('请至少配置一条满减阶梯'); + return false; + } + + const normalized = options.form.reduceTiers + .map((tier, index) => ({ + index, + meetAmount: Number(tier.meetAmount || 0), + reduceAmount: Number(tier.reduceAmount || 0), + })) + .toSorted((first, second) => first.meetAmount - second.meetAmount); + + for (const tier of normalized) { + if (tier.meetAmount <= 0 || tier.reduceAmount <= 0) { + message.warning('满减门槛与优惠金额必须大于 0'); + return false; + } + if (tier.reduceAmount >= tier.meetAmount) { + message.warning('满减优惠金额必须小于门槛金额'); + return false; + } + } + + for (let index = 1; index < normalized.length; index += 1) { + const current = normalized[index]; + const previous = normalized[index - 1]; + if (!current || !previous) { + continue; + } + + if (current.meetAmount <= previous.meetAmount) { + message.warning('满减门槛金额必须严格递增'); + return false; + } + } + + return true; + } + + function validateGiftRules() { + if ( + !options.form.giftRule.buyQuantity || + options.form.giftRule.buyQuantity <= 0 + ) { + message.warning('购买数量门槛必须大于 0'); + return false; + } + + if ( + !options.form.giftRule.giftQuantity || + options.form.giftRule.giftQuantity <= 0 + ) { + message.warning('赠送数量必须大于 0'); + return false; + } + + if ( + !validateScope( + options.form.giftRule.applicableScope, + true, + '适用商品范围', + ) + ) { + return false; + } + + if ( + options.form.giftRule.giftScopeType === 'specified' && + !validateScope(options.form.giftRule.giftScope, false, '指定赠品范围') + ) { + return false; + } + + return true; + } + + function validateSecondHalfRules() { + if ( + !validateScope( + options.form.secondHalfRule.applicableScope, + false, + '第二份半价适用范围', + ) + ) { + return false; + } + + return true; + } + + function validateScope( + scope: FullReductionScopeForm, + allowAll: boolean, + scopeName: string, + ) { + if (!allowAll && scope.scopeType === 'all') { + message.warning(`${scopeName}不支持全部商品,请选择分类或商品`); + return false; + } + + if (scope.scopeType === 'category' && scope.categoryIds.length === 0) { + message.warning(`${scopeName}至少选择一个分类`); + return false; + } + + if (scope.scopeType === 'product' && scope.productIds.length === 0) { + message.warning(`${scopeName}至少选择一个商品`); + return false; + } + + return true; + } + + function resolveScopeStoreId() { + const operationStoreId = options.resolveOperationStoreId( + options.form.storeScopeMode === 'stores' ? options.form.storeIds : [], + ); + if (operationStoreId) { + return operationStoreId; + } + return options.stores.value[0]?.id ?? ''; + } + + return { + addReduceTier, + drawerMode, + openCreateDrawer, + openEditDrawer, + openGiftApplicablePicker, + openGiftScopePicker, + openSecondHalfApplicablePicker, + removeReduceTier, + setDrawerOpen, + setFormActivityType, + setFormChannels, + setFormDescription, + setFormName, + setFormStackWithCoupon, + setFormStoreIds, + setFormStoreScopeMode, + setFormValidDateRange, + setGiftBuyQuantity, + setGiftGiftQuantity, + setGiftScopeType, + setReduceTierMeetAmount, + setReduceTierReduceAmount, + setSecondHalfDiscountType, + submitDrawer, + }; +} + +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)); +} + +function normalizeStoreIds(value: string[]) { + return [...new Set(value.map((item) => item.trim()).filter(Boolean))]; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/helpers.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/helpers.ts new file mode 100644 index 0000000..d8f7494 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/helpers.ts @@ -0,0 +1,376 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingFullReductionDetailDto, + MarketingFullReductionDisplayStatus, + MarketingFullReductionGiftRuleDto, + MarketingFullReductionScopeRuleDto, + MarketingFullReductionSecondHalfDiscountType, + MarketingFullReductionSecondHalfRuleDto, + SaveMarketingFullReductionDto, +} from '#/api/marketing'; +import type { + FullReductionCardViewModel, + FullReductionEditorForm, + FullReductionScopeForm, +} from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动页面纯函数。 + * 1. 负责列表文案、样式、规则可视化映射。 + * 2. 负责详情与编辑表单互转、保存请求构建。 + */ +import dayjs from 'dayjs'; + +import { + createDefaultFullReductionEditorForm, + createDefaultGiftRuleForm, + createDefaultScopeForm, + createDefaultSecondHalfRuleForm, + createDefaultTierForm, + FULL_REDUCTION_ACTIVITY_TEXT_MAP, + FULL_REDUCTION_CHANNEL_TEXT_MAP, + FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS, +} from './constants'; + +/** 千分位整数格式化。 */ +export function formatInteger(value: number) { + return Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 0, + }).format(value); +} + +/** 货币格式化。 */ +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 resolveActivityTypeLabel( + value: FullReductionCardViewModel['activityType'], +) { + return FULL_REDUCTION_ACTIVITY_TEXT_MAP[value]; +} + +/** 第二份折扣文案。 */ +export function resolveSecondHalfDiscountLabel( + value: MarketingFullReductionSecondHalfDiscountType, +) { + return ( + FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS.find( + (item) => item.value === value, + )?.label ?? '5折' + ); +} + +/** 活动规则短标签。 */ +export function resolveRulePills(item: FullReductionCardViewModel) { + if (item.activityType === 'reduce') { + return [...item.reduceTiers] + .toSorted((first, second) => first.meetAmount - second.meetAmount) + .map( + (tier) => + `满${trimDecimal(tier.meetAmount)}减${trimDecimal(tier.reduceAmount)}`, + ); + } + + if (item.activityType === 'gift' && item.giftRule) { + return [ + `买${item.giftRule.buyQuantity}件赠${item.giftRule.giftQuantity}件`, + ]; + } + + const discountType = item.secondHalfRule?.discountType ?? 'half'; + return [`第2份${resolveSecondHalfDiscountLabel(discountType)}`]; +} + +/** 渠道文案。 */ +export function resolveChannelsText( + channels: FullReductionCardViewModel['channels'], +) { + const ordered = ['delivery', 'pickup', 'dine_in'] as const; + const labels = ordered + .filter((channel) => channels.includes(channel)) + .map((channel) => FULL_REDUCTION_CHANNEL_TEXT_MAP[channel]); + + return labels.length > 0 ? labels.join(' / ') : '--'; +} + +/** 门店范围文案。 */ +export function resolveStoreScopeText( + item: FullReductionCardViewModel, + storeNameMap: Record, +) { + if (item.storeScopeMode === 'all') { + return '全部门店'; + } + + const storeNames = item.storeIds + .map((storeId) => storeNameMap[storeId]) + .filter((name) => typeof name === 'string' && name.length > 0); + + if (storeNames.length === 0) { + return '指定门店'; + } + + return storeNames.join(' / '); +} + +/** 商品范围摘要文案。 */ +export function resolveScopeSummary( + scope: FullReductionScopeForm | MarketingFullReductionScopeRuleDto, +) { + if (scope.scopeType === 'all') { + return '全部商品'; + } + + if (scope.scopeType === 'category') { + return `已选 ${scope.categoryIds.length} 个分类`; + } + + return `已选 ${scope.productIds.length} 个商品`; +} + +/** 卡片附加信息文案。 */ +export function resolveExtraMetaText(item: FullReductionCardViewModel) { + if (item.activityType === 'gift' && item.giftRule) { + return item.giftRule.giftScopeType === 'same_lowest' + ? '赠品:同商品(价低者)' + : `赠品:${resolveScopeSummary(item.giftRule.giftScope)}`; + } + + if (item.activityType === 'second_half' && item.secondHalfRule) { + return `适用:${resolveScopeSummary(item.secondHalfRule.applicableScope)}`; + } + + return ''; +} + +/** 卡片数据指标。 */ +export function resolveCardMetrics(item: FullReductionCardViewModel) { + if (item.activityType === 'gift') { + return [ + { + label: '参与订单', + value: `${formatInteger(item.metrics.participatingOrderCount)}单`, + }, + { + label: '赠出商品', + value: `${formatInteger(item.metrics.giftedCount)}件`, + }, + { + label: '带动销售', + value: formatCurrency(item.metrics.drivenSalesAmount), + }, + ]; + } + + if (item.activityType === 'second_half') { + return [ + { + label: '参与订单', + value: `${formatInteger(item.metrics.participatingOrderCount)}单`, + }, + { + label: '优惠总额', + value: formatCurrency(item.metrics.discountTotalAmount), + }, + { + label: '连带率提升', + value: + item.metrics.attachRateIncreasePercent > 0 + ? `${trimDecimal(item.metrics.attachRateIncreasePercent)}%` + : '--', + }, + ]; + } + + return [ + { + label: '参与订单', + value: `${formatInteger(item.metrics.participatingOrderCount)}单`, + }, + { + label: '优惠总额', + value: formatCurrency(item.metrics.discountTotalAmount), + }, + { + label: '客单价提升', + value: + item.metrics.ticketIncreaseAmount > 0 + ? formatCurrency(item.metrics.ticketIncreaseAmount) + : '--', + }, + ]; +} + +/** 详情映射为编辑表单。 */ +export function mapDetailToEditorForm( + detail: MarketingFullReductionDetailDto, +): FullReductionEditorForm { + const form = createDefaultFullReductionEditorForm(); + form.id = detail.id; + form.name = detail.name; + form.activityType = detail.activityType; + form.reduceTiers = + detail.reduceTiers.length > 0 + ? detail.reduceTiers.map((tier) => + createDefaultTierForm(tier.meetAmount, tier.reduceAmount), + ) + : [createDefaultTierForm()]; + form.giftRule = mapGiftRuleToForm(detail.giftRule); + form.secondHalfRule = mapSecondHalfRuleToForm(detail.secondHalfRule); + form.validDateRange = [dayjs(detail.startDate), dayjs(detail.endDate)]; + form.channels = [...detail.channels]; + form.storeScopeMode = detail.storeScopeMode; + form.storeIds = [...detail.storeIds]; + form.scopeStoreId = detail.scopeStoreId; + form.stackWithCoupon = detail.stackWithCoupon; + form.description = detail.description ?? ''; + form.metrics = { ...detail.metrics }; + return form; +} + +/** 编辑表单构建保存请求。 */ +export function buildSaveFullReductionPayload( + form: FullReductionEditorForm, + storeId: string, + scopeStoreId: string, +): SaveMarketingFullReductionDto { + const [start, end] = (form.validDateRange ?? []) as [Dayjs, Dayjs]; + + return { + id: form.id || undefined, + storeId, + scopeStoreId, + name: form.name.trim(), + activityType: form.activityType, + reduceTiers: + form.activityType === 'reduce' + ? form.reduceTiers.map((tier) => ({ + meetAmount: Number(tier.meetAmount || 0), + reduceAmount: Number(tier.reduceAmount || 0), + })) + : [], + giftRule: + form.activityType === 'gift' + ? { + buyQuantity: Math.floor(Number(form.giftRule.buyQuantity || 0)), + giftQuantity: Math.floor(Number(form.giftRule.giftQuantity || 0)), + giftScopeType: form.giftRule.giftScopeType, + applicableScope: normalizeScopeForSave( + form.giftRule.applicableScope, + ), + giftScope: + form.giftRule.giftScopeType === 'specified' + ? normalizeScopeForSave(form.giftRule.giftScope) + : createDefaultScopeForm('all'), + } + : undefined, + secondHalfRule: + form.activityType === 'second_half' + ? { + discountType: form.secondHalfRule.discountType, + applicableScope: normalizeScopeForSave( + form.secondHalfRule.applicableScope, + ), + } + : undefined, + startDate: start.format('YYYY-MM-DD'), + endDate: end.format('YYYY-MM-DD'), + channels: [...form.channels], + storeScopeMode: form.storeScopeMode, + storeIds: form.storeScopeMode === 'stores' ? [...form.storeIds] : undefined, + stackWithCoupon: form.stackWithCoupon, + description: form.description.trim() || undefined, + metrics: { ...form.metrics }, + }; +} + +/** 深拷贝商品范围。 */ +export function cloneScopeForm( + scope: FullReductionScopeForm, +): FullReductionScopeForm { + return { + scopeType: scope.scopeType, + categoryIds: [...scope.categoryIds], + productIds: [...scope.productIds], + }; +} + +/** 校验状态映射。 */ +export function isEndedStatus(status: MarketingFullReductionDisplayStatus) { + return status === 'ended'; +} + +function mapGiftRuleToForm(rule?: MarketingFullReductionGiftRuleDto) { + const form = createDefaultGiftRuleForm(); + if (!rule) { + return form; + } + + form.buyQuantity = rule.buyQuantity; + form.giftQuantity = rule.giftQuantity; + form.giftScopeType = rule.giftScopeType; + form.applicableScope = mapScopeToForm(rule.applicableScope, 'all'); + form.giftScope = + rule.giftScopeType === 'specified' + ? mapScopeToForm(rule.giftScope, 'product') + : createDefaultScopeForm('all'); + return form; +} + +function mapSecondHalfRuleToForm( + rule?: MarketingFullReductionSecondHalfRuleDto, +) { + const form = createDefaultSecondHalfRuleForm(); + if (!rule) { + return form; + } + form.discountType = rule.discountType; + form.applicableScope = mapScopeToForm(rule.applicableScope, 'category'); + return form; +} + +function mapScopeToForm( + scope: MarketingFullReductionScopeRuleDto | undefined, + fallbackScopeType: FullReductionScopeForm['scopeType'], +): FullReductionScopeForm { + if (!scope) { + return createDefaultScopeForm(fallbackScopeType); + } + + return { + scopeType: scope.scopeType, + categoryIds: [...scope.categoryIds], + productIds: [...scope.productIds], + }; +} + +function normalizeScopeForSave(scope: FullReductionScopeForm) { + if (scope.scopeType === 'all') { + return createDefaultScopeForm('all'); + } + + if (scope.scopeType === 'category') { + return { + scopeType: 'category' as const, + categoryIds: [...scope.categoryIds], + productIds: [], + }; + } + + return { + scopeType: 'product' as const, + categoryIds: [], + productIds: [...scope.productIds], + }; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/picker-actions.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/picker-actions.ts new file mode 100644 index 0000000..b84dbc6 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/full-reduction-page/picker-actions.ts @@ -0,0 +1,220 @@ +import type { Ref } from 'vue'; + +import type { + FullReductionPickerCategoryItem, + FullReductionPickerProductItem, + FullReductionPickerTarget, + FullReductionScopeForm, +} from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动二级抽屉(商品范围选择)动作。 + * 1. 管理范围选择抽屉开关、检索和列表加载。 + * 2. 统一分类/商品选择结果回填。 + */ +import { message } from 'ant-design-vue'; + +import { + getProductCategoryListApi, + searchProductPickerApi, +} from '#/api/product'; + +import { cloneScopeForm } from './helpers'; + +interface OpenPickerOptions { + allowAll: boolean; + initialScope: FullReductionScopeForm; + scopeStoreId: string; + target: FullReductionPickerTarget; + title: string; +} + +interface CreatePickerActionsOptions { + isPickerLoading: Ref; + isPickerOpen: Ref; + pickerAllowAll: Ref; + pickerCategories: Ref; + pickerCategoryFilterId: Ref; + pickerKeyword: Ref; + pickerProducts: Ref; + pickerScope: Ref; + pickerTarget: Ref; + pickerTitle: Ref; + resolveOperationStoreId: () => string; +} + +export function createPickerActions(options: CreatePickerActionsOptions) { + let activeScopeStoreId = ''; + let onConfirmScope: ((scope: FullReductionScopeForm) => void) | null = null; + + function setPickerOpen(value: boolean) { + options.isPickerOpen.value = value; + if (!value) { + onConfirmScope = null; + } + } + + function setPickerKeyword(value: string) { + options.pickerKeyword.value = value; + } + + function setPickerCategoryFilterId(value: string) { + options.pickerCategoryFilterId.value = value; + } + + function setPickerScopeType(value: FullReductionScopeForm['scopeType']) { + if (!options.pickerAllowAll.value && value === 'all') { + return; + } + + options.pickerScope.value = { + scopeType: value, + categoryIds: + value === 'category' ? options.pickerScope.value.categoryIds : [], + productIds: + value === 'product' ? options.pickerScope.value.productIds : [], + }; + } + + function setPickerCategoryIds(ids: string[]) { + options.pickerScope.value = { + ...options.pickerScope.value, + categoryIds: [...ids], + }; + } + + function setPickerProductIds(ids: string[]) { + options.pickerScope.value = { + ...options.pickerScope.value, + productIds: [...ids], + }; + } + + async function loadPickerCategories() { + const storeId = activeScopeStoreId || options.resolveOperationStoreId(); + if (!storeId) { + options.pickerCategories.value = []; + return; + } + + options.pickerCategories.value = await getProductCategoryListApi(storeId); + } + + async function loadPickerProducts() { + const storeId = activeScopeStoreId || options.resolveOperationStoreId(); + if (!storeId) { + options.pickerProducts.value = []; + return; + } + + options.pickerProducts.value = await searchProductPickerApi({ + storeId, + keyword: options.pickerKeyword.value.trim() || undefined, + categoryId: options.pickerCategoryFilterId.value || undefined, + limit: 500, + }); + } + + async function reloadPickerList() { + if (!options.isPickerOpen.value) { + return; + } + + options.isPickerLoading.value = true; + try { + await loadPickerCategories(); + if (options.pickerScope.value.scopeType === 'product') { + await loadPickerProducts(); + } else { + options.pickerProducts.value = []; + } + } catch (error) { + console.error(error); + options.pickerCategories.value = []; + options.pickerProducts.value = []; + message.error('加载可选范围失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function searchPickerProducts() { + if (options.pickerScope.value.scopeType !== 'product') { + return; + } + + options.isPickerLoading.value = true; + try { + await loadPickerProducts(); + } catch (error) { + console.error(error); + options.pickerProducts.value = []; + message.error('加载可选商品失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function openPicker( + params: OpenPickerOptions, + onConfirm: (scope: FullReductionScopeForm) => void, + ) { + const resolvedStoreId = + params.scopeStoreId || options.resolveOperationStoreId(); + if (!resolvedStoreId) { + message.warning('请先选择门店后再配置商品范围'); + return; + } + + activeScopeStoreId = resolvedStoreId; + onConfirmScope = onConfirm; + options.pickerTitle.value = params.title; + options.pickerTarget.value = params.target; + options.pickerAllowAll.value = params.allowAll; + options.pickerKeyword.value = ''; + options.pickerCategoryFilterId.value = ''; + + const scope = cloneScopeForm(params.initialScope); + if (!params.allowAll && scope.scopeType === 'all') { + scope.scopeType = 'category'; + } + options.pickerScope.value = scope; + options.isPickerOpen.value = true; + + await reloadPickerList(); + } + + function submitPicker() { + const scope = options.pickerScope.value; + if (!options.pickerAllowAll.value && scope.scopeType === 'all') { + message.warning('当前范围不支持全部商品,请选择分类或商品'); + return; + } + + if (scope.scopeType === 'category' && scope.categoryIds.length === 0) { + message.warning('请至少选择一个分类'); + return; + } + + if (scope.scopeType === 'product' && scope.productIds.length === 0) { + message.warning('请至少选择一个商品'); + return; + } + + onConfirmScope?.(cloneScopeForm(scope)); + setPickerOpen(false); + } + + return { + openPicker, + reloadPickerList, + searchPickerProducts, + setPickerCategoryFilterId, + setPickerCategoryIds, + setPickerKeyword, + setPickerOpen, + setPickerProductIds, + setPickerScopeType, + submitPicker, + }; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/composables/useMarketingFullReductionPage.ts b/apps/web-antd/src/views/marketing/full-reduction/composables/useMarketingFullReductionPage.ts new file mode 100644 index 0000000..373bac0 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/composables/useMarketingFullReductionPage.ts @@ -0,0 +1,337 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { + FullReductionCardViewModel, + FullReductionPickerCategoryItem, + FullReductionPickerProductItem, + FullReductionPickerTarget, + FullReductionScopeForm, + FullReductionStatsViewModel, +} from '#/views/marketing/full-reduction/types'; + +/** + * 文件职责:满减活动页面状态与行为编排。 + * 1. 管理门店、筛选、分页、统计与加载状态。 + * 2. 编排主抽屉编辑与二级范围抽屉选择。 + * 3. 封装卡片停用/删除动作。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { createCardActions } from './full-reduction-page/card-actions'; +import { + createDefaultFullReductionEditorForm, + createDefaultFullReductionFilterForm, + createDefaultScopeForm, + FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS, + FULL_REDUCTION_STATUS_FILTER_OPTIONS, +} from './full-reduction-page/constants'; +import { + createDataActions, + createEmptyStats, +} from './full-reduction-page/data-actions'; +import { createDrawerActions } from './full-reduction-page/drawer-actions'; +import { createPickerActions } from './full-reduction-page/picker-actions'; + +export function useMarketingFullReductionPage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const filterForm = reactive(createDefaultFullReductionFilterForm()); + 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(createDefaultFullReductionEditorForm()); + + const isPickerOpen = ref(false); + const isPickerLoading = ref(false); + const pickerTitle = ref('选择商品范围'); + const pickerTarget = ref('giftApplicable'); + const pickerAllowAll = ref(true); + const pickerScope = ref( + createDefaultScopeForm('all'), + ); + const pickerKeyword = ref(''); + const pickerCategoryFilterId = ref(''); + const pickerCategories = ref([]); + const pickerProducts = ref([]); + + const storeOptions = computed(() => [ + { label: '全部门店', value: '' }, + ...stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ]); + const scopedStoreOptions = computed(() => + 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, + searchPickerProducts, + setPickerCategoryFilterId, + setPickerCategoryIds, + setPickerKeyword, + setPickerOpen, + setPickerProductIds, + setPickerScopeType, + submitPicker, + } = createPickerActions({ + isPickerLoading, + isPickerOpen, + pickerAllowAll, + pickerCategories, + pickerCategoryFilterId, + pickerKeyword, + pickerProducts, + pickerScope, + pickerTarget, + pickerTitle, + resolveOperationStoreId, + }); + + const { + addReduceTier, + drawerMode, + openCreateDrawer, + openEditDrawer, + openGiftApplicablePicker, + openGiftScopePicker, + openSecondHalfApplicablePicker, + removeReduceTier, + setDrawerOpen, + setFormActivityType, + setFormChannels, + setFormDescription, + setFormName, + setFormStackWithCoupon, + setFormStoreIds, + setFormStoreScopeMode, + setFormValidDateRange, + setGiftBuyQuantity, + setGiftGiftQuantity, + setGiftScopeType, + setReduceTierMeetAmount, + setReduceTierReduceAmount, + setSecondHalfDiscountType, + submitDrawer, + } = createDrawerActions({ + form, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + loadActivities, + openPicker, + resolveOperationStoreId, + selectedStoreId, + stores, + }); + + const { disableActivity, removeActivity } = 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: '' | FullReductionCardViewModel['displayStatus'], + ) { + filterForm.status = value; + } + + function setActivityTypeFilter( + value: '' | FullReductionCardViewModel['activityType'], + ) { + filterForm.activityType = value; + } + + async function applyFilters() { + page.value = 1; + await loadActivities(); + } + + async function resetFilters() { + filterForm.activityType = ''; + 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 handlePickerScopeTypeChange( + value: FullReductionScopeForm['scopeType'], + ) { + setPickerScopeType(value); + if (value === 'product') { + await searchPickerProducts(); + return; + } + await reloadPickerList(); + } + + async function handlePickerCategoryFilterChange(value: string) { + setPickerCategoryFilterId(value); + await searchPickerProducts(); + } + + async function handlePickerSearch() { + await searchPickerProducts(); + } + + watch(selectedStoreId, () => { + page.value = 1; + filterForm.activityType = ''; + filterForm.status = ''; + keyword.value = ''; + void loadActivities(); + }); + + onMounted(async () => { + await loadStores(); + await loadActivities(); + }); + + return { + addReduceTier, + applyFilters, + disableActivity, + drawerSubmitText, + drawerTitle, + filterForm, + form, + FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS, + FULL_REDUCTION_STATUS_FILTER_OPTIONS, + handlePageChange, + handlePickerCategoryFilterChange, + handlePickerScopeTypeChange, + handlePickerSearch, + hasStore, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + isLoading, + isPickerLoading, + isPickerOpen, + isStoreLoading, + keyword, + openCreateDrawer, + openEditDrawer, + openGiftApplicablePicker, + openGiftScopePicker, + openSecondHalfApplicablePicker, + page, + pageSize, + pickerAllowAll, + pickerCategories, + pickerCategoryFilterId, + pickerKeyword, + pickerProducts, + pickerScope, + pickerTarget, + pickerTitle, + removeActivity, + removeReduceTier, + resetFilters, + rows, + scopedStoreOptions, + selectedStoreId, + setDrawerOpen, + setFormActivityType, + setFormChannels, + setFormDescription, + setFormName, + setFormStackWithCoupon, + setFormStoreIds, + setFormStoreScopeMode, + setFormValidDateRange, + setGiftBuyQuantity, + setGiftGiftQuantity, + setGiftScopeType, + setKeyword: setKeywordValue, + setPickerCategoryIds, + setPickerKeyword, + setPickerOpen, + setPickerProductIds, + setReduceTierMeetAmount, + setReduceTierReduceAmount, + setSecondHalfDiscountType, + setSelectedStoreId, + setStatusFilter, + setTypeFilter: setActivityTypeFilter, + stats, + storeNameMap, + storeOptions, + stores, + submitDrawer, + submitPicker, + total, + }; +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/index.vue b/apps/web-antd/src/views/marketing/full-reduction/index.vue new file mode 100644 index 0000000..c90978c --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/index.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/full-reduction/styles/base.less b/apps/web-antd/src/views/marketing/full-reduction/styles/base.less new file mode 100644 index 0000000..6b2370e --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/styles/base.less @@ -0,0 +1,29 @@ +/** + * 文件职责:满减活动页面基础样式变量。 + */ +.page-marketing-full-reduction { + --mfr-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --mfr-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --mfr-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%); + --mfr-border: #e7eaf0; + --mfr-text: #1f2937; + --mfr-subtext: #6b7280; + --mfr-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/full-reduction/styles/card.less b/apps/web-antd/src/views/marketing/full-reduction/styles/card.less new file mode 100644 index 0000000..62199fe --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/styles/card.less @@ -0,0 +1,148 @@ +/** + * 文件职责:满减活动卡片样式。 + * 1. 对齐原型卡片结构、规则胶囊与状态标签。 + */ +.page-marketing-full-reduction { + .mfr-card { + padding: 18px 22px 14px; + background: #fff; + border: 1px solid var(--mfr-border); + border-radius: 12px; + box-shadow: var(--mfr-shadow-sm); + transition: box-shadow var(--mfr-transition); + } + + .mfr-card:hover { + box-shadow: var(--mfr-shadow-md); + } + + .mfr-card.mfr-dimmed { + opacity: 0.56; + } + + .mfr-card-head { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 12px; + } + + .mfr-card-name { + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + font-weight: 600; + color: var(--mfr-text); + white-space: nowrap; + } + + .mfr-type-tag, + .mfr-status-tag { + display: inline-flex; + flex-shrink: 0; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 11px; + border-radius: 999px; + } + + .mfr-type-reduce { + color: #1677ff; + background: #e6f4ff; + } + + .mfr-type-gift { + color: #16a34a; + background: #dcfce7; + } + + .mfr-type-second-half { + color: #d97706; + background: #fef3c7; + } + + .mfr-status-ongoing { + color: #166534; + background: #dcfce7; + } + + .mfr-status-upcoming { + color: #1d4ed8; + background: #dbeafe; + } + + .mfr-status-ended { + color: #475569; + background: #e2e8f0; + } + + .mfr-tiers { + display: flex; + flex-wrap: wrap; + gap: 0; + align-items: center; + margin-bottom: 12px; + } + + .mfr-tier-pill { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + font-size: 13px; + color: #1677ff; + background: #f2f8ff; + border: 1px solid #cfe4ff; + border-radius: 999px; + } + + .mfr-tier-arrow { + display: inline-flex; + padding: 0 6px; + color: #c0c6cf; + } + + .mfr-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin-bottom: 10px; + font-size: 13px; + color: var(--mfr-subtext); + } + + .mfr-meta-item { + display: inline-flex; + gap: 5px; + align-items: center; + } + + .mfr-meta-item .iconify { + width: 14px; + height: 14px; + color: var(--mfr-muted); + } + + .mfr-data { + display: flex; + gap: 22px; + align-items: center; + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; + color: var(--mfr-subtext); + background: #f8f9fb; + border-radius: 8px; + } + + .mfr-data-item strong { + font-weight: 600; + color: var(--mfr-text); + } + + .mfr-card-actions { + padding-top: 11px; + border-top: 1px solid #f3f4f6; + } +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/styles/drawer.less b/apps/web-antd/src/views/marketing/full-reduction/styles/drawer.less new file mode 100644 index 0000000..2860c0d --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/styles/drawer.less @@ -0,0 +1,233 @@ +/** + * 文件职责:满减活动主编辑抽屉样式。 + */ +.mfr-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; + } + + .mfr-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-wrap { + height: 100%; + } + + .ant-input-number-input { + height: 32px; + } + + .ant-picker { + height: 34px; + padding: 0 10px; + } + + .ant-input-number:focus-within, + .ant-picker-focused, + .ant-select-focused .ant-select-selector, + .ant-input:focus { + border-color: #1677ff !important; + box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important; + } + + .mfr-section-title { + padding-left: 10px; + margin: 2px 0 12px; + font-size: 14px; + font-weight: 600; + color: #1f2937; + border-left: 3px solid #1677ff; + } + + .mfr-tier-item { + margin-bottom: 10px; + } + + .mfr-inline-fields { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + font-size: 13px; + color: #4b5563; + } + + .mfr-inline-fields .ant-input-number { + width: 90px; + } + + .mfr-tier-delete { + padding: 0 6px; + font-size: 12px; + color: #6b7280; + cursor: pointer; + background: none; + border: none; + border-radius: 6px; + transition: all 0.2s; + } + + .mfr-tier-delete:hover { + color: #ef4444; + background: #fef2f2; + } + + .mfr-tier-delete:disabled { + color: #c4c7cf; + cursor: not-allowed; + background: transparent; + } + + .mfr-add-tier { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 12px; + font-size: 12px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px dashed #d0d5dd; + border-radius: 6px; + transition: all 0.2s; + } + + .mfr-add-tier:hover { + color: #1677ff; + background: #f2f8ff; + border-color: #91caff; + } + + .mfr-scope-line { + display: flex; + gap: 10px; + align-items: center; + } + + .mfr-scope-text { + font-size: 12px; + color: #6b7280; + } + + .mfr-section-divider { + height: 1px; + margin: 18px 0; + background: linear-gradient( + 90deg, + rgb(229 231 235 / 0%) 0%, + rgb(229 231 235 / 100%) 18%, + rgb(229 231 235 / 100%) 82%, + rgb(229 231 235 / 0%) 100% + ); + } + + .mfr-channel-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .mfr-channel-pill { + height: 30px; + padding: 0 14px; + font-size: 12px; + line-height: 28px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + transition: all 0.2s; + } + + .mfr-channel-pill:hover { + color: #1677ff; + border-color: #91caff; + } + + .mfr-channel-pill.checked { + color: #1677ff; + background: #e8f3ff; + border-color: #91caff; + } + + .mfr-range-picker { + width: 100%; + max-width: 360px; + } + + .mfr-store-multi { + width: 100%; + margin-top: 8px; + } + + .mfr-store-multi .ant-select-selector { + min-height: 34px !important; + padding: 1px 8px !important; + } + + .mfr-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-start; + } + + .mfr-drawer-footer .ant-btn { + min-width: 64px; + height: 32px; + border-radius: 6px; + } +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/styles/index.less b/apps/web-antd/src/views/marketing/full-reduction/styles/index.less new file mode 100644 index 0000000..0cf68d3 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/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/full-reduction/styles/layout.less b/apps/web-antd/src/views/marketing/full-reduction/styles/layout.less new file mode 100644 index 0000000..fd8d835 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/styles/layout.less @@ -0,0 +1,151 @@ +/** + * 文件职责:满减活动页面布局样式。 + * 1. 工具栏、统计、列表与分页布局。 + */ +.page-marketing-full-reduction { + .mfr-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mfr-toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mfr-border); + border-radius: 10px; + box-shadow: var(--mfr-shadow-sm); + } + + .mfr-store-select { + width: 220px; + } + + .mfr-filter-select { + width: 140px; + } + + .mfr-search { + width: 200px; + } + + .mfr-store-select .ant-select-selector, + .mfr-filter-select .ant-select-selector { + border-radius: 8px !important; + } + + .mfr-spacer { + flex: 1; + } + + .mfr-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .mfr-stat-card { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mfr-border); + border-radius: 10px; + box-shadow: var(--mfr-shadow-sm); + transition: box-shadow var(--mfr-transition); + } + + .mfr-stat-card:hover { + box-shadow: var(--mfr-shadow-md); + } + + .mfr-stat-main { + min-width: 0; + } + + .mfr-stat-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 18px; + border-radius: 8px; + } + + .mfr-stat-blue { + color: #1677ff; + background: #e6f4ff; + } + + .mfr-stat-green { + color: #52c41a; + background: #f6ffed; + } + + .mfr-stat-orange { + color: #fa8c16; + background: #fff7e6; + } + + .mfr-stat-cyan { + color: #0891b2; + background: #ecfeff; + } + + .mfr-stat-value { + overflow: hidden; + text-overflow: ellipsis; + font-size: 22px; + font-weight: 700; + line-height: 1.1; + color: var(--mfr-text); + white-space: nowrap; + } + + .mfr-stat-value-green { + color: #16a34a; + } + + .mfr-stat-value-orange { + color: #d97706; + } + + .mfr-stat-label { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: var(--mfr-muted); + white-space: nowrap; + } + + .mfr-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .mfr-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px solid var(--mfr-border); + border-radius: 10px; + box-shadow: var(--mfr-shadow-sm); + } + + .mfr-pagination { + display: flex; + justify-content: flex-end; + padding: 12px 4px 2px; + margin-top: 12px; + } +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/styles/picker.less b/apps/web-antd/src/views/marketing/full-reduction/styles/picker.less new file mode 100644 index 0000000..98ff392 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/styles/picker.less @@ -0,0 +1,193 @@ +/** + * 文件职责:满减活动二级范围抽屉样式。 + */ +.mfr-picker-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: 0; + } + + .ant-drawer-footer { + padding: 10px 16px; + border-top: 1px solid #f0f0f0; + } + + .mfr-picker-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + } + + .mfr-picker-toolbar .ant-radio-group { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + } + + .mfr-picker-toolbar .ant-radio-button-wrapper { + height: 30px; + padding: 0 12px; + font-size: 12px; + line-height: 28px; + color: #4b5563; + border: 1px solid #d9d9d9; + border-radius: 6px !important; + } + + .mfr-picker-toolbar .ant-radio-button-wrapper::before { + display: none !important; + } + + .mfr-picker-toolbar + .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + color: #1677ff; + background: #e8f3ff; + border-color: #91caff; + box-shadow: none; + } + + .mfr-picker-search { + flex: 1; + min-width: 180px; + max-width: 260px; + } + + .mfr-picker-category { + width: 150px; + } + + .mfr-picker-count { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #1677ff; + } + + .mfr-picker-all { + padding: 24px 16px; + font-size: 13px; + color: #6b7280; + text-align: center; + background: #fafafa; + } + + .mfr-picker-body { + max-height: 60vh; + overflow: auto; + } + + .mfr-picker-empty { + padding: 36px 0; + } + + .mfr-picker-table { + width: 100%; + font-size: 13px; + border-collapse: collapse; + } + + .mfr-picker-table th { + position: sticky; + top: 0; + z-index: 2; + padding: 10px 14px; + font-size: 12px; + font-weight: 500; + color: #6b7280; + text-align: left; + background: #f8f9fb; + } + + .mfr-picker-table td { + padding: 10px 14px; + color: #1f2937; + border-bottom: 1px solid #f3f4f6; + } + + .mfr-picker-table tbody tr { + cursor: pointer; + } + + .mfr-picker-table tbody tr:hover td { + background: #f6faff; + } + + .mfr-picker-table tbody tr.checked td { + background: #e8f3ff; + } + + .mfr-picker-col-check { + width: 44px; + } + + .mfr-picker-col-count, + .mfr-picker-col-price, + .mfr-picker-col-status { + white-space: nowrap; + } + + .mfr-picker-product-name { + font-weight: 500; + } + + .mfr-picker-product-spu { + margin-top: 2px; + font-size: 12px; + color: #9ca3af; + } + + .mfr-prod-status { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 12px; + border-radius: 999px; + } + + .mfr-prod-status-on { + color: #166534; + background: #dcfce7; + } + + .mfr-prod-status-soldout { + color: #92400e; + background: #fef3c7; + } + + .mfr-prod-status-off { + color: #475569; + background: #e2e8f0; + } + + .mfr-picker-footer { + display: flex; + align-items: center; + justify-content: space-between; + } + + .mfr-picker-footer-info { + font-size: 12px; + color: #6b7280; + } + + .mfr-picker-footer-actions { + display: inline-flex; + gap: 8px; + align-items: center; + } +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/styles/responsive.less b/apps/web-antd/src/views/marketing/full-reduction/styles/responsive.less new file mode 100644 index 0000000..7d84cd1 --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/styles/responsive.less @@ -0,0 +1,42 @@ +/** + * 文件职责:满减活动页面响应式样式。 + */ +.page-marketing-full-reduction { + @media (width <= 1200px) { + .mfr-toolbar { + flex-wrap: wrap; + } + + .mfr-spacer { + display: none; + } + + .mfr-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (width <= 768px) { + .mfr-stats { + grid-template-columns: 1fr; + } + + .mfr-card { + padding: 14px; + } + + .mfr-card-head { + flex-wrap: wrap; + } + + .mfr-data { + flex-direction: column; + gap: 6px; + align-items: flex-start; + } + + .mfr-meta { + gap: 8px 12px; + } + } +} diff --git a/apps/web-antd/src/views/marketing/full-reduction/types.ts b/apps/web-antd/src/views/marketing/full-reduction/types.ts new file mode 100644 index 0000000..8d1501c --- /dev/null +++ b/apps/web-antd/src/views/marketing/full-reduction/types.ts @@ -0,0 +1,92 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingFullReductionActivityType, + MarketingFullReductionChannel, + MarketingFullReductionDisplayStatus, + MarketingFullReductionGiftScopeType, + MarketingFullReductionListItemDto, + MarketingFullReductionMetricsDto, + MarketingFullReductionScopeType, + MarketingFullReductionSecondHalfDiscountType, + MarketingFullReductionStatsDto, + MarketingFullReductionStoreScopeMode, +} from '#/api/marketing'; +import type { ProductCategoryDto, ProductPickerItemDto } from '#/api/product'; + +/** + * 文件职责:满减活动页面类型定义。 + */ + +/** 列表筛选表单。 */ +export interface FullReductionFilterForm { + activityType: '' | MarketingFullReductionActivityType; + status: '' | MarketingFullReductionDisplayStatus; +} + +/** 商品范围表单。 */ +export interface FullReductionScopeForm { + categoryIds: string[]; + productIds: string[]; + scopeType: MarketingFullReductionScopeType; +} + +/** 满减阶梯表单。 */ +export interface FullReductionTierForm { + meetAmount: null | number; + reduceAmount: null | number; +} + +/** 满赠规则表单。 */ +export interface FullReductionGiftRuleForm { + applicableScope: FullReductionScopeForm; + buyQuantity: null | number; + giftQuantity: null | number; + giftScope: FullReductionScopeForm; + giftScopeType: MarketingFullReductionGiftScopeType; +} + +/** 第二份半价规则表单。 */ +export interface FullReductionSecondHalfRuleForm { + applicableScope: FullReductionScopeForm; + discountType: MarketingFullReductionSecondHalfDiscountType; +} + +/** 满减编辑抽屉表单。 */ +export interface FullReductionEditorForm { + activityType: MarketingFullReductionActivityType; + channels: MarketingFullReductionChannel[]; + description: string; + id: string; + giftRule: FullReductionGiftRuleForm; + metrics: MarketingFullReductionMetricsDto; + name: string; + reduceTiers: FullReductionTierForm[]; + scopeStoreId: string; + secondHalfRule: FullReductionSecondHalfRuleForm; + stackWithCoupon: boolean; + storeIds: string[]; + storeScopeMode: MarketingFullReductionStoreScopeMode; + validDateRange: [Dayjs, Dayjs] | null; +} + +/** 列表卡片视图模型。 */ +export type FullReductionCardViewModel = MarketingFullReductionListItemDto; + +/** 统计视图模型。 */ +export type FullReductionStatsViewModel = MarketingFullReductionStatsDto; + +/** 二级抽屉选择目标。 */ +export type FullReductionPickerTarget = + | 'giftApplicable' + | 'giftScope' + | 'secondHalfApplicable'; + +/** 二级抽屉选择模式。 */ +export type FullReductionPickerMode = 'category' | 'product'; + +/** 二级抽屉分类项。 */ +export type FullReductionPickerCategoryItem = ProductCategoryDto; + +/** 二级抽屉商品项。 */ +export type FullReductionPickerProductItem = ProductPickerItemDto;