From be0a8e6914b4dd2e0871d825d60a4914f754ce55 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 2 Mar 2026 21:43:47 +0800 Subject: [PATCH] feat: implement marketing punch card management page --- apps/web-antd/src/api/marketing/index.ts | 1 + apps/web-antd/src/api/marketing/punch-card.ts | 334 ++++++++++++ .../components/PunchCardEditorDrawer.vue | 511 ++++++++++++++++++ .../PunchCardProductPickerDrawer.vue | 190 +++++++ .../components/PunchCardStatsCards.vue | 62 +++ .../components/PunchCardTemplateCard.vue | 139 +++++ .../components/PunchCardUsageRecordTable.vue | 122 +++++ .../components/PunchCardUsageStatsCards.vue | 48 ++ .../punch-card-page/card-actions.ts | 96 ++++ .../composables/punch-card-page/constants.ts | 184 +++++++ .../punch-card-page/data-actions.ts | 186 +++++++ .../punch-card-page/drawer-actions.ts | 333 ++++++++++++ .../composables/punch-card-page/helpers.ts | 242 +++++++++ .../punch-card-page/picker-actions.ts | 160 ++++++ .../composables/useMarketingPunchCardPage.ts | 470 ++++++++++++++++ .../src/views/marketing/punch-card/index.vue | 363 +++++++++++++ .../marketing/punch-card/styles/base.less | 29 + .../marketing/punch-card/styles/card.less | 196 +++++++ .../marketing/punch-card/styles/drawer.less | 328 +++++++++++ .../marketing/punch-card/styles/index.less | 6 + .../marketing/punch-card/styles/layout.less | 199 +++++++ .../punch-card/styles/responsive.less | 50 ++ .../marketing/punch-card/styles/table.less | 56 ++ .../src/views/marketing/punch-card/types.ts | 145 +++++ 24 files changed, 4450 insertions(+) create mode 100644 apps/web-antd/src/api/marketing/punch-card.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/components/PunchCardEditorDrawer.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/components/PunchCardProductPickerDrawer.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/components/PunchCardStatsCards.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/components/PunchCardTemplateCard.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageRecordTable.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageStatsCards.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/card-actions.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/constants.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/data-actions.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/helpers.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/picker-actions.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/composables/useMarketingPunchCardPage.ts create mode 100644 apps/web-antd/src/views/marketing/punch-card/index.vue create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/base.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/card.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/drawer.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/index.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/layout.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/responsive.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/styles/table.less create mode 100644 apps/web-antd/src/views/marketing/punch-card/types.ts diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts index 5cccbab..1f468f6 100644 --- a/apps/web-antd/src/api/marketing/index.ts +++ b/apps/web-antd/src/api/marketing/index.ts @@ -186,4 +186,5 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { export * from './flash-sale'; export * from './full-reduction'; export * from './new-customer'; +export * from './punch-card'; export * from './seckill'; diff --git a/apps/web-antd/src/api/marketing/punch-card.ts b/apps/web-antd/src/api/marketing/punch-card.ts new file mode 100644 index 0000000..259c1cf --- /dev/null +++ b/apps/web-antd/src/api/marketing/punch-card.ts @@ -0,0 +1,334 @@ +/** + * 文件职责:营销中心次卡管理 API 与 DTO 定义。 + * 1. 维护次卡列表、详情、保存、状态、删除、使用记录及导出契约。 + */ +import { requestClient } from '#/api/request'; + +/** 次卡状态。 */ +export type MarketingPunchCardStatus = 'disabled' | 'enabled'; + +/** 有效期类型。 */ +export type MarketingPunchCardValidityType = 'days' | 'range'; + +/** 适用范围类型。 */ +export type MarketingPunchCardScopeType = + | 'all' + | 'category' + | 'product' + | 'tag'; + +/** 使用模式。 */ +export type MarketingPunchCardUsageMode = 'cap' | 'free'; + +/** 过期策略。 */ +export type MarketingPunchCardExpireStrategy = 'invalidate' | 'refund'; + +/** 使用记录展示状态。 */ +export type MarketingPunchCardUsageDisplayStatus = + | 'almost_used_up' + | 'expired' + | 'normal' + | 'used_up'; + +/** 使用记录筛选状态。 */ +export type MarketingPunchCardUsageFilterStatus = + | 'expired' + | 'normal' + | 'used_up'; + +/** 次卡范围。 */ +export interface MarketingPunchCardScopeDto { + categoryIds: string[]; + productIds: string[]; + scopeType: MarketingPunchCardScopeType; + tagIds: string[]; +} + +/** 次卡模板统计。 */ +export interface MarketingPunchCardStatsDto { + activeInUseCount: number; + onSaleCount: number; + totalRevenueAmount: number; + totalSoldCount: number; +} + +/** 次卡列表项。 */ +export interface MarketingPunchCardListItemDto { + activeCount: number; + coverImageUrl?: string; + dailyLimit: null | number; + id: string; + isDimmed: boolean; + name: string; + originalPrice: null | number; + revenueAmount: number; + salePrice: number; + scopeType: MarketingPunchCardScopeType; + soldCount: number; + status: MarketingPunchCardStatus; + totalTimes: number; + updatedAt: string; + usageCapAmount: null | number; + usageMode: MarketingPunchCardUsageMode; + validitySummary: string; +} + +/** 次卡列表结果。 */ +export interface MarketingPunchCardListResultDto { + items: MarketingPunchCardListItemDto[]; + page: number; + pageSize: number; + stats: MarketingPunchCardStatsDto; + totalCount: number; +} + +/** 次卡详情。 */ +export interface MarketingPunchCardDetailDto { + activeCount: number; + allowTransfer: boolean; + coverImageUrl?: string; + dailyLimit: null | number; + description?: string; + expireStrategy: MarketingPunchCardExpireStrategy; + id: string; + name: string; + notifyChannels: string[]; + originalPrice: null | number; + perOrderLimit: null | number; + perUserPurchaseLimit: null | number; + revenueAmount: number; + salePrice: number; + scope: MarketingPunchCardScopeDto; + soldCount: number; + status: MarketingPunchCardStatus; + storeId: string; + totalTimes: number; + updatedAt: string; + usageCapAmount: null | number; + usageMode: MarketingPunchCardUsageMode; + validityDays: null | number; + validityType: MarketingPunchCardValidityType; + validFrom: null | string; + validTo: null | string; +} + +/** 保存次卡请求。 */ +export interface SaveMarketingPunchCardDto { + allowTransfer: boolean; + coverImageUrl?: string; + dailyLimit: null | number; + description?: string; + expireStrategy: MarketingPunchCardExpireStrategy; + id?: string; + name: string; + notifyChannels: string[]; + originalPrice: null | number; + perOrderLimit: null | number; + perUserPurchaseLimit: null | number; + salePrice: number; + scopeCategoryIds: string[]; + scopeProductIds: string[]; + scopeTagIds: string[]; + scopeType: MarketingPunchCardScopeType; + storeId: string; + totalTimes: number; + usageCapAmount: null | number; + usageMode: MarketingPunchCardUsageMode; + validityDays: null | number; + validityType: MarketingPunchCardValidityType; + validFrom: null | string; + validTo: null | string; +} + +/** 修改状态请求。 */ +export interface ChangeMarketingPunchCardStatusDto { + punchCardId: string; + status: MarketingPunchCardStatus; + storeId: string; +} + +/** 删除次卡请求。 */ +export interface DeleteMarketingPunchCardDto { + punchCardId: string; + storeId: string; +} + +/** 次卡列表查询。 */ +export interface MarketingPunchCardListQuery { + keyword?: string; + page: number; + pageSize: number; + status?: '' | MarketingPunchCardStatus; + storeId: string; +} + +/** 次卡详情查询。 */ +export interface MarketingPunchCardDetailQuery { + punchCardId: string; + storeId: string; +} + +/** 使用记录统计。 */ +export interface MarketingPunchCardUsageStatsDto { + expiringSoonCount: number; + monthUsedCount: number; + todayUsedCount: number; +} + +/** 次卡选项。 */ +export interface MarketingPunchCardTemplateOptionDto { + name: string; + templateId: string; +} + +/** 使用记录项。 */ +export interface MarketingPunchCardUsageRecordDto { + displayStatus: MarketingPunchCardUsageDisplayStatus; + extraPayAmount: null | number; + id: string; + memberName: string; + memberPhoneMasked: string; + productName: string; + punchCardId: string; + punchCardInstanceId: string; + punchCardName: string; + recordNo: string; + remainingTimesAfterUse: number; + totalTimes: number; + usedAt: string; + usedTimes: number; +} + +/** 使用记录列表结果。 */ +export interface MarketingPunchCardUsageRecordListResultDto { + items: MarketingPunchCardUsageRecordDto[]; + page: number; + pageSize: number; + stats: MarketingPunchCardUsageStatsDto; + templateOptions: MarketingPunchCardTemplateOptionDto[]; + totalCount: number; +} + +/** 使用记录查询参数。 */ +export interface MarketingPunchCardUsageRecordListQuery { + keyword?: string; + page: number; + pageSize: number; + punchCardId?: string; + status?: '' | MarketingPunchCardUsageFilterStatus; + storeId: string; +} + +/** 使用记录导出参数。 */ +export interface ExportMarketingPunchCardUsageRecordQuery { + keyword?: string; + punchCardId?: string; + status?: '' | MarketingPunchCardUsageFilterStatus; + storeId: string; +} + +/** 使用记录导出回执。 */ +export interface MarketingPunchCardUsageRecordExportDto { + fileContentBase64: string; + fileName: string; + totalCount: number; +} + +/** 写入使用记录请求。 */ +export interface WriteMarketingPunchCardUsageRecordDto { + extraPayAmount: null | number; + memberName?: string; + memberPhoneMasked?: string; + productName: string; + punchCardId: string; + punchCardInstanceId?: string; + punchCardInstanceNo?: string; + storeId: string; + usedAt?: string; + usedTimes: number; +} + +/** 查询次卡列表。 */ +export async function getMarketingPunchCardListApi( + params: MarketingPunchCardListQuery, +) { + return requestClient.get( + '/marketing/punch-card/list', + { + params, + }, + ); +} + +/** 查询次卡详情。 */ +export async function getMarketingPunchCardDetailApi( + params: MarketingPunchCardDetailQuery, +) { + return requestClient.get( + '/marketing/punch-card/detail', + { + params, + }, + ); +} + +/** 保存次卡。 */ +export async function saveMarketingPunchCardApi( + data: SaveMarketingPunchCardDto, +) { + return requestClient.post( + '/marketing/punch-card/save', + data, + ); +} + +/** 修改次卡状态。 */ +export async function changeMarketingPunchCardStatusApi( + data: ChangeMarketingPunchCardStatusDto, +) { + return requestClient.post( + '/marketing/punch-card/status', + data, + ); +} + +/** 删除次卡。 */ +export async function deleteMarketingPunchCardApi( + data: DeleteMarketingPunchCardDto, +) { + return requestClient.post('/marketing/punch-card/delete', data); +} + +/** 查询使用记录。 */ +export async function getMarketingPunchCardUsageRecordListApi( + params: MarketingPunchCardUsageRecordListQuery, +) { + return requestClient.get( + '/marketing/punch-card/usage-record/list', + { + params, + }, + ); +} + +/** 导出使用记录。 */ +export async function exportMarketingPunchCardUsageRecordApi( + params: ExportMarketingPunchCardUsageRecordQuery, +) { + return requestClient.get( + '/marketing/punch-card/usage-record/export', + { + params, + }, + ); +} + +/** 写入使用记录。 */ +export async function writeMarketingPunchCardUsageRecordApi( + data: WriteMarketingPunchCardUsageRecordDto, +) { + return requestClient.post( + '/marketing/punch-card/usage-record/write', + data, + ); +} diff --git a/apps/web-antd/src/views/marketing/punch-card/components/PunchCardEditorDrawer.vue b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardEditorDrawer.vue new file mode 100644 index 0000000..4c06d8d --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardEditorDrawer.vue @@ -0,0 +1,511 @@ + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/components/PunchCardProductPickerDrawer.vue b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardProductPickerDrawer.vue new file mode 100644 index 0000000..9bf1ff0 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardProductPickerDrawer.vue @@ -0,0 +1,190 @@ + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/components/PunchCardStatsCards.vue b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardStatsCards.vue new file mode 100644 index 0000000..5b2c96b --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardStatsCards.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/components/PunchCardTemplateCard.vue b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardTemplateCard.vue new file mode 100644 index 0000000..527feb9 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardTemplateCard.vue @@ -0,0 +1,139 @@ + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageRecordTable.vue b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageRecordTable.vue new file mode 100644 index 0000000..aaa6e52 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageRecordTable.vue @@ -0,0 +1,122 @@ + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageStatsCards.vue b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageStatsCards.vue new file mode 100644 index 0000000..404a015 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/components/PunchCardUsageStatsCards.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/card-actions.ts b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/card-actions.ts new file mode 100644 index 0000000..309931b --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/card-actions.ts @@ -0,0 +1,96 @@ +import type { Ref } from 'vue'; + +import type { PunchCardTemplateCardViewModel } from '#/views/marketing/punch-card/types'; + +/** + * 文件职责:次卡卡片动作(上下架/删除)。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeMarketingPunchCardStatusApi, + deleteMarketingPunchCardApi, +} from '#/api/marketing'; + +interface CreateCardActionsOptions { + canManage: Ref; + loadPunchCardList: () => Promise; + loadUsageRecords: () => Promise; + selectedStoreId: Ref; +} + +export function createCardActions(options: CreateCardActionsOptions) { + async function toggleStatus(item: PunchCardTemplateCardViewModel) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled'; + const confirmText = nextStatus === 'enabled' ? '上架' : '下架'; + + Modal.confirm({ + title: `${confirmText}次卡`, + content: `确认${confirmText}「${item.name}」吗?`, + onOk: async () => { + try { + await changeMarketingPunchCardStatusApi({ + storeId: options.selectedStoreId.value, + punchCardId: item.id, + status: nextStatus, + }); + message.success(`${confirmText}成功`); + await Promise.all([ + options.loadPunchCardList(), + options.loadUsageRecords(), + ]); + } catch (error) { + console.error(error); + message.error(`${confirmText}失败`); + } + }, + }); + } + + async function removeCard(item: PunchCardTemplateCardViewModel) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + Modal.confirm({ + title: '删除次卡', + content: `确认删除「${item.name}」吗?删除后不可恢复。`, + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteMarketingPunchCardApi({ + storeId: options.selectedStoreId.value, + punchCardId: item.id, + }); + message.success('删除成功'); + await Promise.all([ + options.loadPunchCardList(), + options.loadUsageRecords(), + ]); + } catch (error) { + console.error(error); + message.error('删除失败'); + } + }, + }); + } + + return { + removeCard, + toggleStatus, + }; +} diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/constants.ts b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/constants.ts new file mode 100644 index 0000000..c3979ca --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/constants.ts @@ -0,0 +1,184 @@ +import type { + MarketingPunchCardExpireStrategy, + MarketingPunchCardScopeType, + MarketingPunchCardStatus, + MarketingPunchCardUsageFilterStatus, + MarketingPunchCardUsageMode, + MarketingPunchCardValidityType, +} from '#/api/marketing'; +import type { + PunchCardEditorForm, + PunchCardListFilterForm, + PunchCardScopeForm, + PunchCardUsageFilterForm, + PunchCardUsageStatsViewModel, +} from '#/views/marketing/punch-card/types'; + +/** + * 文件职责:次卡页面常量与默认值。 + */ + +/** 查看权限码。 */ +export const PUNCH_CARD_VIEW_PERMISSION = 'tenant:marketing:punch-card:view'; + +/** 管理权限码。 */ +export const PUNCH_CARD_MANAGE_PERMISSION = + 'tenant:marketing:punch-card:manage'; + +/** 列表状态筛选项。 */ +export const PUNCH_CARD_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingPunchCardStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '上架', value: 'enabled' }, + { label: '下架', value: 'disabled' }, +]; + +/** 使用记录状态筛选项。 */ +export const PUNCH_CARD_RECORD_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingPunchCardUsageFilterStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '正常使用', value: 'normal' }, + { label: '已用完', value: 'used_up' }, + { label: '已过期', value: 'expired' }, +]; + +/** 有效期类型选项。 */ +export const PUNCH_CARD_VALIDITY_OPTIONS: Array<{ + label: string; + value: MarketingPunchCardValidityType; +}> = [ + { label: '固定天数', value: 'days' }, + { label: '日期范围', value: 'range' }, +]; + +/** 适用范围选项。 */ +export const PUNCH_CARD_SCOPE_OPTIONS: Array<{ + label: string; + value: MarketingPunchCardScopeType; +}> = [ + { label: '全部商品', value: 'all' }, + { label: '指定分类', value: 'category' }, + { label: '指定标签', value: 'tag' }, + { label: '指定商品', value: 'product' }, +]; + +/** 使用模式选项。 */ +export const PUNCH_CARD_USAGE_MODE_OPTIONS: Array<{ + label: string; + value: MarketingPunchCardUsageMode; +}> = [ + { label: '完全免费', value: 'free' }, + { label: '金额上限', value: 'cap' }, +]; + +/** 过期策略选项。 */ +export const PUNCH_CARD_EXPIRE_STRATEGY_OPTIONS: Array<{ + label: string; + value: MarketingPunchCardExpireStrategy; +}> = [ + { label: '剩余次数作废', value: 'invalidate' }, + { label: '可申请退款', value: 'refund' }, +]; + +/** 通知渠道选项。 */ +export const PUNCH_CARD_NOTIFY_CHANNEL_OPTIONS: Array<{ + label: string; + value: string; +}> = [ + { label: '站内消息', value: 'in_app' }, + { label: '短信通知', value: 'sms' }, +]; + +/** 适用范围文案。 */ +export const PUNCH_CARD_SCOPE_TEXT_MAP: Record< + MarketingPunchCardScopeType, + string +> = { + all: '全部商品', + category: '指定分类', + tag: '指定标签', + product: '指定商品', +}; + +/** 状态文案。 */ +export const PUNCH_CARD_STATUS_TEXT_MAP: Record< + MarketingPunchCardStatus, + string +> = { + enabled: '上架', + disabled: '下架', +}; + +/** 记录状态文案。 */ +export const PUNCH_CARD_RECORD_STATUS_TEXT_MAP: Record = { + normal: '正常使用', + almost_used_up: '即将用完', + used_up: '已用完', + expired: '已过期', +}; + +/** 创建默认列表筛选。 */ +export function createDefaultPunchCardListFilterForm(): PunchCardListFilterForm { + return { + status: '', + }; +} + +/** 创建默认使用记录筛选。 */ +export function createDefaultPunchCardUsageFilterForm(): PunchCardUsageFilterForm { + return { + templateId: '', + status: '', + }; +} + +/** 创建默认适用范围。 */ +export function createDefaultPunchCardScopeForm( + scopeType: MarketingPunchCardScopeType = 'all', +): PunchCardScopeForm { + return { + scopeType, + categoryIds: [], + tagIds: [], + productIds: [], + }; +} + +/** 创建默认编辑表单。 */ +export function createDefaultPunchCardEditorForm(): PunchCardEditorForm { + return { + id: '', + name: '', + coverImageUrl: '', + salePrice: null, + originalPrice: null, + totalTimes: null, + validityType: 'days', + validityDays: 30, + validDateRange: null, + scope: createDefaultPunchCardScopeForm('all'), + usageMode: 'free', + usageCapAmount: null, + dailyLimit: 1, + perOrderLimit: 1, + perUserPurchaseLimit: null, + allowTransfer: false, + expireStrategy: 'invalidate', + description: '', + notifyChannels: ['in_app'], + status: 'enabled', + }; +} + +/** 创建默认记录统计。 */ +export function createDefaultPunchCardUsageStats(): PunchCardUsageStatsViewModel { + return { + todayUsedCount: 0, + monthUsedCount: 0, + expiringSoonCount: 0, + }; +} diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/data-actions.ts b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/data-actions.ts new file mode 100644 index 0000000..e2c298d --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/data-actions.ts @@ -0,0 +1,186 @@ +import type { Ref } from 'vue'; + +import type { MarketingPunchCardStatsDto } from '#/api/marketing'; +import type { StoreListItemDto } from '#/api/store'; +import type { + PunchCardListFilterForm, + PunchCardTemplateCardViewModel, + PunchCardTemplateOptionViewModel, + PunchCardUsageFilterForm, + PunchCardUsagePager, + PunchCardUsageStatsViewModel, +} from '#/views/marketing/punch-card/types'; + +/** + * 文件职责:次卡页面数据拉取动作。 + */ +import { message } from 'ant-design-vue'; + +import { + getMarketingPunchCardListApi, + getMarketingPunchCardUsageRecordListApi, +} from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +import { createDefaultPunchCardUsageStats } from './constants'; +import { toUsageRecordViewModel } from './helpers'; + +interface CreateDataActionsOptions { + isListLoading: Ref; + isRecordLoading: Ref; + isStoreLoading: Ref; + listFilterForm: PunchCardListFilterForm; + listKeyword: Ref; + listPage: Ref; + listPageSize: Ref; + listRows: Ref; + listStats: Ref; + listTotalCount: Ref; + recordFilterForm: PunchCardUsageFilterForm; + recordKeyword: Ref; + selectedStoreId: Ref; + stores: Ref; + templateOptions: Ref; + usagePager: Ref; + usageStats: 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.listRows.value = []; + options.listTotalCount.value = 0; + options.usagePager.value = { + ...options.usagePager.value, + items: [], + totalCount: 0, + }; + options.templateOptions.value = []; + options.listStats.value = { + onSaleCount: 0, + totalSoldCount: 0, + totalRevenueAmount: 0, + activeInUseCount: 0, + }; + options.usageStats.value = createDefaultPunchCardUsageStats(); + return; + } + + if (!options.selectedStoreId.value) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + return; + } + + const exists = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!exists) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadPunchCardList() { + if (!options.selectedStoreId.value) { + options.listRows.value = []; + options.listTotalCount.value = 0; + return; + } + + options.isListLoading.value = true; + try { + const result = await getMarketingPunchCardListApi({ + storeId: options.selectedStoreId.value, + page: options.listPage.value, + pageSize: options.listPageSize.value, + status: options.listFilterForm.status, + keyword: options.listKeyword.value.trim() || undefined, + }); + + options.listRows.value = result.items; + options.listTotalCount.value = result.totalCount; + options.listPage.value = result.page; + options.listPageSize.value = result.pageSize; + options.listStats.value = result.stats; + } catch (error) { + console.error(error); + options.listRows.value = []; + options.listTotalCount.value = 0; + options.listStats.value = { + onSaleCount: 0, + totalSoldCount: 0, + totalRevenueAmount: 0, + activeInUseCount: 0, + }; + message.error('加载次卡列表失败'); + } finally { + options.isListLoading.value = false; + } + } + + async function loadUsageRecords() { + if (!options.selectedStoreId.value) { + options.usagePager.value = { + ...options.usagePager.value, + items: [], + totalCount: 0, + }; + options.templateOptions.value = []; + options.usageStats.value = createDefaultPunchCardUsageStats(); + return; + } + + options.isRecordLoading.value = true; + try { + const result = await getMarketingPunchCardUsageRecordListApi({ + storeId: options.selectedStoreId.value, + page: options.usagePager.value.page, + pageSize: options.usagePager.value.pageSize, + punchCardId: options.recordFilterForm.templateId || undefined, + status: options.recordFilterForm.status, + keyword: options.recordKeyword.value.trim() || undefined, + }); + + options.usagePager.value = { + items: result.items.map((item) => toUsageRecordViewModel(item)), + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + options.templateOptions.value = result.templateOptions; + options.usageStats.value = result.stats; + } catch (error) { + console.error(error); + options.usagePager.value = { + ...options.usagePager.value, + items: [], + totalCount: 0, + }; + options.templateOptions.value = []; + options.usageStats.value = createDefaultPunchCardUsageStats(); + message.error('加载使用记录失败'); + } finally { + options.isRecordLoading.value = false; + } + } + + return { + loadPunchCardList, + loadStores, + loadUsageRecords, + }; +} diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/drawer-actions.ts new file mode 100644 index 0000000..6b56738 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/drawer-actions.ts @@ -0,0 +1,333 @@ +import type { Ref } from 'vue'; + +import type { PunchCardEditorForm } from '#/views/marketing/punch-card/types'; + +/** + * 文件职责:次卡主抽屉动作。 + */ +import { message } from 'ant-design-vue'; + +import { + getMarketingPunchCardDetailApi, + saveMarketingPunchCardApi, +} from '#/api/marketing'; + +import { + mapDetailToEditorForm, + mapEditorFormToSaveDto, + resetEditorForm, +} from './helpers'; + +interface CreateDrawerActionsOptions { + canManage: Ref; + drawerMode: Ref<'create' | 'edit'>; + form: PunchCardEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadPunchCardList: () => Promise; + loadUsageRecords: () => Promise; + openProductPicker: ( + initialProductIds: string[], + onConfirm: (selectedProductIds: string[]) => void, + ) => Promise; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormCoverImageUrl(value: string) { + options.form.coverImageUrl = value; + } + + function setFormSalePrice(value: null | number) { + options.form.salePrice = value; + } + + function setFormOriginalPrice(value: null | number) { + options.form.originalPrice = value; + } + + function setFormTotalTimes(value: null | number) { + options.form.totalTimes = value; + } + + function setFormValidityType(value: PunchCardEditorForm['validityType']) { + options.form.validityType = value; + if (value === 'days') { + options.form.validDateRange = null; + return; + } + + options.form.validityDays = null; + } + + function setFormValidityDays(value: null | number) { + options.form.validityDays = value; + } + + function setFormValidDateRange(value: [any, any] | null) { + options.form.validDateRange = value; + } + + function setFormScopeType(value: PunchCardEditorForm['scope']['scopeType']) { + options.form.scope.scopeType = value; + options.form.scope.categoryIds = []; + options.form.scope.tagIds = []; + options.form.scope.productIds = []; + } + + function setScopeCategoryIds(value: string[]) { + options.form.scope.categoryIds = [...value]; + } + + function setScopeTagIds(value: string[]) { + options.form.scope.tagIds = [...value]; + } + + function setScopeProductIds(value: string[]) { + options.form.scope.productIds = [...value]; + } + + function setFormUsageMode(value: PunchCardEditorForm['usageMode']) { + options.form.usageMode = value; + if (value === 'free') { + options.form.usageCapAmount = null; + } + } + + function setFormUsageCapAmount(value: null | number) { + options.form.usageCapAmount = value; + } + + function setFormDailyLimit(value: null | number) { + options.form.dailyLimit = value; + } + + function setFormPerOrderLimit(value: null | number) { + options.form.perOrderLimit = value; + } + + function setFormPerUserPurchaseLimit(value: null | number) { + options.form.perUserPurchaseLimit = value; + } + + function setFormAllowTransfer(value: boolean) { + options.form.allowTransfer = value; + } + + function setFormExpireStrategy(value: PunchCardEditorForm['expireStrategy']) { + options.form.expireStrategy = value; + } + + function setFormDescription(value: string) { + options.form.description = value; + } + + function toggleNotifyChannel(value: string) { + if (options.form.notifyChannels.includes(value)) { + options.form.notifyChannels = options.form.notifyChannels.filter( + (item) => item !== value, + ); + return; + } + + options.form.notifyChannels = [...options.form.notifyChannels, value]; + } + + async function openCreateDrawer() { + if (!options.canManage.value) { + return; + } + + options.drawerMode.value = 'create'; + resetEditorForm(options.form); + options.isDrawerOpen.value = true; + } + + async function openEditDrawer(id: string) { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + options.drawerMode.value = 'edit'; + options.isDrawerLoading.value = true; + options.isDrawerOpen.value = true; + + try { + const detail = await getMarketingPunchCardDetailApi({ + storeId: options.selectedStoreId.value, + punchCardId: id, + }); + Object.assign(options.form, mapDetailToEditorForm(detail)); + } catch (error) { + console.error(error); + options.isDrawerOpen.value = false; + message.error('加载次卡详情失败'); + } finally { + options.isDrawerLoading.value = false; + } + } + + async function openScopeProductPicker() { + if (!options.canManage.value) { + return; + } + + await options.openProductPicker( + options.form.scope.productIds, + (selectedProductIds) => { + options.form.scope.scopeType = 'product'; + options.form.scope.productIds = [...selectedProductIds]; + }, + ); + } + + async function submitDrawer() { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + const name = options.form.name.trim(); + if (!name) { + message.warning('请输入次卡名称'); + return; + } + + if (!options.form.salePrice || options.form.salePrice <= 0) { + message.warning('售价必须大于 0'); + return; + } + + if (!options.form.totalTimes || options.form.totalTimes <= 0) { + message.warning('总次数必须大于 0'); + return; + } + + if ( + options.form.originalPrice !== null && + options.form.originalPrice !== undefined && + options.form.originalPrice > 0 && + options.form.originalPrice < options.form.salePrice + ) { + message.warning('原价不能小于售价'); + return; + } + + if ( + options.form.validityType === 'days' && + (!options.form.validityDays || options.form.validityDays <= 0) + ) { + message.warning('请输入有效天数'); + return; + } + + if (options.form.validityType === 'range' && !options.form.validDateRange) { + message.warning('请选择有效日期范围'); + return; + } + + if ( + options.form.scope.scopeType === 'category' && + options.form.scope.categoryIds.length === 0 + ) { + message.warning('请至少选择一个分类'); + return; + } + + if ( + options.form.scope.scopeType === 'tag' && + options.form.scope.tagIds.length === 0 + ) { + message.warning('请至少选择一个标签'); + return; + } + + if ( + options.form.scope.scopeType === 'product' && + options.form.scope.productIds.length === 0 + ) { + message.warning('请至少选择一个商品'); + return; + } + + if ( + options.form.usageMode === 'cap' && + (!options.form.usageCapAmount || options.form.usageCapAmount <= 0) + ) { + message.warning('请输入单次金额上限'); + return; + } + + if (options.form.notifyChannels.length === 0) { + message.warning('请至少选择一种购买通知方式'); + return; + } + + options.isDrawerSubmitting.value = true; + try { + const payload = mapEditorFormToSaveDto( + options.form, + options.selectedStoreId.value, + ); + await saveMarketingPunchCardApi(payload); + message.success('保存成功'); + options.isDrawerOpen.value = false; + await Promise.all([ + options.loadPunchCardList(), + options.loadUsageRecords(), + ]); + } catch (error) { + console.error(error); + message.error('保存失败'); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + openCreateDrawer, + openEditDrawer, + openScopeProductPicker, + setDrawerOpen, + setFormAllowTransfer, + setFormCoverImageUrl, + setFormDailyLimit, + setFormDescription, + setFormExpireStrategy, + setFormName, + setFormOriginalPrice, + setFormPerOrderLimit, + setFormPerUserPurchaseLimit, + setFormSalePrice, + setFormScopeType, + setFormTotalTimes, + setFormUsageCapAmount, + setFormUsageMode, + setFormValidDateRange, + setFormValidityDays, + setFormValidityType, + setScopeCategoryIds, + setScopeProductIds, + setScopeTagIds, + submitDrawer, + toggleNotifyChannel, + }; +} diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/helpers.ts b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/helpers.ts new file mode 100644 index 0000000..0e0f703 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/helpers.ts @@ -0,0 +1,242 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingPunchCardDetailDto, + MarketingPunchCardUsageRecordDto, + SaveMarketingPunchCardDto, +} from '#/api/marketing'; +import type { + PunchCardEditorForm, + PunchCardUsageRecordViewModel, +} from '#/views/marketing/punch-card/types'; + +import dayjs from 'dayjs'; + +import { + createDefaultPunchCardEditorForm, + createDefaultPunchCardScopeForm, + PUNCH_CARD_RECORD_STATUS_TEXT_MAP, +} from './constants'; + +/** + * 文件职责:次卡页面工具方法。 + */ + +/** 金额格式化。 */ +export function formatCurrency(value: null | number | undefined) { + const amount = Number(value ?? 0); + if (Number.isNaN(amount)) { + return '¥0'; + } + + if (Math.abs(amount) >= 1000) { + return `¥${amount.toLocaleString('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + })}`; + } + + return `¥${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`; +} + +/** 记录状态转文案。 */ +export function resolveUsageStatusText(status: string) { + return PUNCH_CARD_RECORD_STATUS_TEXT_MAP[status] ?? '正常使用'; +} + +/** 记录状态转样式类。 */ +export function resolveUsageStatusClass(status: string) { + if (status === 'normal') { + return 'is-green'; + } + if (status === 'almost_used_up') { + return 'is-orange'; + } + return 'is-gray'; +} + +/** DTO 转列表视图模型。 */ +export function toUsageRecordViewModel( + source: MarketingPunchCardUsageRecordDto, +): PunchCardUsageRecordViewModel { + return { + ...source, + displayStatusText: resolveUsageStatusText(source.displayStatus), + }; +} + +/** 详情转编辑表单。 */ +export function mapDetailToEditorForm( + detail: MarketingPunchCardDetailDto, +): PunchCardEditorForm { + const dateRange = toValidDateRange(detail.validFrom, detail.validTo); + + const form = createDefaultPunchCardEditorForm(); + form.id = detail.id; + form.name = detail.name; + form.coverImageUrl = detail.coverImageUrl ?? ''; + form.salePrice = detail.salePrice; + form.originalPrice = detail.originalPrice; + form.totalTimes = detail.totalTimes; + form.validityType = detail.validityType; + form.validityDays = detail.validityDays; + form.validDateRange = dateRange; + form.scope = { + scopeType: detail.scope.scopeType, + categoryIds: [...detail.scope.categoryIds], + tagIds: [...detail.scope.tagIds], + productIds: [...detail.scope.productIds], + }; + form.usageMode = detail.usageMode; + form.usageCapAmount = detail.usageCapAmount; + form.dailyLimit = detail.dailyLimit; + form.perOrderLimit = detail.perOrderLimit; + form.perUserPurchaseLimit = detail.perUserPurchaseLimit; + form.allowTransfer = detail.allowTransfer; + form.expireStrategy = detail.expireStrategy; + form.description = detail.description ?? ''; + form.notifyChannels = + detail.notifyChannels.length > 0 ? [...detail.notifyChannels] : ['in_app']; + form.status = detail.status; + + return form; +} + +/** 编辑表单转保存 DTO。 */ +export function mapEditorFormToSaveDto( + form: PunchCardEditorForm, + storeId: string, +): SaveMarketingPunchCardDto { + const validFrom = + form.validityType === 'range' && form.validDateRange + ? form.validDateRange[0].format('YYYY-MM-DD') + : null; + const validTo = + form.validityType === 'range' && form.validDateRange + ? form.validDateRange[1].format('YYYY-MM-DD') + : null; + + return { + id: form.id || undefined, + storeId, + name: form.name.trim(), + coverImageUrl: form.coverImageUrl.trim() || undefined, + salePrice: Number(form.salePrice ?? 0), + originalPrice: + form.originalPrice === null || form.originalPrice === undefined + ? null + : Number(form.originalPrice), + totalTimes: Number(form.totalTimes ?? 0), + validityType: form.validityType, + validityDays: + form.validityType === 'days' + ? Number(form.validityDays ?? 0) || null + : null, + validFrom, + validTo, + scopeType: form.scope.scopeType, + scopeCategoryIds: [...form.scope.categoryIds], + scopeTagIds: [...form.scope.tagIds], + scopeProductIds: [...form.scope.productIds], + usageMode: form.usageMode, + usageCapAmount: + form.usageMode === 'cap' + ? Number(form.usageCapAmount ?? 0) || null + : null, + dailyLimit: + form.dailyLimit === null || form.dailyLimit === undefined + ? null + : Number(form.dailyLimit) || null, + perOrderLimit: + form.perOrderLimit === null || form.perOrderLimit === undefined + ? null + : Number(form.perOrderLimit) || null, + perUserPurchaseLimit: + form.perUserPurchaseLimit === null || + form.perUserPurchaseLimit === undefined + ? null + : Number(form.perUserPurchaseLimit) || null, + allowTransfer: form.allowTransfer, + expireStrategy: form.expireStrategy, + description: form.description.trim() || undefined, + notifyChannels: [...form.notifyChannels], + }; +} + +/** 创建空编辑表单。 */ +export function resetEditorForm(form: PunchCardEditorForm) { + const value = createDefaultPunchCardEditorForm(); + form.id = value.id; + form.name = value.name; + form.coverImageUrl = value.coverImageUrl; + form.salePrice = value.salePrice; + form.originalPrice = value.originalPrice; + form.totalTimes = value.totalTimes; + form.validityType = value.validityType; + form.validityDays = value.validityDays; + form.validDateRange = value.validDateRange; + form.scope = createDefaultPunchCardScopeForm(value.scope.scopeType); + form.usageMode = value.usageMode; + form.usageCapAmount = value.usageCapAmount; + form.dailyLimit = value.dailyLimit; + form.perOrderLimit = value.perOrderLimit; + form.perUserPurchaseLimit = value.perUserPurchaseLimit; + form.allowTransfer = value.allowTransfer; + form.expireStrategy = value.expireStrategy; + form.description = value.description; + form.notifyChannels = [...value.notifyChannels]; + form.status = value.status; +} + +/** 深拷贝范围。 */ +export function cloneScope(scope: PunchCardEditorForm['scope']) { + return { + scopeType: scope.scopeType, + categoryIds: [...scope.categoryIds], + tagIds: [...scope.tagIds], + productIds: [...scope.productIds], + }; +} + +/** base64 下载。 */ +export function downloadBase64File( + fileName: string, + fileContentBase64: string, +) { + const blob = decodeBase64ToBlob(fileContentBase64); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +function toValidDateRange( + validFrom: null | string, + validTo: null | string, +): [Dayjs, Dayjs] | null { + if (!validFrom || !validTo) { + return null; + } + + const from = dayjs(validFrom); + const to = dayjs(validTo); + if (!from.isValid() || !to.isValid()) { + return null; + } + + return [from, to]; +} + +function decodeBase64ToBlob(base64: string) { + const binary = atob(base64); + const length = binary.length; + const bytes = new Uint8Array(length); + for (let index = 0; index < length; index++) { + bytes[index] = binary.codePointAt(index) ?? 0; + } + return new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); +} diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/picker-actions.ts b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/picker-actions.ts new file mode 100644 index 0000000..f8d756e --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/punch-card-page/picker-actions.ts @@ -0,0 +1,160 @@ +import type { Ref } from 'vue'; + +import type { + PunchCardPickerCategoryItem, + PunchCardPickerProductItem, +} from '#/views/marketing/punch-card/types'; + +/** + * 文件职责:次卡二级商品选择抽屉动作。 + */ +import { message } from 'ant-design-vue'; + +import { + getProductCategoryListApi, + searchProductPickerApi, +} from '#/api/product'; + +interface CreatePickerActionsOptions { + isPickerLoading: Ref; + isPickerOpen: Ref; + pickerCategories: Ref; + pickerCategoryFilterId: Ref; + pickerKeyword: Ref; + pickerProducts: Ref; + pickerSelectedProductIds: Ref; + resolveStoreId: () => string; +} + +export function createPickerActions(options: CreatePickerActionsOptions) { + let onConfirm: ((productIds: string[]) => void) | null = null; + + function setPickerOpen(value: boolean) { + options.isPickerOpen.value = value; + if (!value) { + onConfirm = null; + } + } + + function setPickerKeyword(value: string) { + options.pickerKeyword.value = value; + } + + function setPickerCategoryFilterId(value: string) { + options.pickerCategoryFilterId.value = value; + } + + function setPickerSelectedProductIds(value: string[]) { + options.pickerSelectedProductIds.value = [...value]; + } + + function togglePickerProduct(id: string) { + if (options.pickerSelectedProductIds.value.includes(id)) { + options.pickerSelectedProductIds.value = + options.pickerSelectedProductIds.value.filter((item) => item !== id); + return; + } + + options.pickerSelectedProductIds.value = [ + ...options.pickerSelectedProductIds.value, + id, + ]; + } + + async function loadPickerCategories() { + const storeId = options.resolveStoreId(); + if (!storeId) { + options.pickerCategories.value = []; + return; + } + + options.pickerCategories.value = await getProductCategoryListApi(storeId); + } + + async function loadPickerProducts() { + const storeId = options.resolveStoreId(); + 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 reloadPickerData() { + 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 searchPickerProducts() { + options.isPickerLoading.value = true; + try { + await loadPickerProducts(); + } catch (error) { + console.error(error); + options.pickerProducts.value = []; + message.error('加载商品失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function openProductPicker( + initialProductIds: string[], + callback: (productIds: string[]) => void, + ) { + const storeId = options.resolveStoreId(); + if (!storeId) { + message.warning('请先选择门店后再选择商品'); + return; + } + + options.pickerSelectedProductIds.value = [...initialProductIds]; + options.pickerKeyword.value = ''; + options.pickerCategoryFilterId.value = ''; + onConfirm = callback; + options.isPickerOpen.value = true; + + await reloadPickerData(); + } + + function submitPicker() { + if (options.pickerSelectedProductIds.value.length === 0) { + message.warning('请至少选择一个商品'); + return; + } + + onConfirm?.([...options.pickerSelectedProductIds.value]); + setPickerOpen(false); + } + + return { + openProductPicker, + reloadPickerData, + searchPickerProducts, + setPickerCategoryFilterId, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + submitPicker, + togglePickerProduct, + }; +} diff --git a/apps/web-antd/src/views/marketing/punch-card/composables/useMarketingPunchCardPage.ts b/apps/web-antd/src/views/marketing/punch-card/composables/useMarketingPunchCardPage.ts new file mode 100644 index 0000000..9d93366 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/composables/useMarketingPunchCardPage.ts @@ -0,0 +1,470 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { + PunchCardPickerCategoryItem, + PunchCardPickerProductItem, + PunchCardTabKey, + PunchCardTemplateCardViewModel, + PunchCardTemplateOptionViewModel, + PunchCardUsagePager, +} from '#/views/marketing/punch-card/types'; + +/** + * 文件职责:次卡页面状态与行为编排。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { message } from 'ant-design-vue'; + +import { exportMarketingPunchCardUsageRecordApi } from '#/api/marketing'; +import { + getProductCategoryListApi, + getProductLabelListApi, +} from '#/api/product'; + +import { createDefaultPunchCardUsagePager } from '../types'; +import { createCardActions } from './punch-card-page/card-actions'; +import { + createDefaultPunchCardEditorForm, + createDefaultPunchCardListFilterForm, + createDefaultPunchCardUsageFilterForm, + createDefaultPunchCardUsageStats, + PUNCH_CARD_MANAGE_PERMISSION, +} from './punch-card-page/constants'; +import { createDataActions } from './punch-card-page/data-actions'; +import { createDrawerActions } from './punch-card-page/drawer-actions'; +import { downloadBase64File } from './punch-card-page/helpers'; +import { createPickerActions } from './punch-card-page/picker-actions'; + +export function useMarketingPunchCardPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const activeTab = ref('list'); + + const listFilterForm = reactive(createDefaultPunchCardListFilterForm()); + const listKeyword = ref(''); + const listPage = ref(1); + const listPageSize = ref(4); + const listRows = ref([]); + const listTotalCount = ref(0); + const listStats = ref({ + onSaleCount: 0, + totalSoldCount: 0, + totalRevenueAmount: 0, + activeInUseCount: 0, + }); + const isListLoading = ref(false); + + const recordFilterForm = reactive(createDefaultPunchCardUsageFilterForm()); + const recordKeyword = ref(''); + const usagePager = ref( + createDefaultPunchCardUsagePager(), + ); + const usageStats = ref(createDefaultPunchCardUsageStats()); + const templateOptions = ref([]); + const isRecordLoading = ref(false); + + const isDrawerOpen = ref(false); + const isDrawerLoading = ref(false); + const isDrawerSubmitting = ref(false); + const drawerMode = ref<'create' | 'edit'>('create'); + const form = reactive(createDefaultPunchCardEditorForm()); + + const isPickerOpen = ref(false); + const isPickerLoading = ref(false); + const pickerKeyword = ref(''); + const pickerCategoryFilterId = ref(''); + const pickerCategories = ref([]); + const pickerProducts = ref([]); + const pickerSelectedProductIds = ref([]); + const scopeCategoryOptions = ref>([]); + const scopeTagOptions = ref>([]); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(PUNCH_CARD_MANAGE_PERMISSION), + ); + + const hasStore = computed(() => stores.value.length > 0); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const pickerCategoryOptions = computed(() => [ + { label: '全部分类', value: '' }, + ...pickerCategories.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ]); + + const drawerTitle = computed(() => + drawerMode.value === 'create' ? '创建次卡' : '编辑次卡', + ); + + const drawerSubmitText = computed(() => '保存'); + + function resolveStoreId() { + return selectedStoreId.value; + } + + const { loadStores, loadPunchCardList, loadUsageRecords } = createDataActions( + { + stores, + selectedStoreId, + isStoreLoading, + isListLoading, + listFilterForm, + listKeyword, + listPage, + listPageSize, + listRows, + listStats, + listTotalCount, + isRecordLoading, + recordFilterForm, + recordKeyword, + usagePager, + usageStats, + templateOptions, + }, + ); + + const { + openProductPicker, + reloadPickerData, + searchPickerProducts, + setPickerCategoryFilterId, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + submitPicker, + togglePickerProduct, + } = createPickerActions({ + isPickerLoading, + isPickerOpen, + pickerCategories, + pickerCategoryFilterId, + pickerKeyword, + pickerProducts, + pickerSelectedProductIds, + resolveStoreId, + }); + + const { + openCreateDrawer, + openEditDrawer, + openScopeProductPicker, + setDrawerOpen, + setFormAllowTransfer, + setFormCoverImageUrl, + setFormDailyLimit, + setFormDescription, + setFormExpireStrategy, + setFormName, + setFormOriginalPrice, + setFormPerOrderLimit, + setFormPerUserPurchaseLimit, + setFormSalePrice, + setFormScopeType, + setFormTotalTimes, + setFormUsageCapAmount, + setFormUsageMode, + setFormValidDateRange, + setFormValidityDays, + setFormValidityType, + setScopeCategoryIds, + setScopeProductIds, + setScopeTagIds, + submitDrawer, + toggleNotifyChannel, + } = createDrawerActions({ + canManage, + form, + drawerMode, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + loadPunchCardList, + loadUsageRecords, + selectedStoreId, + openProductPicker, + }); + + const { removeCard, toggleStatus } = createCardActions({ + canManage, + selectedStoreId, + loadPunchCardList, + loadUsageRecords, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setActiveTab(value: PunchCardTabKey) { + activeTab.value = value; + } + + function setListKeyword(value: string) { + listKeyword.value = value; + } + + function setListStatusFilter(value: '' | 'disabled' | 'enabled') { + listFilterForm.status = value; + } + + function setRecordKeyword(value: string) { + recordKeyword.value = value; + } + + function setRecordTemplateFilter(value: string) { + recordFilterForm.templateId = value; + } + + function setRecordStatusFilter(value: '' | 'expired' | 'normal' | 'used_up') { + recordFilterForm.status = value; + } + + async function applyListFilters() { + listPage.value = 1; + await loadPunchCardList(); + } + + async function resetListFilters() { + listFilterForm.status = ''; + listKeyword.value = ''; + listPage.value = 1; + await loadPunchCardList(); + } + + async function handleListPageChange(page: number, pageSize: number) { + listPage.value = page; + listPageSize.value = pageSize; + await loadPunchCardList(); + } + + async function applyRecordFilters() { + usagePager.value = { + ...usagePager.value, + page: 1, + }; + await loadUsageRecords(); + } + + async function resetRecordFilters() { + recordFilterForm.templateId = ''; + recordFilterForm.status = ''; + recordKeyword.value = ''; + usagePager.value = { + ...usagePager.value, + page: 1, + }; + await loadUsageRecords(); + } + + async function handleRecordPageChange(page: number, pageSize: number) { + usagePager.value = { + ...usagePager.value, + page, + pageSize, + }; + await loadUsageRecords(); + } + + async function handlePickerCategoryChange(value: string) { + setPickerCategoryFilterId(value); + await searchPickerProducts(); + } + + async function handlePickerSearch() { + await searchPickerProducts(); + } + + async function exportUsageRecords() { + if (!selectedStoreId.value) { + message.warning('请先选择门店'); + return; + } + + try { + const result = await exportMarketingPunchCardUsageRecordApi({ + storeId: selectedStoreId.value, + punchCardId: recordFilterForm.templateId || undefined, + status: recordFilterForm.status, + keyword: recordKeyword.value.trim() || undefined, + }); + + downloadBase64File(result.fileName, result.fileContentBase64); + message.success(`导出成功,共 ${result.totalCount} 条`); + } catch (error) { + console.error(error); + message.error('导出失败'); + } + } + + async function loadScopeOptions() { + if (!selectedStoreId.value) { + scopeCategoryOptions.value = []; + scopeTagOptions.value = []; + return; + } + + try { + const [categories, labels] = await Promise.all([ + getProductCategoryListApi(selectedStoreId.value), + getProductLabelListApi({ + storeId: selectedStoreId.value, + status: 'enabled', + }), + ]); + + scopeCategoryOptions.value = categories.map((item) => ({ + label: item.name, + value: item.id, + })); + scopeTagOptions.value = labels.map((item) => ({ + label: item.name, + value: item.id, + })); + } catch (error) { + console.error(error); + scopeCategoryOptions.value = []; + scopeTagOptions.value = []; + } + } + + watch(selectedStoreId, async () => { + listPage.value = 1; + listFilterForm.status = ''; + listKeyword.value = ''; + + usagePager.value = { + ...usagePager.value, + page: 1, + pageSize: 10, + }; + recordFilterForm.templateId = ''; + recordFilterForm.status = ''; + recordKeyword.value = ''; + + await Promise.all([ + loadScopeOptions(), + loadPunchCardList(), + loadUsageRecords(), + ]); + }); + + onMounted(async () => { + await loadStores(); + if (selectedStoreId.value) { + await Promise.all([ + loadScopeOptions(), + loadPunchCardList(), + loadUsageRecords(), + ]); + } + }); + + return { + activeTab, + applyListFilters, + applyRecordFilters, + canManage, + drawerSubmitText, + drawerTitle, + drawerMode, + exportUsageRecords, + form, + handleListPageChange, + handlePickerCategoryChange, + handlePickerSearch, + handleRecordPageChange, + hasStore, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + isListLoading, + isPickerLoading, + isPickerOpen, + isRecordLoading, + isStoreLoading, + listFilterForm, + listKeyword, + listPage, + listPageSize, + listRows, + listStats, + listTotalCount, + openCreateDrawer, + openEditDrawer, + openScopeProductPicker, + pickerCategories, + pickerCategoryFilterId, + pickerCategoryOptions, + pickerKeyword, + pickerProducts, + pickerSelectedProductIds, + recordFilterForm, + recordKeyword, + reloadPickerData, + removeCard, + resetListFilters, + resetRecordFilters, + scopeCategoryOptions, + scopeTagOptions, + selectedStoreId, + setActiveTab, + setDrawerOpen, + setFormAllowTransfer, + setFormCoverImageUrl, + setFormDailyLimit, + setFormDescription, + setFormExpireStrategy, + setFormName, + setFormOriginalPrice, + setFormPerOrderLimit, + setFormPerUserPurchaseLimit, + setFormSalePrice, + setFormScopeType, + setFormTotalTimes, + setFormUsageCapAmount, + setFormUsageMode, + setFormValidDateRange, + setFormValidityDays, + setFormValidityType, + setListKeyword, + setListStatusFilter, + setPickerCategoryFilterId, + setPickerKeyword, + setPickerOpen, + setPickerSelectedProductIds, + setRecordKeyword, + setRecordStatusFilter, + setRecordTemplateFilter, + setScopeCategoryIds, + setScopeProductIds, + setScopeTagIds, + setSelectedStoreId, + storeOptions, + submitDrawer, + submitPicker, + templateOptions, + toggleNotifyChannel, + togglePickerProduct, + toggleStatus, + usagePager, + usageStats, + }; +} diff --git a/apps/web-antd/src/views/marketing/punch-card/index.vue b/apps/web-antd/src/views/marketing/punch-card/index.vue new file mode 100644 index 0000000..cd2c730 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/index.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/punch-card/styles/base.less b/apps/web-antd/src/views/marketing/punch-card/styles/base.less new file mode 100644 index 0000000..47b4746 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/base.less @@ -0,0 +1,29 @@ +/** + * 文件职责:次卡页面基础变量。 + */ +.page-marketing-punch-card { + --mpc-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --mpc-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --mpc-shadow-md: 0 8px 22px rgb(0 0 0 / 10%), 0 2px 6px rgb(0 0 0 / 8%); + --mpc-border: #e7eaf0; + --mpc-text: #1f2937; + --mpc-subtext: #6b7280; + --mpc-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/punch-card/styles/card.less b/apps/web-antd/src/views/marketing/punch-card/styles/card.less new file mode 100644 index 0000000..4468a8a --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/card.less @@ -0,0 +1,196 @@ +/** + * 文件职责:次卡卡片样式。 + */ +.page-marketing-punch-card { + .mpc-card { + display: flex; + overflow: hidden; + background: #fff; + border: 1px solid var(--mpc-border); + border-radius: 12px; + box-shadow: var(--mpc-shadow-sm); + transition: box-shadow var(--mpc-transition); + } + + .mpc-card:hover { + box-shadow: var(--mpc-shadow-md); + } + + .mpc-card-off { + opacity: 0.58; + } + + .mpc-card-cover { + flex-shrink: 0; + width: 148px; + min-height: 188px; + overflow: hidden; + background: linear-gradient(135deg, #0f172a, #334155); + } + + .mpc-card-cover-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .mpc-card-cover-fallback { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #fff; + } + + .mpc-card-cover-icon { + width: 64px; + height: 64px; + opacity: 0.28; + } + + .mpc-card-cover-count { + font-size: 28px; + font-weight: 700; + line-height: 1; + } + + .mpc-card-cover-count small { + margin-left: 2px; + font-size: 12px; + font-weight: 400; + opacity: 0.85; + } + + .mpc-card-body { + display: flex; + flex: 1; + flex-direction: column; + padding: 15px 16px 13px; + } + + .mpc-card-name-row { + display: flex; + gap: 8px; + align-items: center; + } + + .mpc-card-name { + overflow: hidden; + text-overflow: ellipsis; + font-size: 16px; + font-weight: 600; + color: var(--mpc-text); + white-space: nowrap; + } + + .mpc-scope-tag { + display: inline-flex; + flex-shrink: 0; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 11px; + border-radius: 999px; + } + + .mpc-scope-tag.is-blue { + color: #1677ff; + background: #e6f4ff; + } + + .mpc-scope-tag.is-green { + color: #16a34a; + background: #dcfce7; + } + + .mpc-scope-tag.is-purple { + color: #7c3aed; + background: #f3e8ff; + } + + .mpc-scope-tag.is-orange { + color: #d97706; + background: #fef3c7; + } + + .mpc-card-price-row { + display: flex; + gap: 8px; + align-items: baseline; + margin-top: 6px; + } + + .mpc-card-price-now { + font-size: 24px; + font-weight: 700; + line-height: 1; + color: #ef4444; + } + + .mpc-card-price-origin { + font-size: 13px; + color: #9ca3af; + text-decoration: line-through; + } + + .mpc-card-info-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + } + + .mpc-card-info-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 11px; + color: #4b5563; + background: #f5f5f5; + border-radius: 5px; + } + + .mpc-card-meta { + display: flex; + gap: 14px; + align-items: center; + margin-top: 10px; + font-size: 12px; + color: #9ca3af; + } + + .mpc-card-actions { + display: flex; + gap: 6px; + align-items: center; + padding-top: 10px; + margin-top: auto; + border-top: 1px solid #f3f4f6; + } + + .mpc-status-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + margin-left: auto; + font-size: 11px; + border-radius: 999px; + } + + .mpc-status-tag.is-green { + color: #166534; + background: #dcfce7; + } + + .mpc-status-tag.is-gray { + color: #475569; + background: #e2e8f0; + } +} diff --git a/apps/web-antd/src/views/marketing/punch-card/styles/drawer.less b/apps/web-antd/src/views/marketing/punch-card/styles/drawer.less new file mode 100644 index 0000000..c0964df --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/drawer.less @@ -0,0 +1,328 @@ +/** + * 文件职责:次卡主抽屉与二级抽屉样式。 + */ +.mpc-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; + } + + .ant-input, + .ant-input-number, + .ant-picker, + .ant-select-selector { + border-radius: 6px !important; + } + + .mpc-editor-form .ant-form-item { + margin-bottom: 14px; + } + + .mpc-inline-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .mpc-inline-row .ant-form-item:last-child { + grid-column: span 2; + } + + .mpc-inline-fields { + display: flex; + gap: 8px; + align-items: center; + margin-top: 8px; + font-size: 13px; + color: #4b5563; + } + + .mpc-inline-fields .ant-input-number { + width: 110px; + } + + .mpc-range-picker { + width: 100%; + margin-top: 8px; + } + + .mpc-form-hint { + margin-top: 6px; + font-size: 12px; + color: #9ca3af; + } + + .mpc-cover-uploader { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + } + + .mpc-cover-preview { + width: 108px; + height: 76px; + object-fit: cover; + border-radius: 8px; + box-shadow: 0 1px 4px rgb(0 0 0 / 10%); + } + + .mpc-section-divider { + height: 1px; + margin: 18px 0; + background: linear-gradient( + 90deg, + rgb(229 231 235 / 0%) 0%, + rgb(229 231 235 / 100%) 16%, + rgb(229 231 235 / 100%) 84%, + rgb(229 231 235 / 0%) 100% + ); + } + + .mpc-notify-pills { + display: flex; + gap: 8px; + align-items: center; + } + + .mpc-notify-pill { + height: 30px; + padding: 0 14px; + font-size: 12px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + transition: all 0.2s; + } + + .mpc-notify-pill:hover { + color: #1677ff; + border-color: #91caff; + } + + .mpc-notify-pill.checked { + color: #1677ff; + background: #e8f3ff; + border-color: #91caff; + } + + .mpc-selected-products { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + } + + .mpc-selected-product { + display: inline-flex; + gap: 4px; + align-items: center; + height: 24px; + padding: 0 8px; + font-size: 12px; + color: #4b5563; + background: #f5f5f5; + border-radius: 6px; + } + + .mpc-selected-product-name { + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mpc-selected-product-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: #94a3b8; + cursor: pointer; + background: transparent; + border: 0; + border-radius: 4px; + } + + .mpc-selected-product-remove:hover { + color: #ef4444; + background: #fef2f2; + } + + .mpc-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-start; + } +} + +.mpc-picker-drawer { + .ant-drawer-header { + min-height: 54px; + padding: 0 18px; + border-bottom: 1px solid #f0f0f0; + } + + .ant-drawer-body { + padding: 14px 16px 12px; + } + + .ant-drawer-footer { + padding: 10px 16px; + border-top: 1px solid #f0f0f0; + } + + .mpc-picker-toolbar { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 12px; + } + + .mpc-picker-toolbar .ant-input { + width: 240px; + } + + .mpc-picker-category { + width: 180px; + } + + .mpc-picker-count { + margin-left: auto; + font-size: 12px; + color: #9ca3af; + } + + .mpc-picker-empty { + padding: 24px 0; + } + + .mpc-picker-table-wrap { + max-height: 460px; + overflow: auto; + border: 1px solid #eceef2; + border-radius: 10px; + } + + .mpc-picker-table { + width: 100%; + border-collapse: collapse; + } + + .mpc-picker-table thead th { + position: sticky; + top: 0; + z-index: 1; + padding: 10px 8px; + font-size: 12px; + font-weight: 600; + color: #4b5563; + text-align: left; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; + } + + .mpc-picker-table tbody td { + padding: 10px 8px; + font-size: 13px; + color: #374151; + border-bottom: 1px solid #f1f5f9; + } + + .mpc-picker-table tbody tr { + cursor: pointer; + transition: background-color 0.2s; + } + + .mpc-picker-table tbody tr:hover { + background: #f8fbff; + } + + .mpc-picker-table tbody tr.checked { + background: #eef6ff; + } + + .mpc-picker-col-check { + width: 42px; + } + + .mpc-picker-col-price { + width: 100px; + } + + .mpc-picker-col-status { + width: 90px; + } + + .mpc-picker-product-name { + font-weight: 500; + color: #1f2937; + } + + .mpc-picker-product-spu { + margin-top: 2px; + font-size: 12px; + color: #94a3b8; + } + + .mpc-picker-product-status { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + font-size: 11px; + border-radius: 999px; + } + + .mpc-picker-product-status.is-green { + color: #166534; + background: #dcfce7; + } + + .mpc-picker-product-status.is-orange { + color: #d97706; + background: #fef3c7; + } + + .mpc-picker-product-status.is-gray { + color: #475569; + background: #e2e8f0; + } + + .mpc-picker-footer { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + } + + .mpc-picker-footer-info { + font-size: 12px; + color: #6b7280; + } + + .mpc-picker-footer-actions { + display: flex; + gap: 8px; + align-items: center; + } +} diff --git a/apps/web-antd/src/views/marketing/punch-card/styles/index.less b/apps/web-antd/src/views/marketing/punch-card/styles/index.less new file mode 100644 index 0000000..d6ef2dd --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './table.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/marketing/punch-card/styles/layout.less b/apps/web-antd/src/views/marketing/punch-card/styles/layout.less new file mode 100644 index 0000000..2f0b119 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/layout.less @@ -0,0 +1,199 @@ +/** + * 文件职责:次卡页面布局样式。 + */ +.page-marketing-punch-card { + .mpc-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mpc-tab-panel { + display: flex; + flex-direction: column; + gap: 14px; + } + + .mpc-toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mpc-border); + border-radius: 10px; + box-shadow: var(--mpc-shadow-sm); + } + + .mpc-toolbar-top { + gap: 12px; + } + + .mpc-store-select { + width: 220px; + } + + .mpc-filter-select { + width: 150px; + } + + .mpc-search { + width: 220px; + } + + .mpc-spacer { + flex: 1; + } + + .mpc-readonly-tip { + margin-left: auto; + font-size: 12px; + color: #a1a1aa; + } + + .mpc-segments { + display: inline-flex; + overflow: hidden; + background: #f5f5f5; + border: 1px solid #e7e7e7; + border-radius: 9px; + } + + .mpc-segment-item { + min-width: 108px; + height: 34px; + padding: 0 16px; + font-size: 13px; + color: #4b5563; + cursor: pointer; + background: transparent; + border: 0; + transition: all var(--mpc-transition); + } + + .mpc-segment-item.active { + font-weight: 600; + color: #1677ff; + background: #fff; + box-shadow: 0 1px 4px rgb(0 0 0 / 8%); + } + + .mpc-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .mpc-record-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + + .mpc-stat-card { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mpc-border); + border-radius: 10px; + box-shadow: var(--mpc-shadow-sm); + transition: box-shadow var(--mpc-transition); + } + + .mpc-stat-card:hover { + box-shadow: var(--mpc-shadow-md); + } + + .mpc-stat-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 18px; + border-radius: 8px; + } + + .mpc-stat-blue { + color: #1677ff; + background: #e6f4ff; + } + + .mpc-stat-cyan { + color: #0891b2; + background: #ecfeff; + } + + .mpc-stat-green { + color: #16a34a; + background: #dcfce7; + } + + .mpc-stat-orange { + color: #d97706; + background: #fef3c7; + } + + .mpc-stat-main { + min-width: 0; + } + + .mpc-stat-value { + overflow: hidden; + text-overflow: ellipsis; + font-size: 22px; + font-weight: 700; + line-height: 1.2; + color: var(--mpc-text); + white-space: nowrap; + } + + .mpc-stat-value-green { + color: #16a34a; + } + + .mpc-stat-value-orange { + color: #d97706; + } + + .mpc-stat-label { + margin-top: 2px; + font-size: 12px; + color: var(--mpc-muted); + } + + .mpc-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .mpc-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px solid var(--mpc-border); + border-radius: 10px; + box-shadow: var(--mpc-shadow-sm); + } + + .mpc-pagination { + display: flex; + justify-content: flex-end; + margin-top: 12px; + margin-right: 4px; + } + + .mpc-table-panel { + padding: 10px 12px; + background: #fff; + border: 1px solid var(--mpc-border); + border-radius: 12px; + box-shadow: var(--mpc-shadow-sm); + } +} diff --git a/apps/web-antd/src/views/marketing/punch-card/styles/responsive.less b/apps/web-antd/src/views/marketing/punch-card/styles/responsive.less new file mode 100644 index 0000000..9e1a33a --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/responsive.less @@ -0,0 +1,50 @@ +/** + * 文件职责:次卡页面响应式样式。 + */ +.page-marketing-punch-card { + @media (width <= 1200px) { + .mpc-toolbar { + flex-wrap: wrap; + } + + .mpc-spacer { + display: none; + } + + .mpc-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mpc-record-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (width <= 768px) { + .mpc-stats, + .mpc-record-stats { + grid-template-columns: 1fr; + } + + .mpc-card-grid { + grid-template-columns: 1fr; + } + + .mpc-card { + flex-direction: column; + } + + .mpc-card-cover { + width: 100%; + min-height: 120px; + } + + .mpc-inline-row { + grid-template-columns: 1fr; + } + + .mpc-inline-row .ant-form-item:last-child { + grid-column: span 1; + } + } +} diff --git a/apps/web-antd/src/views/marketing/punch-card/styles/table.less b/apps/web-antd/src/views/marketing/punch-card/styles/table.less new file mode 100644 index 0000000..a1f7327 --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/styles/table.less @@ -0,0 +1,56 @@ +/** + * 文件职责:次卡使用记录表格样式。 + */ +.page-marketing-punch-card { + .mpc-record-no { + font-family: ui-monospace, SFMono-Regular, menlo, monospace; + font-size: 12px; + color: #374151; + } + + .mpc-record-member { + display: flex; + flex-direction: column; + gap: 2px; + } + + .mpc-record-member-name { + font-size: 13px; + font-weight: 500; + color: #1f2937; + } + + .mpc-record-member-phone { + font-size: 12px; + color: #94a3b8; + } + + .mpc-record-remaining { + font-weight: 600; + color: #1677ff; + } + + .mpc-record-status { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 12px; + border-radius: 999px; + } + + .mpc-record-status.is-green { + color: #166534; + background: #dcfce7; + } + + .mpc-record-status.is-orange { + color: #d97706; + background: #fef3c7; + } + + .mpc-record-status.is-gray { + color: #475569; + background: #e2e8f0; + } +} diff --git a/apps/web-antd/src/views/marketing/punch-card/types.ts b/apps/web-antd/src/views/marketing/punch-card/types.ts new file mode 100644 index 0000000..b64946f --- /dev/null +++ b/apps/web-antd/src/views/marketing/punch-card/types.ts @@ -0,0 +1,145 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingPunchCardDetailDto, + MarketingPunchCardExpireStrategy, + MarketingPunchCardListItemDto, + MarketingPunchCardScopeType, + MarketingPunchCardStatsDto, + MarketingPunchCardStatus, + MarketingPunchCardTemplateOptionDto, + MarketingPunchCardUsageMode, + MarketingPunchCardUsageRecordDto, + MarketingPunchCardUsageStatsDto, + MarketingPunchCardValidityType, +} from '#/api/marketing'; +import type { ProductCategoryDto, ProductPickerItemDto } from '#/api/product'; + +/** + * 文件职责:次卡管理页面类型定义。 + */ + +/** 页面分段。 */ +export type PunchCardTabKey = 'list' | 'records'; + +/** 次卡列表筛选表单。 */ +export interface PunchCardListFilterForm { + status: '' | MarketingPunchCardStatus; +} + +/** 使用记录筛选表单。 */ +export interface PunchCardUsageFilterForm { + status: '' | 'expired' | 'normal' | 'used_up'; + templateId: string; +} + +/** 次卡适用范围表单。 */ +export interface PunchCardScopeForm { + categoryIds: string[]; + productIds: string[]; + scopeType: MarketingPunchCardScopeType; + tagIds: string[]; +} + +/** 次卡编辑抽屉表单。 */ +export interface PunchCardEditorForm { + allowTransfer: boolean; + coverImageUrl: string; + dailyLimit: null | number; + description: string; + expireStrategy: MarketingPunchCardExpireStrategy; + id: string; + name: string; + notifyChannels: string[]; + originalPrice: null | number; + perOrderLimit: null | number; + perUserPurchaseLimit: null | number; + salePrice: null | number; + scope: PunchCardScopeForm; + status: MarketingPunchCardStatus; + totalTimes: null | number; + usageCapAmount: null | number; + usageMode: MarketingPunchCardUsageMode; + validityDays: null | number; + validityType: MarketingPunchCardValidityType; + validDateRange: [Dayjs, Dayjs] | null; +} + +/** 次卡卡片视图模型。 */ +export type PunchCardTemplateCardViewModel = MarketingPunchCardListItemDto; + +/** 次卡统计视图模型。 */ +export type PunchCardStatsViewModel = MarketingPunchCardStatsDto; + +/** 使用记录项视图模型。 */ +export interface PunchCardUsageRecordViewModel extends MarketingPunchCardUsageRecordDto { + displayStatusText: string; +} + +/** 使用记录统计视图模型。 */ +export type PunchCardUsageStatsViewModel = MarketingPunchCardUsageStatsDto; + +/** 使用记录分页模型。 */ +export interface PunchCardUsagePager { + items: PunchCardUsageRecordViewModel[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 次卡下拉选项。 */ +export type PunchCardTemplateOptionViewModel = + MarketingPunchCardTemplateOptionDto; + +/** 二级抽屉分类项。 */ +export type PunchCardPickerCategoryItem = ProductCategoryDto; + +/** 二级抽屉商品项。 */ +export type PunchCardPickerProductItem = ProductPickerItemDto; + +/** 抽屉模式。 */ +export type PunchCardDrawerMode = 'create' | 'edit'; + +/** 创建默认使用记录分页。 */ +export function createDefaultPunchCardUsagePager(): PunchCardUsagePager { + return { + items: [], + page: 1, + pageSize: 10, + totalCount: 0, + }; +} + +/** 标准化详情转表单。 */ +export function createEditorFormByDetail( + detail: MarketingPunchCardDetailDto, + validDateRange: [Dayjs, Dayjs] | null, +): PunchCardEditorForm { + return { + id: detail.id, + name: detail.name, + coverImageUrl: detail.coverImageUrl ?? '', + salePrice: detail.salePrice, + originalPrice: detail.originalPrice, + totalTimes: detail.totalTimes, + validityType: detail.validityType, + validityDays: detail.validityDays, + validDateRange, + scope: { + scopeType: detail.scope.scopeType, + categoryIds: [...detail.scope.categoryIds], + tagIds: [...detail.scope.tagIds], + productIds: [...detail.scope.productIds], + }, + usageMode: detail.usageMode, + usageCapAmount: detail.usageCapAmount, + dailyLimit: detail.dailyLimit, + perOrderLimit: detail.perOrderLimit, + perUserPurchaseLimit: detail.perUserPurchaseLimit, + allowTransfer: detail.allowTransfer, + expireStrategy: detail.expireStrategy, + description: detail.description ?? '', + notifyChannels: [...detail.notifyChannels], + status: detail.status, + }; +}