From 0e043ddd7973ac18bf6913176f523ffb0f79fab9 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 2 Mar 2026 15:58:53 +0800 Subject: [PATCH] feat(@vben/web-antd): implement new customer gift page and drawers --- apps/web-antd/src/api/marketing/index.ts | 1 + .../src/api/marketing/new-customer.ts | 213 ++++++++++++ .../components/NewCustomerCouponDrawer.vue | 165 +++++++++ .../components/NewCustomerCouponGroup.vue | 80 +++++ .../NewCustomerInviteRecordTable.vue | 114 +++++++ .../components/NewCustomerStatsCards.vue | 80 +++++ .../new-customer-page/constants.ts | 131 ++++++++ .../new-customer-page/data-actions.ts | 158 +++++++++ .../new-customer-page/drawer-actions.ts | 172 ++++++++++ .../new-customer-page/editor-actions.ts | 176 ++++++++++ .../composables/new-customer-page/helpers.ts | 156 +++++++++ .../useMarketingNewCustomerPage.ts | 170 ++++++++++ .../views/marketing/new-customer/index.vue | 316 ++++++++++++++++++ .../marketing/new-customer/styles/base.less | 13 + .../marketing/new-customer/styles/card.less | 168 ++++++++++ .../marketing/new-customer/styles/drawer.less | 100 ++++++ .../marketing/new-customer/styles/index.less | 7 + .../marketing/new-customer/styles/layout.less | 183 ++++++++++ .../new-customer/styles/responsive.less | 59 ++++ .../new-customer/styles/section.less | 95 ++++++ .../marketing/new-customer/styles/table.less | 42 +++ .../src/views/marketing/new-customer/types.ts | 75 +++++ 22 files changed, 2674 insertions(+) create mode 100644 apps/web-antd/src/api/marketing/new-customer.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponDrawer.vue create mode 100644 apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponGroup.vue create mode 100644 apps/web-antd/src/views/marketing/new-customer/components/NewCustomerInviteRecordTable.vue create mode 100644 apps/web-antd/src/views/marketing/new-customer/components/NewCustomerStatsCards.vue create mode 100644 apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/constants.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/data-actions.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/editor-actions.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/helpers.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/composables/useMarketingNewCustomerPage.ts create mode 100644 apps/web-antd/src/views/marketing/new-customer/index.vue create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/base.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/card.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/drawer.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/index.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/layout.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/responsive.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/section.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/styles/table.less create mode 100644 apps/web-antd/src/views/marketing/new-customer/types.ts diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts index 5765ce1..5cccbab 100644 --- a/apps/web-antd/src/api/marketing/index.ts +++ b/apps/web-antd/src/api/marketing/index.ts @@ -185,4 +185,5 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { export * from './flash-sale'; export * from './full-reduction'; +export * from './new-customer'; export * from './seckill'; diff --git a/apps/web-antd/src/api/marketing/new-customer.ts b/apps/web-antd/src/api/marketing/new-customer.ts new file mode 100644 index 0000000..dc40514 --- /dev/null +++ b/apps/web-antd/src/api/marketing/new-customer.ts @@ -0,0 +1,213 @@ +/** + * 文件职责:营销中心新客有礼 API 与 DTO 定义。 + * 1. 维护新客有礼详情、配置保存、邀请记录分页与写入契约。 + */ +import { requestClient } from '#/api/request'; + +/** 礼包类型。 */ +export type MarketingNewCustomerGiftType = 'coupon' | 'direct'; + +/** 券场景。 */ +export type MarketingNewCustomerCouponScene = 'invitee' | 'inviter' | 'welcome'; + +/** 券类型。 */ +export type MarketingNewCustomerCouponType = + | 'amount_off' + | 'discount' + | 'free_shipping'; + +/** 分享渠道。 */ +export type MarketingNewCustomerShareChannel = + | 'moments' + | 'sms' + | 'wechat_friend'; + +/** 邀请订单状态。 */ +export type MarketingNewCustomerInviteOrderStatus = 'ordered' | 'pending_order'; + +/** 邀请奖励状态。 */ +export type MarketingNewCustomerInviteRewardStatus = 'issued' | 'pending'; + +/** 优惠券规则。 */ +export interface MarketingNewCustomerCouponRuleDto { + couponType: MarketingNewCustomerCouponType; + id: string; + minimumSpend: null | number; + scene: MarketingNewCustomerCouponScene; + sortOrder: number; + validDays: number; + value: null | number; +} + +/** 新客有礼设置。 */ +export interface MarketingNewCustomerSettingsDto { + directMinimumSpend: null | number; + directReduceAmount: null | number; + giftEnabled: boolean; + giftType: MarketingNewCustomerGiftType; + inviteEnabled: boolean; + inviteeCoupons: MarketingNewCustomerCouponRuleDto[]; + inviterCoupons: MarketingNewCustomerCouponRuleDto[]; + shareChannels: MarketingNewCustomerShareChannel[]; + storeId: string; + updatedAt: string; + welcomeCoupons: MarketingNewCustomerCouponRuleDto[]; +} + +/** 统计数据。 */ +export interface MarketingNewCustomerStatsDto { + firstOrderConversionRate: number; + firstOrderedCount: number; + giftClaimRate: number; + giftClaimedCount: number; + monthlyGrowthCount: number; + monthlyGrowthRatePercent: number; + monthlyNewCustomers: number; +} + +/** 邀请记录。 */ +export interface MarketingNewCustomerInviteRecordDto { + id: string; + inviteeName: string; + inviteTime: string; + inviterName: string; + orderStatus: MarketingNewCustomerInviteOrderStatus; + rewardIssuedAt: null | string; + rewardStatus: MarketingNewCustomerInviteRewardStatus; + sourceChannel?: string; +} + +/** 邀请记录分页。 */ +export interface MarketingNewCustomerInviteRecordListResultDto { + items: MarketingNewCustomerInviteRecordDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 新客有礼详情。 */ +export interface MarketingNewCustomerDetailDto { + inviteRecords: MarketingNewCustomerInviteRecordListResultDto; + settings: MarketingNewCustomerSettingsDto; + stats: MarketingNewCustomerStatsDto; +} + +/** 新客有礼详情查询。 */ +export interface MarketingNewCustomerDetailQuery { + recordPage?: number; + recordPageSize?: number; + storeId: string; +} + +/** 邀请记录查询。 */ +export interface MarketingNewCustomerInviteRecordListQuery { + page: number; + pageSize: number; + storeId: string; +} + +/** 保存券规则项。 */ +export interface SaveMarketingNewCustomerCouponRuleDto { + couponType: MarketingNewCustomerCouponType; + minimumSpend: null | number; + validDays: number; + value: null | number; +} + +/** 保存新客有礼配置请求。 */ +export interface SaveMarketingNewCustomerSettingsDto { + directMinimumSpend: null | number; + directReduceAmount: null | number; + giftEnabled: boolean; + giftType: MarketingNewCustomerGiftType; + inviteEnabled: boolean; + inviteeCoupons: SaveMarketingNewCustomerCouponRuleDto[]; + inviterCoupons: SaveMarketingNewCustomerCouponRuleDto[]; + shareChannels: MarketingNewCustomerShareChannel[]; + storeId: string; + welcomeCoupons: SaveMarketingNewCustomerCouponRuleDto[]; +} + +/** 写入邀请记录请求。 */ +export interface WriteMarketingNewCustomerInviteRecordDto { + inviteeName: string; + inviteTime: string; + inviterName: string; + orderStatus: MarketingNewCustomerInviteOrderStatus; + rewardIssuedAt: null | string; + rewardStatus: MarketingNewCustomerInviteRewardStatus; + sourceChannel?: string; + storeId: string; +} + +/** 新客成长记录。 */ +export interface MarketingNewCustomerGrowthRecordDto { + customerKey: string; + customerName?: string; + firstOrderAt: null | string; + giftClaimedAt: null | string; + id: string; + registeredAt: string; + sourceChannel?: string; +} + +/** 写入成长记录请求。 */ +export interface WriteMarketingNewCustomerGrowthRecordDto { + customerKey: string; + customerName?: string; + firstOrderAt: null | string; + giftClaimedAt: null | string; + registeredAt: string; + sourceChannel?: string; + storeId: string; +} + +/** 获取新客有礼详情。 */ +export async function getMarketingNewCustomerDetailApi( + params: MarketingNewCustomerDetailQuery, +) { + return requestClient.get( + '/marketing/new-customer/detail', + { params }, + ); +} + +/** 保存新客有礼配置。 */ +export async function saveMarketingNewCustomerSettingsApi( + data: SaveMarketingNewCustomerSettingsDto, +) { + return requestClient.post( + '/marketing/new-customer/save', + data, + ); +} + +/** 获取邀请记录分页。 */ +export async function getMarketingNewCustomerInviteRecordListApi( + params: MarketingNewCustomerInviteRecordListQuery, +) { + return requestClient.get( + '/marketing/new-customer/invite-record/list', + { params }, + ); +} + +/** 写入邀请记录。 */ +export async function writeMarketingNewCustomerInviteRecordApi( + data: WriteMarketingNewCustomerInviteRecordDto, +) { + return requestClient.post( + '/marketing/new-customer/invite-record/write', + data, + ); +} + +/** 写入成长记录。 */ +export async function writeMarketingNewCustomerGrowthRecordApi( + data: WriteMarketingNewCustomerGrowthRecordDto, +) { + return requestClient.post( + '/marketing/new-customer/growth-record/write', + data, + ); +} diff --git a/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponDrawer.vue b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponDrawer.vue new file mode 100644 index 0000000..9d55934 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponDrawer.vue @@ -0,0 +1,165 @@ + + + diff --git a/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponGroup.vue b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponGroup.vue new file mode 100644 index 0000000..8b3d00b --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerCouponGroup.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerInviteRecordTable.vue b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerInviteRecordTable.vue new file mode 100644 index 0000000..86ebb05 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerInviteRecordTable.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerStatsCards.vue b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerStatsCards.vue new file mode 100644 index 0000000..628527f --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/components/NewCustomerStatsCards.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/constants.ts b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/constants.ts new file mode 100644 index 0000000..1220ee9 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/constants.ts @@ -0,0 +1,131 @@ +import type { + MarketingNewCustomerCouponScene, + MarketingNewCustomerCouponType, + MarketingNewCustomerGiftType, + MarketingNewCustomerInviteOrderStatus, + MarketingNewCustomerInviteRewardStatus, + MarketingNewCustomerShareChannel, +} from '#/api/marketing'; +import type { + NewCustomerCouponDrawerForm, + NewCustomerInviteRecordPager, + NewCustomerPageForm, + NewCustomerStatsViewModel, +} from '#/views/marketing/new-customer/types'; + +/** + * 文件职责:新客有礼页面常量与默认值。 + */ + +/** 查看权限码。 */ +export const NEW_CUSTOMER_VIEW_PERMISSION = + 'tenant:marketing:new-customer:view'; + +/** 管理权限码。 */ +export const NEW_CUSTOMER_MANAGE_PERMISSION = + 'tenant:marketing:new-customer:manage'; + +/** 礼包类型选项。 */ +export const NEW_CUSTOMER_GIFT_TYPE_OPTIONS: Array<{ + label: string; + value: MarketingNewCustomerGiftType; +}> = [ + { label: '优惠券包', value: 'coupon' }, + { label: '首单直减', value: 'direct' }, +]; + +/** 分享渠道选项。 */ +export const NEW_CUSTOMER_SHARE_CHANNEL_OPTIONS: Array<{ + label: string; + value: MarketingNewCustomerShareChannel; +}> = [ + { label: '微信好友', value: 'wechat_friend' }, + { label: '朋友圈', value: 'moments' }, + { label: '短信', value: 'sms' }, +]; + +/** 券类型选项。 */ +export const NEW_CUSTOMER_COUPON_TYPE_OPTIONS: Array<{ + label: string; + value: MarketingNewCustomerCouponType; +}> = [ + { label: '满减券', value: 'amount_off' }, + { label: '折扣券', value: 'discount' }, + { label: '免配送费', value: 'free_shipping' }, +]; + +/** 券场景标题。 */ +export const NEW_CUSTOMER_COUPON_SCENE_TITLE_MAP: Record< + MarketingNewCustomerCouponScene, + string +> = { + welcome: '新客礼包券', + inviter: '邀请人奖励券', + invitee: '被邀请人奖励券', +}; + +/** 邀请记录订单状态文案。 */ +export const NEW_CUSTOMER_ORDER_STATUS_TEXT_MAP: Record< + MarketingNewCustomerInviteOrderStatus, + string +> = { + pending_order: '待下单', + ordered: '已下单', +}; + +/** 邀请记录奖励状态文案。 */ +export const NEW_CUSTOMER_REWARD_STATUS_TEXT_MAP: Record< + MarketingNewCustomerInviteRewardStatus, + string +> = { + pending: '待触发', + issued: '已发放', +}; + +/** 创建默认页面表单。 */ +export function createDefaultNewCustomerPageForm(): NewCustomerPageForm { + return { + giftEnabled: true, + giftType: 'coupon', + directReduceAmount: 10, + directMinimumSpend: 30, + inviteEnabled: true, + shareChannels: ['wechat_friend', 'moments'], + welcomeCoupons: [], + inviterCoupons: [], + inviteeCoupons: [], + }; +} + +/** 创建默认统计。 */ +export function createDefaultNewCustomerStats(): NewCustomerStatsViewModel { + return { + monthlyNewCustomers: 0, + monthlyGrowthCount: 0, + monthlyGrowthRatePercent: 0, + giftClaimRate: 0, + giftClaimedCount: 0, + firstOrderConversionRate: 0, + firstOrderedCount: 0, + }; +} + +/** 创建默认邀请记录分页。 */ +export function createDefaultNewCustomerInvitePager(): NewCustomerInviteRecordPager { + return { + items: [], + page: 1, + pageSize: 10, + totalCount: 0, + }; +} + +/** 创建默认券抽屉表单。 */ +export function createDefaultNewCustomerCouponDrawerForm(): NewCustomerCouponDrawerForm { + return { + couponType: 'amount_off', + value: null, + minimumSpend: null, + validDays: 7, + }; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/data-actions.ts b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/data-actions.ts new file mode 100644 index 0000000..37a5001 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/data-actions.ts @@ -0,0 +1,158 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +import type { + NewCustomerInviteRecordPager, + NewCustomerPageForm, + NewCustomerStatsViewModel, +} from '#/views/marketing/new-customer/types'; + +/** + * 文件职责:新客有礼页面数据加载动作。 + * 1. 加载门店、详情与邀请记录分页。 + */ +import { message } from 'ant-design-vue'; + +import { + getMarketingNewCustomerDetailApi, + getMarketingNewCustomerInviteRecordListApi, +} from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +import { + createDefaultNewCustomerInvitePager, + createDefaultNewCustomerStats, +} from './constants'; +import { mapSettingsToPageForm } from './helpers'; + +interface CreateDataActionsOptions { + form: NewCustomerPageForm; + invitePager: Ref; + isDetailLoading: Ref; + isInviteLoading: Ref; + isStoreLoading: Ref; + selectedStoreId: Ref; + stats: Ref; + stores: Ref; + updatedAt: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + function applyForm(next: NewCustomerPageForm) { + options.form.giftEnabled = next.giftEnabled; + options.form.giftType = next.giftType; + options.form.directReduceAmount = next.directReduceAmount; + options.form.directMinimumSpend = next.directMinimumSpend; + options.form.inviteEnabled = next.inviteEnabled; + options.form.shareChannels = [...next.shareChannels]; + options.form.welcomeCoupons = [...next.welcomeCoupons]; + options.form.inviterCoupons = [...next.inviterCoupons]; + options.form.inviteeCoupons = [...next.inviteeCoupons]; + } + + 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 = ''; + 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 loadDetail(resetForm: boolean) { + if (!options.selectedStoreId.value) { + options.stats.value = createDefaultNewCustomerStats(); + options.invitePager.value = createDefaultNewCustomerInvitePager(); + options.updatedAt.value = ''; + return; + } + + options.isDetailLoading.value = true; + try { + const detail = await getMarketingNewCustomerDetailApi({ + storeId: options.selectedStoreId.value, + recordPage: options.invitePager.value.page, + recordPageSize: options.invitePager.value.pageSize, + }); + + options.stats.value = detail.stats; + options.updatedAt.value = detail.settings.updatedAt; + + if (resetForm) { + applyForm(mapSettingsToPageForm(detail.settings)); + } + + options.invitePager.value = { + items: detail.inviteRecords.items ?? [], + page: detail.inviteRecords.page, + pageSize: detail.inviteRecords.pageSize, + totalCount: detail.inviteRecords.totalCount, + }; + } catch (error) { + console.error(error); + options.stats.value = createDefaultNewCustomerStats(); + options.invitePager.value = createDefaultNewCustomerInvitePager(); + message.error('加载新客有礼失败'); + } finally { + options.isDetailLoading.value = false; + } + } + + async function loadInviteRecords() { + if (!options.selectedStoreId.value) { + options.invitePager.value = createDefaultNewCustomerInvitePager(); + return; + } + + options.isInviteLoading.value = true; + try { + const result = await getMarketingNewCustomerInviteRecordListApi({ + storeId: options.selectedStoreId.value, + page: options.invitePager.value.page, + pageSize: options.invitePager.value.pageSize, + }); + + options.invitePager.value = { + items: result.items ?? [], + page: result.page, + pageSize: result.pageSize, + totalCount: result.totalCount, + }; + } catch (error) { + console.error(error); + options.invitePager.value = { + ...options.invitePager.value, + items: [], + totalCount: 0, + }; + message.error('加载邀请记录失败'); + } finally { + options.isInviteLoading.value = false; + } + } + + return { + loadDetail, + loadInviteRecords, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/drawer-actions.ts new file mode 100644 index 0000000..975e4e9 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/drawer-actions.ts @@ -0,0 +1,172 @@ +import type { ComputedRef } from 'vue'; + +import type { MarketingNewCustomerCouponScene } from '#/api/marketing'; +import type { + NewCustomerCouponDrawerForm, + NewCustomerPageForm, +} from '#/views/marketing/new-customer/types'; + +/** + * 文件职责:新客有礼券抽屉动作。 + * 1. 管理抽屉开关、字段更新与添加券行为。 + */ +import { reactive, ref } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { createDefaultNewCustomerCouponDrawerForm } from './constants'; +import { normalizeCouponSort, toNullableNumber } from './helpers'; + +interface CreateDrawerActionsOptions { + canManage: ComputedRef; + form: NewCustomerPageForm; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + const isCouponDrawerOpen = ref(false); + const couponDrawerScene = ref('welcome'); + const couponDrawerForm = reactive( + createDefaultNewCustomerCouponDrawerForm(), + ); + + function setCouponDrawerOpen(value: boolean) { + isCouponDrawerOpen.value = value; + } + + function setCouponDrawerType( + value: NewCustomerCouponDrawerForm['couponType'], + ) { + couponDrawerForm.couponType = value; + if (value === 'free_shipping') { + couponDrawerForm.value = null; + } + } + + function setCouponDrawerValue(value: null | number) { + couponDrawerForm.value = value; + } + + function setCouponDrawerMinimumSpend(value: null | number) { + couponDrawerForm.minimumSpend = value; + } + + function setCouponDrawerValidDays(value: null | number) { + couponDrawerForm.validDays = Math.max(1, Math.min(365, Number(value || 1))); + } + + function openCouponDrawer(scene: MarketingNewCustomerCouponScene) { + if (!options.canManage.value) { + return; + } + + resetCouponDrawerForm(); + couponDrawerScene.value = scene; + isCouponDrawerOpen.value = true; + } + + function closeCouponDrawer() { + isCouponDrawerOpen.value = false; + } + + function submitCouponDrawer() { + if (!validateBeforeSubmit()) { + return; + } + + const next = { + id: `draft-${Date.now()}-${Math.round(Math.random() * 1000)}`, + scene: couponDrawerScene.value, + couponType: couponDrawerForm.couponType, + value: + couponDrawerForm.couponType === 'free_shipping' + ? null + : toNullableNumber(couponDrawerForm.value), + minimumSpend: toNullableNumber(couponDrawerForm.minimumSpend), + validDays: Math.max( + 1, + Math.min(365, Math.floor(couponDrawerForm.validDays)), + ), + sortOrder: 0, + }; + + if (couponDrawerScene.value === 'welcome') { + options.form.welcomeCoupons = normalizeCouponSort('welcome', [ + ...options.form.welcomeCoupons, + next, + ]); + } else if (couponDrawerScene.value === 'inviter') { + options.form.inviterCoupons = normalizeCouponSort('inviter', [ + ...options.form.inviterCoupons, + next, + ]); + } else { + options.form.inviteeCoupons = normalizeCouponSort('invitee', [ + ...options.form.inviteeCoupons, + next, + ]); + } + + isCouponDrawerOpen.value = false; + } + + function validateBeforeSubmit() { + const minimumSpend = toNullableNumber(couponDrawerForm.minimumSpend) ?? 0; + if (minimumSpend < 0) { + message.warning('使用门槛不能小于 0'); + return false; + } + + if (couponDrawerForm.validDays < 1 || couponDrawerForm.validDays > 365) { + message.warning('有效期必须在 1-365 天之间'); + return false; + } + + if (couponDrawerForm.couponType === 'free_shipping') { + return true; + } + + const value = toNullableNumber(couponDrawerForm.value); + if (value === null || value <= 0) { + message.warning('请填写正确的券面额'); + return false; + } + + if (couponDrawerForm.couponType === 'discount' && value >= 10) { + message.warning('折扣券面额必须小于 10'); + return false; + } + + if ( + couponDrawerForm.couponType === 'amount_off' && + minimumSpend > 0 && + value >= minimumSpend + ) { + message.warning('满减券减免金额必须小于使用门槛'); + return false; + } + + return true; + } + + function resetCouponDrawerForm() { + const next = createDefaultNewCustomerCouponDrawerForm(); + couponDrawerForm.couponType = next.couponType; + couponDrawerForm.value = next.value; + couponDrawerForm.minimumSpend = next.minimumSpend; + couponDrawerForm.validDays = next.validDays; + } + + return { + closeCouponDrawer, + couponDrawerForm, + couponDrawerScene, + isCouponDrawerOpen, + openCouponDrawer, + setCouponDrawerMinimumSpend, + setCouponDrawerOpen, + setCouponDrawerType, + setCouponDrawerValidDays, + setCouponDrawerValue, + submitCouponDrawer, + }; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/editor-actions.ts b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/editor-actions.ts new file mode 100644 index 0000000..91ea383 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/editor-actions.ts @@ -0,0 +1,176 @@ +import type { ComputedRef, Ref } from 'vue'; + +import type { MarketingNewCustomerCouponScene } from '#/api/marketing'; +import type { NewCustomerPageForm } from '#/views/marketing/new-customer/types'; + +/** + * 文件职责:新客有礼页面编辑与保存动作。 + * 1. 管理主表单字段更新。 + * 2. 负责保存与重置。 + */ +import { message } from 'ant-design-vue'; + +import { saveMarketingNewCustomerSettingsApi } from '#/api/marketing'; + +import { buildSaveSettingsPayload, normalizeCouponSort } from './helpers'; + +interface CreateEditorActionsOptions { + canManage: ComputedRef; + form: NewCustomerPageForm; + isSaving: Ref; + loadDetail: (resetForm: boolean) => Promise; + selectedStoreId: Ref; +} + +export function createEditorActions(options: CreateEditorActionsOptions) { + function setGiftEnabled(value: boolean) { + options.form.giftEnabled = value; + } + + function setGiftType(value: NewCustomerPageForm['giftType']) { + options.form.giftType = value; + } + + function setDirectReduceAmount(value: null | number) { + options.form.directReduceAmount = value; + } + + function setDirectMinimumSpend(value: null | number) { + options.form.directMinimumSpend = value; + } + + function setInviteEnabled(value: boolean) { + options.form.inviteEnabled = value; + } + + function toggleShareChannel( + channel: NewCustomerPageForm['shareChannels'][number], + ) { + if (options.form.shareChannels.includes(channel)) { + options.form.shareChannels = options.form.shareChannels.filter( + (item) => item !== channel, + ); + return; + } + + options.form.shareChannels = [...options.form.shareChannels, channel]; + } + + function removeCoupon(scene: MarketingNewCustomerCouponScene, index: number) { + const list = resolveCouponList(scene).filter((_, idx) => idx !== index); + applyCouponList(scene, list); + } + + async function resetSettings() { + await options.loadDetail(true); + } + + async function saveSettings() { + if (!options.canManage.value) { + return; + } + + if (!options.selectedStoreId.value) { + return; + } + + if (!validateBeforeSubmit()) { + return; + } + + options.isSaving.value = true; + try { + await saveMarketingNewCustomerSettingsApi( + buildSaveSettingsPayload(options.form, options.selectedStoreId.value), + ); + message.success('新客有礼设置已保存'); + await options.loadDetail(true); + } catch (error) { + console.error(error); + } finally { + options.isSaving.value = false; + } + } + + function validateBeforeSubmit() { + if (options.form.shareChannels.length === 0) { + message.warning('请至少选择一个分享渠道'); + return false; + } + + if ( + options.form.giftType === 'coupon' && + options.form.welcomeCoupons.length === 0 + ) { + message.warning('新客礼包至少需要添加一张优惠券'); + return false; + } + + if (options.form.giftType === 'direct') { + if ( + options.form.directReduceAmount === null || + options.form.directReduceAmount <= 0 + ) { + message.warning('首单直减金额必须大于 0'); + return false; + } + + if ( + options.form.directMinimumSpend === null || + options.form.directMinimumSpend < 0 + ) { + message.warning('首单直减使用门槛不能小于 0'); + return false; + } + } + + if ( + options.form.inviteEnabled && + (options.form.inviterCoupons.length === 0 || + options.form.inviteeCoupons.length === 0) + ) { + message.warning('开启老带新后必须配置邀请人和被邀请人奖励券'); + return false; + } + + return true; + } + + function resolveCouponList(scene: MarketingNewCustomerCouponScene) { + if (scene === 'welcome') { + return options.form.welcomeCoupons; + } + if (scene === 'inviter') { + return options.form.inviterCoupons; + } + return options.form.inviteeCoupons; + } + + function applyCouponList( + scene: MarketingNewCustomerCouponScene, + list: NewCustomerPageForm['welcomeCoupons'], + ) { + const normalized = normalizeCouponSort(scene, list); + if (scene === 'welcome') { + options.form.welcomeCoupons = normalized; + return; + } + if (scene === 'inviter') { + options.form.inviterCoupons = normalized; + return; + } + options.form.inviteeCoupons = normalized; + } + + return { + removeCoupon, + resetSettings, + saveSettings, + setDirectMinimumSpend, + setDirectReduceAmount, + setGiftEnabled, + setGiftType, + setInviteEnabled, + toggleShareChannel, + }; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/helpers.ts b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/helpers.ts new file mode 100644 index 0000000..7c8dc88 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/composables/new-customer-page/helpers.ts @@ -0,0 +1,156 @@ +import type { + MarketingNewCustomerCouponScene, + MarketingNewCustomerSettingsDto, + SaveMarketingNewCustomerCouponRuleDto, + SaveMarketingNewCustomerSettingsDto, +} from '#/api/marketing'; +import type { + NewCustomerCouponFormItem, + NewCustomerPageForm, +} from '#/views/marketing/new-customer/types'; + +import { toCouponFormItem } from '#/views/marketing/new-customer/types'; + +/** + * 文件职责:新客有礼页面纯函数工具。 + */ + +/** 格式化百分比。 */ +export function formatPercent(value: number) { + return Number(value || 0).toFixed(1); +} + +/** 格式化整数。 */ +export function formatInteger(value: number) { + return Math.trunc(Number(value || 0)).toLocaleString('zh-CN'); +} + +/** 格式化金额。 */ +export function formatMoney(value: null | number) { + if (value === null || value === undefined) { + return '0'; + } + return Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +} + +/** 生成券标题。 */ +export function formatCouponName(item: NewCustomerCouponFormItem) { + if (item.couponType === 'free_shipping') { + return '免配送费'; + } + + if (item.couponType === 'discount') { + return `满${formatMoney(item.minimumSpend)}享${formatMoney(item.value)}折`; + } + + return `满${formatMoney(item.minimumSpend)}减${formatMoney(item.value)}`; +} + +/** 生成券描述。 */ +export function formatCouponDescription(item: NewCustomerCouponFormItem) { + if (item.couponType === 'free_shipping') { + return '无门槛,减免配送费'; + } + + if (!item.minimumSpend || item.minimumSpend <= 0) { + return '无门槛可用'; + } + + return `订单满 ${formatMoney(item.minimumSpend)} 元可用`; +} + +/** 重建排序。 */ +export function normalizeCouponSort( + scene: MarketingNewCustomerCouponScene, + items: NewCustomerCouponFormItem[], +) { + return items.map((item, index) => ({ + ...item, + scene, + sortOrder: index + 1, + })); +} + +/** 拷贝券列表。 */ +export function cloneCouponItems(items: NewCustomerCouponFormItem[]) { + return items.map((item) => ({ ...item })); +} + +/** 应用后端设置到页面表单。 */ +export function mapSettingsToPageForm( + settings: MarketingNewCustomerSettingsDto, +): NewCustomerPageForm { + return { + giftEnabled: settings.giftEnabled, + giftType: settings.giftType, + directReduceAmount: settings.directReduceAmount, + directMinimumSpend: settings.directMinimumSpend, + inviteEnabled: settings.inviteEnabled, + shareChannels: [...settings.shareChannels], + welcomeCoupons: normalizeCouponSort( + 'welcome', + settings.welcomeCoupons.map((item) => toCouponFormItem(item)), + ), + inviterCoupons: normalizeCouponSort( + 'inviter', + settings.inviterCoupons.map((item) => toCouponFormItem(item)), + ), + inviteeCoupons: normalizeCouponSort( + 'invitee', + settings.inviteeCoupons.map((item) => toCouponFormItem(item)), + ), + }; +} + +/** 构建保存请求。 */ +export function buildSaveSettingsPayload( + form: NewCustomerPageForm, + storeId: string, +): SaveMarketingNewCustomerSettingsDto { + return { + storeId, + giftEnabled: form.giftEnabled, + giftType: form.giftType, + directReduceAmount: + form.giftType === 'direct' + ? toNullableNumber(form.directReduceAmount) + : null, + directMinimumSpend: + form.giftType === 'direct' + ? toNullableNumber(form.directMinimumSpend) + : null, + inviteEnabled: form.inviteEnabled, + shareChannels: [...form.shareChannels], + welcomeCoupons: buildSaveCouponRules(form.welcomeCoupons), + inviterCoupons: buildSaveCouponRules(form.inviterCoupons), + inviteeCoupons: buildSaveCouponRules(form.inviteeCoupons), + }; +} + +/** 构建保存券列表。 */ +export function buildSaveCouponRules( + items: NewCustomerCouponFormItem[], +): SaveMarketingNewCustomerCouponRuleDto[] { + return items.map((item) => ({ + couponType: item.couponType, + value: + item.couponType === 'free_shipping' ? null : toNullableNumber(item.value), + minimumSpend: toNullableNumber(item.minimumSpend), + validDays: Math.max(1, Math.min(365, Math.floor(item.validDays))), + })); +} + +/** 解析可空数字。 */ +export function toNullableNumber(value: null | number | string) { + if (value === null || value === undefined || value === '') { + return null; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return null; + } + return parsed; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/composables/useMarketingNewCustomerPage.ts b/apps/web-antd/src/views/marketing/new-customer/composables/useMarketingNewCustomerPage.ts new file mode 100644 index 0000000..4c4de16 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/composables/useMarketingNewCustomerPage.ts @@ -0,0 +1,170 @@ +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:新客有礼页面状态与行为编排。 + * 1. 管理门店、统计、配置表单与邀请记录分页状态。 + * 2. 编排保存动作与优惠券抽屉动作。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultNewCustomerInvitePager, + createDefaultNewCustomerPageForm, + createDefaultNewCustomerStats, + NEW_CUSTOMER_MANAGE_PERMISSION, +} from './new-customer-page/constants'; +import { createDataActions } from './new-customer-page/data-actions'; +import { createDrawerActions } from './new-customer-page/drawer-actions'; +import { createEditorActions } from './new-customer-page/editor-actions'; + +export function useMarketingNewCustomerPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const isDetailLoading = ref(false); + const isInviteLoading = ref(false); + const isSaving = ref(false); + + const form = reactive(createDefaultNewCustomerPageForm()); + const stats = ref(createDefaultNewCustomerStats()); + const invitePager = ref(createDefaultNewCustomerInvitePager()); + const updatedAt = ref(''); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + const hasStore = computed(() => stores.value.length > 0); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(NEW_CUSTOMER_MANAGE_PERMISSION), + ); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setInvitePagerPage(page: number) { + invitePager.value = { + ...invitePager.value, + page, + }; + } + + function setInvitePagerPageSize(pageSize: number) { + invitePager.value = { + ...invitePager.value, + pageSize, + }; + } + + const { loadStores, loadDetail, loadInviteRecords } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + isDetailLoading, + isInviteLoading, + form, + stats, + invitePager, + updatedAt, + }); + + const { + removeCoupon, + resetSettings, + saveSettings, + setDirectMinimumSpend, + setDirectReduceAmount, + setGiftEnabled, + setGiftType, + setInviteEnabled, + toggleShareChannel, + } = createEditorActions({ + canManage, + form, + isSaving, + loadDetail, + selectedStoreId, + }); + + const { + closeCouponDrawer, + couponDrawerForm, + couponDrawerScene, + isCouponDrawerOpen, + openCouponDrawer, + setCouponDrawerMinimumSpend, + setCouponDrawerType, + setCouponDrawerValidDays, + setCouponDrawerValue, + submitCouponDrawer, + } = createDrawerActions({ + canManage, + form, + }); + + async function handleInvitePageChange(page: number, pageSize: number) { + setInvitePagerPage(page); + setInvitePagerPageSize(pageSize); + await loadInviteRecords(); + } + + watch(selectedStoreId, async () => { + invitePager.value = { + ...invitePager.value, + page: 1, + }; + await loadDetail(true); + }); + + onMounted(async () => { + await loadStores(); + }); + + return { + canManage, + closeCouponDrawer, + couponDrawerForm, + couponDrawerScene, + form, + handleInvitePageChange, + hasStore, + invitePager, + isCouponDrawerOpen, + isDetailLoading, + isInviteLoading, + isSaving, + isStoreLoading, + openCouponDrawer, + removeCoupon, + resetSettings, + saveSettings, + selectedStoreId, + setCouponDrawerMinimumSpend, + setCouponDrawerType, + setCouponDrawerValidDays, + setCouponDrawerValue, + setDirectMinimumSpend, + setDirectReduceAmount, + setGiftEnabled, + setGiftType, + setInviteEnabled, + setSelectedStoreId, + stats, + storeOptions, + submitCouponDrawer, + toggleShareChannel, + updatedAt, + }; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/index.vue b/apps/web-antd/src/views/marketing/new-customer/index.vue new file mode 100644 index 0000000..2cef786 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/index.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/base.less b/apps/web-antd/src/views/marketing/new-customer/styles/base.less new file mode 100644 index 0000000..1ce1cd5 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/base.less @@ -0,0 +1,13 @@ +/** + * 文件职责:新客有礼页面基础变量样式。 + */ +.page-marketing-new-customer { + --mnc-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --mnc-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --mnc-shadow-md: 0 8px 20px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 6%); + --mnc-border: #e7eaf0; + --mnc-text: #1f2937; + --mnc-subtext: #6b7280; + --mnc-muted: #9ca3af; + --mnc-primary: #1677ff; +} diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/card.less b/apps/web-antd/src/views/marketing/new-customer/styles/card.less new file mode 100644 index 0000000..7cdc706 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/card.less @@ -0,0 +1,168 @@ +/** + * 文件职责:新客有礼券列表与交互控件样式。 + */ +.page-marketing-new-customer { + .mnc-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 var(--mnc-transition); + } + + .mnc-pill:hover { + color: var(--mnc-primary); + border-color: #91caff; + } + + .mnc-pill.checked { + color: var(--mnc-primary); + background: #e8f3ff; + border-color: #91caff; + } + + .mnc-pill:disabled { + color: #b7bcc7; + cursor: not-allowed; + background: #fafafa; + border-color: #e5e7eb; + } + + .mnc-coupon-group { + display: flex; + flex-direction: column; + gap: 10px; + } + + .mnc-coupon-group-title { + display: flex; + gap: 8px; + align-items: baseline; + font-size: 13px; + font-weight: 500; + color: var(--mnc-text); + } + + .mnc-coupon-group-desc { + font-size: 12px; + font-weight: 400; + color: var(--mnc-muted); + } + + .mnc-coupon-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .mnc-coupon-item { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mnc-border); + border-left: 3px solid var(--mnc-primary); + border-radius: 8px; + transition: box-shadow var(--mnc-transition); + } + + .mnc-coupon-item:nth-child(2) { + border-left-color: #16a34a; + } + + .mnc-coupon-item:nth-child(3) { + border-left-color: #d97706; + } + + .mnc-coupon-item:hover { + box-shadow: var(--mnc-shadow-sm); + } + + .mnc-coupon-info { + flex: 1; + min-width: 0; + } + + .mnc-coupon-name { + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; + font-weight: 600; + color: var(--mnc-text); + white-space: nowrap; + } + + .mnc-coupon-desc { + margin-top: 2px; + font-size: 11px; + color: var(--mnc-muted); + } + + .mnc-coupon-validity { + display: inline-flex; + gap: 4px; + align-items: center; + font-size: 12px; + color: var(--mnc-subtext); + } + + .mnc-coupon-validity .iconify { + width: 12px; + height: 12px; + } + + .mnc-coupon-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + } + + .mnc-coupon-remove .iconify { + width: 14px; + height: 14px; + } + + .mnc-coupon-empty .ant-empty-image { + height: 32px; + } + + .mnc-coupon-empty .ant-empty-description { + margin: 0; + font-size: 12px; + } + + .mnc-add-coupon-btn { + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: center; + width: 100%; + padding: 10px 12px; + font-size: 13px; + color: var(--mnc-subtext); + cursor: pointer; + background: none; + border: 2px dashed #d9d9d9; + border-radius: 8px; + transition: all var(--mnc-transition); + } + + .mnc-add-coupon-btn:hover { + color: var(--mnc-primary); + background: #f5f9ff; + border-color: var(--mnc-primary); + } + + .mnc-add-coupon-btn .iconify { + width: 14px; + height: 14px; + } +} diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/drawer.less b/apps/web-antd/src/views/marketing/new-customer/styles/drawer.less new file mode 100644 index 0000000..207f34f --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/drawer.less @@ -0,0 +1,100 @@ +/** + * 文件职责:新客有礼券抽屉样式。 + */ +.mnc-coupon-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; + } + + .mnc-coupon-drawer-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-number { + border-color: #e5e7eb !important; + border-radius: 6px !important; + } + + .ant-input-number-input { + font-size: 13px; + } + + .ant-input-number:focus-within, + .ant-radio-group + .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + border-color: #1677ff !important; + box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important; + } + + .mnc-inline-fields { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 13px; + color: #4b5563; + } + + .mnc-inline-fields .ant-input-number { + width: 140px; + } + + .mnc-field-hint { + margin-top: 4px; + font-size: 11px; + color: #9ca3af; + } + + .mnc-shipping-tip { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + padding: 12px; + font-size: 13px; + color: #6b7280; + background: #f8f9fb; + border-radius: 8px; + } + + .mnc-shipping-tip .iconify { + width: 16px; + height: 16px; + color: #9ca3af; + } + + .mnc-coupon-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-start; + } +} diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/index.less b/apps/web-antd/src/views/marketing/new-customer/styles/index.less new file mode 100644 index 0000000..3080b47 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/index.less @@ -0,0 +1,7 @@ +@import './base.less'; +@import './layout.less'; +@import './section.less'; +@import './card.less'; +@import './drawer.less'; +@import './table.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/layout.less b/apps/web-antd/src/views/marketing/new-customer/styles/layout.less new file mode 100644 index 0000000..b3da910 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/layout.less @@ -0,0 +1,183 @@ +/** + * 文件职责:新客有礼页面布局样式。 + */ +.page-marketing-new-customer { + .mnc-page { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 960px; + } + + .mnc-toolbar { + display: flex; + gap: 12px; + align-items: center; + padding: 10px 14px; + background: #fff; + border: 1px solid var(--mnc-border); + border-radius: 10px; + box-shadow: var(--mnc-shadow-sm); + } + + .mnc-store-select { + width: 220px; + } + + .mnc-store-select .ant-select-selector { + border-radius: 8px !important; + } + + .mnc-readonly-tip { + padding-inline: 10px; + margin-left: auto; + font-size: 12px; + line-height: 26px; + color: #d97706; + background: #fff7e6; + border: 1px solid #ffd591; + border-radius: 999px; + } + + .mnc-banner { + display: flex; + gap: 12px; + align-items: center; + padding: 14px 18px; + background: linear-gradient(135deg, #eef6ff, #f8fbff); + border: 1px solid #d5e6ff; + border-radius: 10px; + } + + .mnc-banner-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--mnc-primary); + background: #deecff; + border-radius: 10px; + } + + .mnc-banner-icon .iconify { + width: 20px; + height: 20px; + } + + .mnc-banner-text { + font-size: 13px; + line-height: 1.6; + color: var(--mnc-subtext); + } + + .mnc-banner-text strong { + margin-right: 6px; + font-weight: 600; + color: var(--mnc-text); + } + + .mnc-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + + .mnc-stat-card { + padding: 18px 20px; + background: #fff; + border: 1px solid var(--mnc-border); + border-radius: 10px; + box-shadow: var(--mnc-shadow-sm); + transition: box-shadow var(--mnc-transition); + } + + .mnc-stat-card:hover { + box-shadow: var(--mnc-shadow-md); + } + + .mnc-stat-label { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + color: var(--mnc-muted); + } + + .mnc-stat-label .iconify { + width: 14px; + height: 14px; + color: var(--mnc-primary); + } + + .mnc-stat-value { + display: flex; + gap: 8px; + align-items: baseline; + font-size: 28px; + font-weight: 700; + line-height: 1.2; + color: var(--mnc-text); + } + + .mnc-stat-unit { + font-size: 13px; + font-weight: 400; + color: var(--mnc-muted); + } + + .mnc-stat-trend { + display: inline-flex; + gap: 2px; + align-items: center; + font-size: 12px; + font-weight: 500; + } + + .mnc-stat-trend.up { + color: #16a34a; + } + + .mnc-stat-trend.down { + color: #dc2626; + } + + .mnc-stat-trend .iconify { + width: 14px; + height: 14px; + } + + .mnc-stat-sub { + margin-top: 4px; + font-size: 11px; + color: var(--mnc-muted); + } + + .mnc-empty { + padding: 28px 14px; + font-size: 13px; + color: var(--mnc-muted); + text-align: center; + background: #fff; + border: 1px solid var(--mnc-border); + border-radius: 10px; + box-shadow: var(--mnc-shadow-sm); + } + + .mnc-card, + .mnc-save-card { + border: 1px solid var(--mnc-border); + border-radius: 10px; + box-shadow: var(--mnc-shadow-sm); + } + + .mnc-card .ant-card-body { + padding: 18px; + } + + .mnc-save-card .ant-card-body { + padding: 14px 18px; + } +} diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/responsive.less b/apps/web-antd/src/views/marketing/new-customer/styles/responsive.less new file mode 100644 index 0000000..76e8447 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/responsive.less @@ -0,0 +1,59 @@ +/** + * 文件职责:新客有礼页面响应式样式。 + */ +.page-marketing-new-customer { + @media (width <= 992px) { + .mnc-page { + max-width: none; + } + + .mnc-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mnc-row { + flex-direction: column; + gap: 8px; + } + + .mnc-row-label { + width: auto; + padding-top: 0; + } + } + + @media (width <= 768px) { + .mnc-toolbar { + flex-wrap: wrap; + } + + .mnc-readonly-tip { + margin-left: 0; + } + + .mnc-stats { + grid-template-columns: 1fr; + } + + .mnc-stat-value { + font-size: 24px; + } + + .mnc-coupon-item { + flex-wrap: wrap; + } + + .mnc-coupon-validity { + margin-right: auto; + } + + .mnc-save-bar { + flex-wrap: wrap; + } + + .mnc-updated-at { + width: 100%; + margin-right: 0; + } + } +} diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/section.less b/apps/web-antd/src/views/marketing/new-customer/styles/section.less new file mode 100644 index 0000000..46e8b78 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/section.less @@ -0,0 +1,95 @@ +/** + * 文件职责:新客有礼页面区块与行结构样式。 + */ +.page-marketing-new-customer { + .mnc-section-title { + padding-left: 10px; + margin-bottom: 16px; + font-size: 15px; + font-weight: 600; + color: var(--mnc-text); + border-left: 3px solid var(--mnc-primary); + } + + .mnc-row { + display: flex; + gap: 16px; + align-items: flex-start; + padding: 14px 0; + border-bottom: 1px solid #f3f4f6; + } + + .mnc-row:last-child { + border-bottom: none; + } + + .mnc-row-block { + flex-direction: column; + gap: 10px; + align-items: stretch; + } + + .mnc-row-label { + flex-shrink: 0; + width: 120px; + padding-top: 4px; + font-size: 13px; + font-weight: 500; + line-height: 22px; + color: var(--mnc-text); + } + + .mnc-row-control { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + } + + .mnc-row-control .ant-switch { + margin-right: 6px; + } + + .mnc-row-hint { + font-size: 11px; + color: var(--mnc-muted); + } + + .mnc-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .mnc-inline .ant-input-number { + width: 100px; + } + + .mnc-inline .ant-input-number-input { + font-size: 13px; + } + + .mnc-row-block-title { + font-size: 13px; + font-weight: 500; + color: var(--mnc-text); + } + + .mnc-invite-table { + width: 100%; + } + + .mnc-save-bar { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + } + + .mnc-updated-at { + margin-right: auto; + font-size: 12px; + color: var(--mnc-muted); + } +} diff --git a/apps/web-antd/src/views/marketing/new-customer/styles/table.less b/apps/web-antd/src/views/marketing/new-customer/styles/table.less new file mode 100644 index 0000000..d41c262 --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/styles/table.less @@ -0,0 +1,42 @@ +/** + * 文件职责:新客有礼邀请记录表格样式。 + */ +.page-marketing-new-customer { + .mnc-invite-table { + .ant-table-wrapper .ant-table { + overflow: hidden; + border: 1px solid #f1f3f8; + border-radius: 8px; + } + + .ant-table-wrapper .ant-table-thead > tr > th { + font-size: 12px; + color: #6b7280; + background: #fafcff; + } + } + + .mnc-status-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 9px; + font-size: 12px; + border-radius: 999px; + } + + .mnc-status-tag.is-green { + color: #166534; + background: #dcfce7; + } + + .mnc-status-tag.is-orange { + color: #c2410c; + background: #ffedd5; + } + + .mnc-status-tag.is-gray { + color: #475569; + background: #e2e8f0; + } +} diff --git a/apps/web-antd/src/views/marketing/new-customer/types.ts b/apps/web-antd/src/views/marketing/new-customer/types.ts new file mode 100644 index 0000000..08cea1d --- /dev/null +++ b/apps/web-antd/src/views/marketing/new-customer/types.ts @@ -0,0 +1,75 @@ +import type { + MarketingNewCustomerCouponRuleDto, + MarketingNewCustomerCouponScene, + MarketingNewCustomerCouponType, + MarketingNewCustomerGiftType, + MarketingNewCustomerInviteRecordDto, + MarketingNewCustomerShareChannel, + MarketingNewCustomerStatsDto, +} from '#/api/marketing'; + +/** + * 文件职责:新客有礼页面类型定义。 + */ + +/** 页面可编辑优惠券项。 */ +export interface NewCustomerCouponFormItem { + couponType: MarketingNewCustomerCouponType; + id: string; + minimumSpend: null | number; + scene: MarketingNewCustomerCouponScene; + sortOrder: number; + validDays: number; + value: null | number; +} + +/** 页面可编辑表单。 */ +export interface NewCustomerPageForm { + directMinimumSpend: null | number; + directReduceAmount: null | number; + giftEnabled: boolean; + giftType: MarketingNewCustomerGiftType; + inviteEnabled: boolean; + inviteeCoupons: NewCustomerCouponFormItem[]; + inviterCoupons: NewCustomerCouponFormItem[]; + shareChannels: MarketingNewCustomerShareChannel[]; + welcomeCoupons: NewCustomerCouponFormItem[]; +} + +/** 券抽屉表单。 */ +export interface NewCustomerCouponDrawerForm { + couponType: MarketingNewCustomerCouponType; + minimumSpend: null | number; + validDays: number; + value: null | number; +} + +/** 邀请记录分页模型。 */ +export interface NewCustomerInviteRecordPager { + items: MarketingNewCustomerInviteRecordDto[]; + page: number; + pageSize: number; + totalCount: number; +} + +/** 统计视图模型。 */ +export type NewCustomerStatsViewModel = MarketingNewCustomerStatsDto; + +/** 邀请记录视图模型。 */ +export type NewCustomerInviteRecordViewModel = + MarketingNewCustomerInviteRecordDto; + +/** 映射为页面可编辑券项。 */ +export function toCouponFormItem( + source: MarketingNewCustomerCouponRuleDto, +): NewCustomerCouponFormItem { + return { + id: source.id, + scene: source.scene, + couponType: source.couponType, + value: source.value, + minimumSpend: source.minimumSpend, + validDays: source.validDays, + sortOrder: source.sortOrder, + }; +}