diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts new file mode 100644 index 0000000..335091a --- /dev/null +++ b/apps/web-antd/src/api/marketing/index.ts @@ -0,0 +1,184 @@ +/** + * 文件职责:营销中心 API 与 DTO 定义。 + * 1. 维护优惠券列表、详情、保存、状态切换与删除契约。 + */ +import { requestClient } from '#/api/request'; + +/** 优惠券类型。 */ +export type MarketingCouponType = 'amount_off' | 'discount' | 'free_delivery'; + +/** 列表展示状态。 */ +export type MarketingCouponDisplayStatus = + | 'disabled' + | 'ended' + | 'ongoing' + | 'upcoming'; + +/** 编辑状态。 */ +export type MarketingCouponEditorStatus = 'disabled' | 'enabled'; + +/** 有效期类型。 */ +export type MarketingCouponValidityType = 'days' | 'fixed'; + +/** 适用渠道。 */ +export type MarketingCouponChannel = 'delivery' | 'dine_in' | 'pickup'; + +/** 门店范围模式。 */ +export type MarketingCouponStoreScopeMode = 'all' | 'stores'; + +/** 优惠券列表查询。 */ +export interface MarketingCouponListQuery { + couponType?: '' | MarketingCouponType; + keyword?: string; + page: number; + pageSize: number; + status?: '' | MarketingCouponDisplayStatus; + storeId: string; +} + +/** 优惠券详情查询。 */ +export interface MarketingCouponDetailQuery { + couponId: string; + storeId: string; +} + +/** 保存优惠券请求。 */ +export interface SaveMarketingCouponDto { + channels: MarketingCouponChannel[]; + couponType: MarketingCouponType; + id?: string; + minimumSpend: null | number; + name: string; + perUserLimit: null | number; + relativeValidDays: null | number; + status: MarketingCouponEditorStatus; + storeId: string; + storeIds?: string[]; + storeScopeMode: MarketingCouponStoreScopeMode; + totalQuantity: number; + validFrom: null | string; + validTo: null | string; + validityType: MarketingCouponValidityType; + value: number; +} + +/** 修改状态请求。 */ +export interface ChangeMarketingCouponStatusDto { + couponId: string; + status: MarketingCouponEditorStatus; + storeId: string; +} + +/** 删除请求。 */ +export interface DeleteMarketingCouponDto { + couponId: string; + storeId: string; +} + +/** 统计数据。 */ +export interface MarketingCouponStatsDto { + claimedCount: number; + ongoingCount: number; + redeemRate: number; + redeemedCount: number; + totalCount: number; +} + +/** 列表项。 */ +export interface MarketingCouponListItemDto { + channels: MarketingCouponChannel[]; + claimedQuantity: number; + couponType: MarketingCouponType; + displayStatus: MarketingCouponDisplayStatus; + id: string; + isDimmed: boolean; + minimumSpend: null | number; + name: string; + perUserLimit: null | number; + redeemedQuantity: number; + relativeValidDays: null | number; + storeIds: string[]; + storeScopeMode: MarketingCouponStoreScopeMode; + totalQuantity: number; + updatedAt: string; + validFrom: null | string; + validTo: null | string; + value: number; +} + +/** 列表结果。 */ +export interface MarketingCouponListResultDto { + items: MarketingCouponListItemDto[]; + page: number; + pageSize: number; + stats: MarketingCouponStatsDto; + total: number; +} + +/** 详情数据。 */ +export interface MarketingCouponDetailDto { + channels: MarketingCouponChannel[]; + claimedQuantity: number; + couponType: MarketingCouponType; + id: string; + minimumSpend: null | number; + name: string; + perUserLimit: null | number; + relativeValidDays: null | number; + status: MarketingCouponEditorStatus; + storeIds: string[]; + storeScopeMode: MarketingCouponStoreScopeMode; + totalQuantity: number; + updatedAt: string; + validFrom: null | string; + validTo: null | string; + validityType: MarketingCouponValidityType; + value: number; +} + +/** 获取优惠券列表。 */ +export async function getMarketingCouponListApi( + params: MarketingCouponListQuery, +) { + return requestClient.get( + '/marketing/coupon/list', + { + params, + }, + ); +} + +/** 获取优惠券详情。 */ +export async function getMarketingCouponDetailApi( + params: MarketingCouponDetailQuery, +) { + return requestClient.get( + '/marketing/coupon/detail', + { + params, + }, + ); +} + +/** 保存优惠券。 */ +export async function saveMarketingCouponApi(data: SaveMarketingCouponDto) { + return requestClient.post( + '/marketing/coupon/save', + data, + ); +} + +/** 修改优惠券状态。 */ +export async function changeMarketingCouponStatusApi( + data: ChangeMarketingCouponStatusDto, +) { + return requestClient.post( + '/marketing/coupon/status', + data, + ); +} + +/** 删除优惠券。 */ +export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { + return requestClient.post('/marketing/coupon/delete', data); +} diff --git a/apps/web-antd/src/views/marketing/coupon/components/CouponEditorDrawer.vue b/apps/web-antd/src/views/marketing/coupon/components/CouponEditorDrawer.vue new file mode 100644 index 0000000..7a9b2e3 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/components/CouponEditorDrawer.vue @@ -0,0 +1,389 @@ + + + diff --git a/apps/web-antd/src/views/marketing/coupon/components/CouponStatsCards.vue b/apps/web-antd/src/views/marketing/coupon/components/CouponStatsCards.vue new file mode 100644 index 0000000..1477917 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/components/CouponStatsCards.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/web-antd/src/views/marketing/coupon/components/CouponTemplateCard.vue b/apps/web-antd/src/views/marketing/coupon/components/CouponTemplateCard.vue new file mode 100644 index 0000000..c346433 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/components/CouponTemplateCard.vue @@ -0,0 +1,147 @@ + + + diff --git a/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/card-actions.ts b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/card-actions.ts new file mode 100644 index 0000000..ab89e84 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/card-actions.ts @@ -0,0 +1,74 @@ +import type { Ref } from 'vue'; + +import type { CouponCardViewModel } from '#/views/marketing/coupon/types'; + +/** + * 文件职责:优惠券卡片行操作。 + * 1. 封装启停与删除动作。 + * 2. 统一确认弹窗与成功提示。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { + changeMarketingCouponStatusApi, + deleteMarketingCouponApi, +} from '#/api/marketing'; + +interface CreateCardActionsOptions { + loadCoupons: () => Promise; + selectedStoreId: Ref; +} + +export function createCardActions(options: CreateCardActionsOptions) { + async function enableCoupon(item: CouponCardViewModel) { + await updateStatus(item, 'enabled', '优惠券已启用'); + } + + async function disableCoupon(item: CouponCardViewModel) { + await updateStatus(item, 'disabled', '优惠券已停用'); + } + + async function updateStatus( + item: CouponCardViewModel, + status: 'disabled' | 'enabled', + successMessage: string, + ) { + if (!options.selectedStoreId.value) return; + + try { + await changeMarketingCouponStatusApi({ + storeId: options.selectedStoreId.value, + couponId: item.id, + status, + }); + message.success(successMessage); + await options.loadCoupons(); + } catch (error) { + console.error(error); + } + } + + function removeCoupon(item: CouponCardViewModel) { + if (!options.selectedStoreId.value) return; + + Modal.confirm({ + title: `确认删除优惠券「${item.name}」吗?`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + await deleteMarketingCouponApi({ + storeId: options.selectedStoreId.value, + couponId: item.id, + }); + message.success('优惠券已删除'); + await options.loadCoupons(); + }, + }); + } + + return { + disableCoupon, + enableCoupon, + removeCoupon, + }; +} diff --git a/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/constants.ts b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/constants.ts new file mode 100644 index 0000000..8dd4a06 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/constants.ts @@ -0,0 +1,129 @@ +import type { + MarketingCouponChannel, + MarketingCouponDisplayStatus, + MarketingCouponStoreScopeMode, + MarketingCouponType, +} from '#/api/marketing'; +import type { + CouponEditorForm, + CouponFilterForm, +} from '#/views/marketing/coupon/types'; + +/** + * 文件职责:优惠券页面常量定义与默认表单构造。 + */ + +/** 列表状态筛选项。 */ +export const COUPON_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingCouponDisplayStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '进行中', value: 'ongoing' }, + { label: '未开始', value: 'upcoming' }, + { label: '已结束', value: 'ended' }, + { label: '已停用', value: 'disabled' }, +]; + +/** 列表类型筛选项。 */ +export const COUPON_TYPE_FILTER_OPTIONS: Array<{ + label: string; + value: '' | MarketingCouponType; +}> = [ + { label: '全部类型', value: '' }, + { label: '满减券', value: 'amount_off' }, + { label: '折扣券', value: 'discount' }, + { label: '免配送费券', value: 'free_delivery' }, +]; + +/** 编辑态券类型项。 */ +export const COUPON_EDITOR_TYPE_OPTIONS: Array<{ + label: string; + value: MarketingCouponType; +}> = [ + { label: '满减券', value: 'amount_off' }, + { label: '折扣券', value: 'discount' }, + { label: '免配送费券', value: 'free_delivery' }, +]; + +/** 有效期类型项。 */ +export const COUPON_VALIDITY_OPTIONS = [ + { label: '固定时间', value: 'fixed' }, + { label: '领取后N天', value: 'days' }, +] as const; + +/** 渠道选项。 */ +export const COUPON_CHANNEL_OPTIONS: Array<{ + label: string; + value: MarketingCouponChannel; +}> = [ + { label: '外卖', value: 'delivery' }, + { label: '自提', value: 'pickup' }, + { label: '堂食', value: 'dine_in' }, +]; + +/** 门店范围选项。 */ +export const COUPON_STORE_SCOPE_OPTIONS: Array<{ + label: string; + value: MarketingCouponStoreScopeMode; +}> = [ + { label: '全部门店', value: 'all' }, + { label: '指定门店', value: 'stores' }, +]; + +/** 列表状态文本。 */ +export const COUPON_STATUS_TEXT_MAP: Record< + MarketingCouponDisplayStatus, + string +> = { + ongoing: '进行中', + upcoming: '未开始', + ended: '已结束', + disabled: '已停用', +}; + +/** 列表状态徽标样式。 */ +export const COUPON_STATUS_TAG_CLASS_MAP: Record< + MarketingCouponDisplayStatus, + string +> = { + ongoing: 'mcp-status-ongoing', + upcoming: 'mcp-status-upcoming', + ended: 'mcp-status-ended', + disabled: 'mcp-status-disabled', +}; + +/** 渠道文本映射。 */ +export const COUPON_CHANNEL_TEXT_MAP: Record = { + delivery: '外卖', + pickup: '自提', + dine_in: '堂食', +}; + +/** 构建默认筛选表单。 */ +export function createDefaultCouponFilterForm(): CouponFilterForm { + return { + status: '', + couponType: '', + }; +} + +/** 构建默认编辑表单。 */ +export function createDefaultCouponEditorForm(): CouponEditorForm { + return { + id: '', + name: '', + couponType: 'amount_off', + value: null, + minimumSpend: null, + totalQuantity: 1000, + perUserLimit: 1, + validityType: 'fixed', + validDateRange: null, + relativeValidDays: 7, + channels: ['delivery', 'pickup', 'dine_in'], + storeScopeMode: 'all', + storeIds: [], + status: 'enabled', + }; +} diff --git a/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/data-actions.ts b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/data-actions.ts new file mode 100644 index 0000000..d92309f --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/data-actions.ts @@ -0,0 +1,113 @@ +import type { Ref } from 'vue'; + +import type { MarketingCouponStatsDto } from '#/api/marketing'; +import type { StoreListItemDto } from '#/api/store'; +import type { + CouponCardViewModel, + CouponFilterForm, +} from '#/views/marketing/coupon/types'; + +/** + * 文件职责:优惠券页面数据读取动作。 + * 1. 加载门店列表与优惠券分页列表。 + * 2. 维护分页、统计和加载态。 + */ +import { message } from 'ant-design-vue'; + +import { getMarketingCouponListApi } from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + filterForm: CouponFilterForm; + isLoading: Ref; + isStoreLoading: Ref; + keyword: Ref; + page: Ref; + pageSize: Ref; + rows: Ref; + selectedStoreId: Ref; + stats: Ref; + stores: Ref; + total: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + options.stores.value = result.items ?? []; + + if (options.stores.value.length === 0) { + options.selectedStoreId.value = ''; + options.rows.value = []; + options.total.value = 0; + return; + } + + const hasSelected = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelected) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadCoupons() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + options.total.value = 0; + return; + } + + options.isLoading.value = true; + try { + const result = await getMarketingCouponListApi({ + storeId: options.selectedStoreId.value, + status: options.filterForm.status, + couponType: options.filterForm.couponType, + keyword: options.keyword.value.trim() || undefined, + page: options.page.value, + pageSize: options.pageSize.value, + }); + + options.rows.value = result.items ?? []; + options.total.value = result.total; + options.page.value = result.page; + options.pageSize.value = result.pageSize; + options.stats.value = result.stats; + } catch (error) { + console.error(error); + options.rows.value = []; + options.total.value = 0; + options.stats.value = createEmptyStats(); + message.error('加载优惠券失败'); + } finally { + options.isLoading.value = false; + } + } + + return { + loadCoupons, + loadStores, + }; +} + +export function createEmptyStats(): MarketingCouponStatsDto { + return { + totalCount: 0, + ongoingCount: 0, + claimedCount: 0, + redeemedCount: 0, + redeemRate: 0, + }; +} diff --git a/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/drawer-actions.ts new file mode 100644 index 0000000..c43ba5b --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/drawer-actions.ts @@ -0,0 +1,274 @@ +import type { Ref } from 'vue'; + +import type { + MarketingCouponChannel, + MarketingCouponStoreScopeMode, + MarketingCouponType, + MarketingCouponValidityType, +} from '#/api/marketing'; +import type { CouponEditorForm } from '#/views/marketing/coupon/types'; + +/** + * 文件职责:优惠券编辑抽屉动作。 + * 1. 管理新增/编辑抽屉与字段更新。 + * 2. 负责详情加载、表单校验与保存提交。 + */ +import { ref } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { + getMarketingCouponDetailApi, + saveMarketingCouponApi, +} from '#/api/marketing'; + +import { createDefaultCouponEditorForm } from './constants'; +import { buildSaveCouponPayload, mapDetailToEditorForm } from './helpers'; + +interface CreateDrawerActionsOptions { + form: CouponEditorForm; + isDrawerLoading: Ref; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadCoupons: () => Promise; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + const drawerMode = ref<'create' | 'edit'>('create'); + + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function applyForm(next: CouponEditorForm) { + options.form.id = next.id; + options.form.name = next.name; + options.form.couponType = next.couponType; + options.form.value = next.value; + options.form.minimumSpend = next.minimumSpend; + options.form.totalQuantity = next.totalQuantity; + options.form.perUserLimit = next.perUserLimit; + options.form.validityType = next.validityType; + options.form.validDateRange = next.validDateRange; + options.form.relativeValidDays = next.relativeValidDays; + options.form.channels = [...next.channels]; + options.form.storeScopeMode = next.storeScopeMode; + options.form.storeIds = [...next.storeIds]; + options.form.status = next.status; + } + + function resetForm() { + applyForm(createDefaultCouponEditorForm()); + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormCouponType(value: MarketingCouponType) { + options.form.couponType = value; + if (value === 'free_delivery') { + options.form.value = null; + options.form.minimumSpend = null; + } + } + + function setFormValue(value: null | number) { + options.form.value = value; + } + + function setFormMinimumSpend(value: null | number) { + options.form.minimumSpend = value; + } + + function setFormTotalQuantity(value: null | number) { + options.form.totalQuantity = value ? Math.max(0, Math.floor(value)) : 0; + } + + function setFormPerUserLimit(value: null | number) { + if (value === null) { + options.form.perUserLimit = null; + return; + } + + options.form.perUserLimit = Math.max(0, Math.floor(value)); + } + + function setFormValidityType(value: MarketingCouponValidityType) { + options.form.validityType = value; + if (value === 'fixed') { + options.form.relativeValidDays = options.form.relativeValidDays || 7; + return; + } + + options.form.validDateRange = null; + } + + function setFormValidDateRange(value: CouponEditorForm['validDateRange']) { + options.form.validDateRange = value; + } + + function setFormRelativeValidDays(value: null | number) { + options.form.relativeValidDays = value ? Math.max(0, Math.floor(value)) : 0; + } + + function setFormChannels(value: MarketingCouponChannel[]) { + options.form.channels = [...value]; + } + + function setFormStoreScopeMode(value: MarketingCouponStoreScopeMode) { + options.form.storeScopeMode = value; + if (value === 'all') { + options.form.storeIds = []; + } + } + + function setFormStoreIds(value: string[]) { + options.form.storeIds = [...value]; + } + + function setFormStatus(checked: boolean) { + options.form.status = checked ? 'enabled' : 'disabled'; + } + + async function openCreateDrawer() { + resetForm(); + drawerMode.value = 'create'; + options.isDrawerOpen.value = true; + } + + async function openEditDrawer(couponId: string) { + if (!options.selectedStoreId.value) return; + + options.isDrawerLoading.value = true; + try { + const detail = await getMarketingCouponDetailApi({ + storeId: options.selectedStoreId.value, + couponId, + }); + + applyForm(mapDetailToEditorForm(detail)); + drawerMode.value = 'edit'; + options.isDrawerOpen.value = true; + } catch (error) { + console.error(error); + } finally { + options.isDrawerLoading.value = false; + } + } + + async function submitDrawer() { + if (!options.selectedStoreId.value) return; + if (!validateBeforeSubmit()) { + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveMarketingCouponApi( + buildSaveCouponPayload(options.form, options.selectedStoreId.value), + ); + message.success( + drawerMode.value === 'create' ? '优惠券已创建' : '优惠券已更新', + ); + options.isDrawerOpen.value = false; + await options.loadCoupons(); + } catch (error) { + console.error(error); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + function validateBeforeSubmit() { + if (!options.form.name.trim()) { + message.warning('请输入券名称'); + return false; + } + + if ( + options.form.couponType !== 'free_delivery' && + (options.form.value === null || options.form.value <= 0) + ) { + message.warning('请设置正确的优惠额度'); + return false; + } + + if ( + options.form.couponType === 'discount' && + options.form.value !== null && + options.form.value >= 10 + ) { + message.warning('折扣券面额必须小于 10'); + return false; + } + + if ( + options.form.couponType === 'amount_off' && + (options.form.minimumSpend === null || options.form.minimumSpend <= 0) + ) { + message.warning('满减券必须设置使用门槛'); + return false; + } + + if (options.form.totalQuantity <= 0) { + message.warning('发放总量必须大于 0'); + return false; + } + + if (options.form.perUserLimit !== null && options.form.perUserLimit <= 0) { + message.warning('每人限领必须大于 0'); + return false; + } + + if (options.form.validityType === 'fixed' && !options.form.validDateRange) { + message.warning('请选择固定有效期'); + return false; + } + + if ( + options.form.validityType === 'days' && + (!options.form.relativeValidDays || options.form.relativeValidDays <= 0) + ) { + message.warning('领取后有效天数必须大于 0'); + return false; + } + + if (options.form.channels.length === 0) { + message.warning('请至少选择一个适用渠道'); + return false; + } + + if ( + options.form.storeScopeMode === 'stores' && + options.form.storeIds.length === 0 + ) { + message.warning('请选择至少一个适用门店'); + return false; + } + + return true; + } + + return { + drawerMode, + openCreateDrawer, + openEditDrawer, + setDrawerOpen, + setFormChannels, + setFormCouponType, + setFormMinimumSpend, + setFormName, + setFormPerUserLimit, + setFormRelativeValidDays, + setFormStatus, + setFormStoreIds, + setFormStoreScopeMode, + setFormTotalQuantity, + setFormValidDateRange, + setFormValidityType, + setFormValue, + submitDrawer, + }; +} diff --git a/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/helpers.ts b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/helpers.ts new file mode 100644 index 0000000..52e4f37 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/composables/coupon-page/helpers.ts @@ -0,0 +1,217 @@ +import type { + MarketingCouponChannel, + MarketingCouponDetailDto, + MarketingCouponType, + SaveMarketingCouponDto, +} from '#/api/marketing'; +import type { + CouponCardViewModel, + CouponEditorForm, +} from '#/views/marketing/coupon/types'; + +/** + * 文件职责:优惠券页面纯函数。 + * 1. 负责列表文案、视觉样式映射。 + * 2. 负责表单与 API DTO 的互转。 + */ +import dayjs from 'dayjs'; + +import { + COUPON_CHANNEL_TEXT_MAP, + COUPON_TYPE_FILTER_OPTIONS, + createDefaultCouponEditorForm, +} from './constants'; + +/** 领取进度百分比。 */ +export function resolveClaimedProgressPercent( + claimedQuantity: number, + totalQuantity: number, +) { + if (totalQuantity <= 0) return 0; + const percent = Math.round((claimedQuantity * 100) / totalQuantity); + return Math.max(0, Math.min(100, percent)); +} + +/** 千分位格式化。 */ +export function formatInteger(value: number) { + return Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 0, + }).format(value); +} + +/** 优惠券左侧色板样式类。 */ +export function resolveCouponTypeClass(couponType: MarketingCouponType) { + if (couponType === 'amount_off') { + return 'mcp-left-red'; + } + if (couponType === 'discount') { + return 'mcp-left-blue'; + } + return 'mcp-left-green'; +} + +/** 进度条颜色类。 */ +export function resolveProgressClass(couponType: MarketingCouponType) { + if (couponType === 'amount_off') { + return 'mcp-progress-red'; + } + if (couponType === 'discount') { + return 'mcp-progress-blue'; + } + return 'mcp-progress-green'; +} + +/** 列表类型文案。 */ +export function resolveCouponTypeLabel(couponType: MarketingCouponType) { + return ( + COUPON_TYPE_FILTER_OPTIONS.find((item) => item.value === couponType) + ?.label ?? '优惠券' + ); +} + +/** 列表券面主文案。 */ +export function resolveCouponFaceValueText(item: CouponCardViewModel) { + if (item.couponType === 'amount_off') { + return `¥${trimDecimal(item.value)}`; + } + if (item.couponType === 'discount') { + return `${trimDecimal(item.value)}折`; + } + return '免配送'; +} + +/** 列表券面副文案。 */ +export function resolveCouponFaceConditionText(item: CouponCardViewModel) { + if (item.couponType === 'amount_off' || item.couponType === 'discount') { + if (item.minimumSpend && item.minimumSpend > 0) { + return `满${trimDecimal(item.minimumSpend)}可用`; + } + return item.couponType === 'discount' ? '全场通用' : '无门槛'; + } + return '无门槛'; +} + +/** 列表有效期文案。 */ +export function resolveCouponValidityText(item: CouponCardViewModel) { + if (item.validFrom && item.validTo) { + return `${item.validFrom.replaceAll('-', '.')} - ${item.validTo.replaceAll('-', '.')}`; + } + if (item.relativeValidDays && item.relativeValidDays > 0) { + return `领取后 ${item.relativeValidDays} 天内有效`; + } + return '--'; +} + +/** 列表规则文案。 */ +export function resolveCouponRuleText(item: CouponCardViewModel) { + const limitText = + item.perUserLimit && item.perUserLimit > 0 + ? `每人限领${item.perUserLimit}张` + : '不限领'; + + const channelText = resolveChannelsText(item.channels); + return `${limitText} | ${channelText}`; +} + +/** 渠道文本。 */ +export function resolveChannelsText(channels: MarketingCouponChannel[]) { + const normalized = channels.toSorted(); + const allChannels: MarketingCouponChannel[] = [ + 'delivery', + 'pickup', + 'dine_in', + ]; + const isAll = allChannels.every((channel) => normalized.includes(channel)); + if (isAll) { + return '全渠道可用'; + } + + const channelLabels = normalized.map( + (channel) => COUPON_CHANNEL_TEXT_MAP[channel], + ); + if (channelLabels.length === 1) { + return `仅${channelLabels[0]}可用`; + } + return `${channelLabels.join('/')}可用`; +} + +/** 详情映射为编辑表单。 */ +export function mapDetailToEditorForm( + detail: MarketingCouponDetailDto, +): CouponEditorForm { + const form = createDefaultCouponEditorForm(); + form.id = detail.id; + form.name = detail.name; + form.couponType = detail.couponType; + form.value = detail.couponType === 'free_delivery' ? null : detail.value; + form.minimumSpend = detail.minimumSpend; + form.totalQuantity = detail.totalQuantity; + form.perUserLimit = detail.perUserLimit; + form.validityType = detail.validityType; + form.validDateRange = + detail.validityType === 'fixed' && detail.validFrom && detail.validTo + ? [dayjs(detail.validFrom), dayjs(detail.validTo)] + : null; + form.relativeValidDays = detail.relativeValidDays; + form.channels = [...detail.channels]; + form.storeScopeMode = detail.storeScopeMode; + form.storeIds = [...detail.storeIds]; + form.status = detail.status; + return form; +} + +/** 编辑表单构建保存请求。 */ +export function buildSaveCouponPayload( + form: CouponEditorForm, + storeId: string, +): SaveMarketingCouponDto { + const validFrom = + form.validityType === 'fixed' && form.validDateRange + ? form.validDateRange[0].format('YYYY-MM-DD') + : null; + const validTo = + form.validityType === 'fixed' && form.validDateRange + ? form.validDateRange[1].format('YYYY-MM-DD') + : null; + + const value = + form.couponType === 'free_delivery' ? 0 : Number(form.value ?? 0); + const minimumSpend = + form.couponType === 'free_delivery' + ? null + : normalizeNullableNumber(form.minimumSpend); + + return { + id: form.id || undefined, + storeId, + name: form.name.trim(), + couponType: form.couponType, + value, + minimumSpend, + totalQuantity: Math.floor(Number(form.totalQuantity || 0)), + perUserLimit: normalizeNullableNumber(form.perUserLimit), + validityType: form.validityType, + validFrom, + validTo, + relativeValidDays: + form.validityType === 'days' + ? Math.floor(Number(form.relativeValidDays || 0)) + : null, + channels: [...form.channels], + storeScopeMode: form.storeScopeMode, + storeIds: form.storeScopeMode === 'stores' ? [...form.storeIds] : undefined, + status: form.status, + }; +} + +function normalizeNullableNumber(value: null | number) { + if (value === null || Number.isNaN(value)) { + return null; + } + return Number(value); +} + +function trimDecimal(value: number) { + const fixed = value.toFixed(2); + return fixed.replace(/\.?0+$/, ''); +} diff --git a/apps/web-antd/src/views/marketing/coupon/composables/useMarketingCouponPage.ts b/apps/web-antd/src/views/marketing/coupon/composables/useMarketingCouponPage.ts new file mode 100644 index 0000000..4c6fd09 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/composables/useMarketingCouponPage.ts @@ -0,0 +1,202 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { CouponCardViewModel } from '#/views/marketing/coupon/types'; + +/** + * 文件职责:优惠券页面状态与行为编排。 + * 1. 管理门店、筛选、分页、统计与加载状态。 + * 2. 编排抽屉编辑动作与卡片启停删除动作。 + */ +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { createCardActions } from './coupon-page/card-actions'; +import { + COUPON_STATUS_FILTER_OPTIONS, + COUPON_TYPE_FILTER_OPTIONS, + createDefaultCouponEditorForm, + createDefaultCouponFilterForm, +} from './coupon-page/constants'; +import { + createDataActions, + createEmptyStats, +} from './coupon-page/data-actions'; +import { createDrawerActions } from './coupon-page/drawer-actions'; + +export function useMarketingCouponPage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const filterForm = reactive(createDefaultCouponFilterForm()); + const keyword = ref(''); + + const rows = ref([]); + const stats = ref(createEmptyStats()); + const page = ref(1); + const pageSize = ref(10); + const total = ref(0); + const isLoading = ref(false); + + const isDrawerOpen = ref(false); + const isDrawerLoading = ref(false); + const isDrawerSubmitting = ref(false); + const form = reactive(createDefaultCouponEditorForm()); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const hasStore = computed(() => !!selectedStoreId.value); + + const { loadCoupons, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + filterForm, + keyword, + rows, + stats, + isLoading, + page, + pageSize, + total, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setKeyword(value: string) { + keyword.value = value; + } + + function setStatusFilter(value: '' | CouponCardViewModel['displayStatus']) { + filterForm.status = value; + } + + function setTypeFilter(value: '' | CouponCardViewModel['couponType']) { + filterForm.couponType = value; + } + + async function applyFilters() { + page.value = 1; + await loadCoupons(); + } + + async function resetFilters() { + filterForm.status = ''; + filterForm.couponType = ''; + keyword.value = ''; + page.value = 1; + await loadCoupons(); + } + + async function handlePageChange(nextPage: number, nextPageSize: number) { + page.value = nextPage; + pageSize.value = nextPageSize; + await loadCoupons(); + } + + const { + drawerMode, + openCreateDrawer, + openEditDrawer, + setDrawerOpen, + setFormChannels, + setFormCouponType, + setFormMinimumSpend, + setFormName, + setFormPerUserLimit, + setFormRelativeValidDays, + setFormStatus, + setFormStoreIds, + setFormStoreScopeMode, + setFormTotalQuantity, + setFormValidDateRange, + setFormValidityType, + setFormValue, + submitDrawer, + } = createDrawerActions({ + form, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + selectedStoreId, + loadCoupons, + }); + + const { disableCoupon, enableCoupon, removeCoupon } = createCardActions({ + selectedStoreId, + loadCoupons, + }); + + const drawerTitle = computed(() => + drawerMode.value === 'create' ? '创建优惠券' : '编辑优惠券', + ); + const drawerSubmitText = computed(() => '保存'); + + watch(selectedStoreId, () => { + page.value = 1; + keyword.value = ''; + filterForm.status = ''; + filterForm.couponType = ''; + void loadCoupons(); + }); + + onMounted(async () => { + await loadStores(); + }); + + return { + applyFilters, + COUPON_STATUS_FILTER_OPTIONS, + COUPON_TYPE_FILTER_OPTIONS, + disableCoupon, + drawerSubmitText, + drawerTitle, + enableCoupon, + filterForm, + form, + handlePageChange, + hasStore, + isDrawerLoading, + isDrawerOpen, + isDrawerSubmitting, + isLoading, + isStoreLoading, + keyword, + openCreateDrawer, + openEditDrawer, + page, + pageSize, + removeCoupon, + resetFilters, + rows, + selectedStoreId, + setDrawerOpen, + setFormChannels, + setFormCouponType, + setFormMinimumSpend, + setFormName, + setFormPerUserLimit, + setFormRelativeValidDays, + setFormStatus, + setFormStoreIds, + setFormStoreScopeMode, + setFormTotalQuantity, + setFormValidDateRange, + setFormValidityType, + setFormValue, + setKeyword, + setSelectedStoreId, + setStatusFilter, + setTypeFilter, + stats, + storeOptions, + stores, + submitDrawer, + total, + }; +} diff --git a/apps/web-antd/src/views/marketing/coupon/index.vue b/apps/web-antd/src/views/marketing/coupon/index.vue new file mode 100644 index 0000000..4dea065 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/index.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/coupon/styles/base.less b/apps/web-antd/src/views/marketing/coupon/styles/base.less new file mode 100644 index 0000000..6392f51 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/styles/base.less @@ -0,0 +1,30 @@ +/** + * 文件职责:优惠券页面基础样式。 + * 1. 定义页面级变量与共用动作按钮。 + */ +.page-marketing-coupon { + --mcp-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); + --mcp-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%); + --mcp-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%); + --mcp-border: #e7eaf0; + --mcp-text: #1f2937; + --mcp-subtext: #6b7280; + --mcp-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/coupon/styles/card.less b/apps/web-antd/src/views/marketing/coupon/styles/card.less new file mode 100644 index 0000000..c24535e --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/styles/card.less @@ -0,0 +1,204 @@ +/** + * 文件职责:优惠券卡片样式。 + * 1. 还原券面左区、详情右区、进度与状态条。 + */ +.page-marketing-coupon { + .mcp-coupon { + display: flex; + overflow: hidden; + background: #fff; + border: 1px solid var(--mcp-border); + border-radius: 12px; + box-shadow: var(--mcp-shadow-sm); + transition: + box-shadow var(--mcp-transition), + transform var(--mcp-transition); + } + + .mcp-coupon:hover { + box-shadow: var(--mcp-shadow-md); + transform: translateY(-1px); + } + + .mcp-coupon.mcp-dimmed { + opacity: 0.58; + } + + .mcp-left { + position: relative; + display: flex; + flex-shrink: 0; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + width: 160px; + min-height: 132px; + padding: 16px 10px; + color: #fff; + } + + .mcp-left::after { + position: absolute; + top: 50%; + right: -6px; + width: 12px; + height: 12px; + content: ''; + background: #fff; + border-radius: 50%; + transform: translateY(-50%); + } + + .mcp-left-red { + background: linear-gradient(135deg, #ff6b6b, #ef5350); + } + + .mcp-left-blue { + background: linear-gradient(135deg, #4facfe, #1677ff); + } + + .mcp-left-green { + color: #1a5c3a; + background: linear-gradient(135deg, #6ee7b7, #34d399); + } + + .mcp-face-value { + font-size: 30px; + font-weight: 800; + line-height: 1.1; + } + + .mcp-face-cond { + font-size: 12px; + opacity: 0.9; + } + + .mcp-right { + flex: 1; + min-width: 0; + padding: 14px 18px; + } + + .mcp-title-row { + display: flex; + gap: 8px; + align-items: center; + } + + .mcp-name { + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + font-weight: 600; + color: var(--mcp-text); + white-space: nowrap; + } + + .mcp-type-pill { + display: inline-flex; + flex-shrink: 0; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 11px; + color: #64748b; + background: #f1f5f9; + border-radius: 999px; + } + + .mcp-validity { + display: flex; + gap: 4px; + align-items: center; + margin-top: 6px; + font-size: 12px; + color: var(--mcp-muted); + } + + .mcp-validity .iconify { + width: 12px; + height: 12px; + } + + .mcp-rules { + margin-top: 4px; + font-size: 12px; + color: var(--mcp-subtext); + } + + .mcp-progress-row { + display: flex; + gap: 10px; + align-items: center; + margin-top: 8px; + font-size: 12px; + color: var(--mcp-subtext); + } + + .mcp-progress-wrap { + flex: 1; + max-width: 220px; + } + + .mcp-progress-bar { + height: 6px; + overflow: hidden; + background: #edf0f5; + border-radius: 999px; + } + + .mcp-progress-fill { + display: block; + height: 100%; + border-radius: 999px; + } + + .mcp-progress-red { + background: linear-gradient(90deg, #ff6b6b, #ef5350); + } + + .mcp-progress-blue { + background: linear-gradient(90deg, #4facfe, #1677ff); + } + + .mcp-progress-green { + background: linear-gradient(90deg, #6ee7b7, #10b981); + } + + .mcp-bottom { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + } + + .mcp-status-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + font-size: 12px; + border-radius: 999px; + } + + .mcp-status-ongoing { + color: #166534; + background: #dcfce7; + } + + .mcp-status-upcoming { + color: #1d4ed8; + background: #dbeafe; + } + + .mcp-status-ended { + color: #475569; + background: #e2e8f0; + } + + .mcp-status-disabled { + color: #b91c1c; + background: #fee2e2; + } +} diff --git a/apps/web-antd/src/views/marketing/coupon/styles/drawer.less b/apps/web-antd/src/views/marketing/coupon/styles/drawer.less new file mode 100644 index 0000000..c8e7600 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/styles/drawer.less @@ -0,0 +1,59 @@ +/** + * 文件职责:优惠券编辑抽屉样式。 + * 1. 规范表单行内布局与提示文案样式。 + */ +.page-marketing-coupon { + .mcp-editor-form { + .ant-form-item { + margin-bottom: 16px; + } + } + + .mcp-inline-fields { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .mcp-inline-fields .ant-input-number { + width: 130px; + } + + .mcp-input-wide { + width: 200px; + } + + .mcp-field-hint { + margin-top: 4px; + font-size: 12px; + color: #9ca3af; + } + + .mcp-form-muted { + font-size: 13px; + color: #6b7280; + } + + .mcp-range-picker { + width: 100%; + max-width: 360px; + } + + .mcp-store-multi { + width: 100%; + margin-top: 10px; + } + + .mcp-switch-row { + display: inline-flex; + gap: 10px; + align-items: center; + } + + .mcp-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-end; + } +} diff --git a/apps/web-antd/src/views/marketing/coupon/styles/index.less b/apps/web-antd/src/views/marketing/coupon/styles/index.less new file mode 100644 index 0000000..1156c2c --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/styles/index.less @@ -0,0 +1,5 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/marketing/coupon/styles/layout.less b/apps/web-antd/src/views/marketing/coupon/styles/layout.less new file mode 100644 index 0000000..a944379 --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/styles/layout.less @@ -0,0 +1,140 @@ +/** + * 文件职责:优惠券页面布局样式。 + * 1. 工具栏、统计区、空态与分页布局。 + */ +.page-marketing-coupon { + .mcp-page { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mcp-toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mcp-border); + border-radius: 10px; + box-shadow: var(--mcp-shadow-sm); + } + + .mcp-store-select { + width: 220px; + } + + .mcp-store-select .ant-select-selector, + .mcp-filter-select .ant-select-selector { + border-radius: 8px !important; + } + + .mcp-filter-select { + width: 140px; + } + + .mcp-search { + width: 180px; + } + + .mcp-spacer { + flex: 1; + } + + .mcp-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + } + + .mcp-stat-card { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid var(--mcp-border); + border-radius: 10px; + box-shadow: var(--mcp-shadow-sm); + transition: box-shadow var(--mcp-transition); + } + + .mcp-stat-card:hover { + box-shadow: var(--mcp-shadow-md); + } + + .mcp-stat-main { + min-width: 0; + } + + .mcp-stat-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 18px; + border-radius: 8px; + } + + .mcp-stat-blue { + color: #1677ff; + background: #e6f4ff; + } + + .mcp-stat-green { + color: #52c41a; + background: #f6ffed; + } + + .mcp-stat-orange { + color: #fa8c16; + background: #fff7e6; + } + + .mcp-stat-purple { + color: #722ed1; + background: #f9f0ff; + } + + .mcp-stat-value { + font-size: 22px; + font-weight: 700; + line-height: 1; + color: var(--mcp-text); + } + + .mcp-stat-label { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: var(--mcp-muted); + white-space: nowrap; + } + + .mcp-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .mcp-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px solid var(--mcp-border); + border-radius: 10px; + box-shadow: var(--mcp-shadow-sm); + } + + .mcp-pagination { + display: flex; + justify-content: flex-end; + padding: 12px 4px 2px; + margin-top: 14px; + } +} diff --git a/apps/web-antd/src/views/marketing/coupon/styles/responsive.less b/apps/web-antd/src/views/marketing/coupon/styles/responsive.less new file mode 100644 index 0000000..630a99f --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/styles/responsive.less @@ -0,0 +1,46 @@ +/** + * 文件职责:优惠券页面响应式样式。 + */ +.page-marketing-coupon { + @media (width <= 1200px) { + .mcp-toolbar { + flex-wrap: wrap; + } + + .mcp-spacer { + display: none; + } + + .mcp-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (width <= 768px) { + .mcp-stats { + grid-template-columns: 1fr; + } + + .mcp-coupon { + flex-direction: column; + } + + .mcp-left { + width: 100%; + min-height: 88px; + } + + .mcp-left::after { + inset: auto auto -6px 50%; + transform: translateX(-50%); + } + + .mcp-progress-row { + flex-wrap: wrap; + } + + .mcp-progress-wrap { + max-width: none; + } + } +} diff --git a/apps/web-antd/src/views/marketing/coupon/types.ts b/apps/web-antd/src/views/marketing/coupon/types.ts new file mode 100644 index 0000000..2976efb --- /dev/null +++ b/apps/web-antd/src/views/marketing/coupon/types.ts @@ -0,0 +1,46 @@ +import type { Dayjs } from 'dayjs'; + +import type { + MarketingCouponChannel, + MarketingCouponDisplayStatus, + MarketingCouponEditorStatus, + MarketingCouponListItemDto, + MarketingCouponStatsDto, + MarketingCouponStoreScopeMode, + MarketingCouponType, + MarketingCouponValidityType, +} from '#/api/marketing'; + +/** + * 文件职责:优惠券页面类型定义。 + */ + +/** 优惠券筛选表单。 */ +export interface CouponFilterForm { + couponType: '' | MarketingCouponType; + status: '' | MarketingCouponDisplayStatus; +} + +/** 优惠券编辑抽屉表单。 */ +export interface CouponEditorForm { + channels: MarketingCouponChannel[]; + couponType: MarketingCouponType; + id: string; + minimumSpend: null | number; + name: string; + perUserLimit: null | number; + relativeValidDays: null | number; + status: MarketingCouponEditorStatus; + storeIds: string[]; + storeScopeMode: MarketingCouponStoreScopeMode; + totalQuantity: number; + validDateRange: [Dayjs, Dayjs] | null; + validityType: MarketingCouponValidityType; + value: null | number; +} + +/** 优惠券卡片视图模型。 */ +export type CouponCardViewModel = MarketingCouponListItemDto; + +/** 优惠券统计视图模型。 */ +export type CouponStatsViewModel = MarketingCouponStatsDto;