From 543b82ab5ea9e0f894ca529366aee6e3515fcb1c Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Tue, 3 Mar 2026 10:12:28 +0800 Subject: [PATCH] feat(project): implement marketing calendar module --- apps/web-antd/src/api/marketing/calendar.ts | 133 ++++++++++++++++ apps/web-antd/src/api/marketing/index.ts | 1 + .../MarketingCalendarActivityDetailDrawer.vue | 61 ++++++++ .../MarketingCalendarConflictBanner.vue | 31 ++++ .../MarketingCalendarConflictDetailDrawer.vue | 72 +++++++++ .../MarketingCalendarMonthHeader.vue | 36 +++++ .../MarketingCalendarStatsCards.vue | 32 ++++ .../components/MarketingCalendarTimeline.vue | 106 +++++++++++++ .../marketing-calendar-page/constants.ts | 56 +++++++ .../marketing-calendar-page/data-actions.ts | 101 ++++++++++++ .../marketing-calendar-page/drawer-actions.ts | 99 ++++++++++++ .../marketing-calendar-page/helpers.ts | 46 ++++++ .../composables/useMarketingCalendarPage.ts | 126 +++++++++++++++ .../src/views/marketing/calendar/index.vue | 103 ++++++++++++ .../views/marketing/calendar/styles/base.less | 33 ++++ .../marketing/calendar/styles/drawer.less | 112 ++++++++++++++ .../marketing/calendar/styles/index.less | 5 + .../marketing/calendar/styles/layout.less | 138 +++++++++++++++++ .../marketing/calendar/styles/responsive.less | 29 ++++ .../marketing/calendar/styles/timeline.less | 146 ++++++++++++++++++ .../src/views/marketing/calendar/types.ts | 53 +++++++ 21 files changed, 1519 insertions(+) create mode 100644 apps/web-antd/src/api/marketing/calendar.ts create mode 100644 apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarActivityDetailDrawer.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictBanner.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictDetailDrawer.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarMonthHeader.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarStatsCards.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarTimeline.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/constants.ts create mode 100644 apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/data-actions.ts create mode 100644 apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/helpers.ts create mode 100644 apps/web-antd/src/views/marketing/calendar/composables/useMarketingCalendarPage.ts create mode 100644 apps/web-antd/src/views/marketing/calendar/index.vue create mode 100644 apps/web-antd/src/views/marketing/calendar/styles/base.less create mode 100644 apps/web-antd/src/views/marketing/calendar/styles/drawer.less create mode 100644 apps/web-antd/src/views/marketing/calendar/styles/index.less create mode 100644 apps/web-antd/src/views/marketing/calendar/styles/layout.less create mode 100644 apps/web-antd/src/views/marketing/calendar/styles/responsive.less create mode 100644 apps/web-antd/src/views/marketing/calendar/styles/timeline.less create mode 100644 apps/web-antd/src/views/marketing/calendar/types.ts diff --git a/apps/web-antd/src/api/marketing/calendar.ts b/apps/web-antd/src/api/marketing/calendar.ts new file mode 100644 index 0000000..a187582 --- /dev/null +++ b/apps/web-antd/src/api/marketing/calendar.ts @@ -0,0 +1,133 @@ +/** + * 文件职责:营销中心营销日历 API 与 DTO 定义。 + */ +import { requestClient } from '#/api/request'; + +/** 总览查询参数。 */ +export interface MarketingCalendarOverviewQuery { + month: number; + storeId: string; + year: number; +} + +/** 日期头。 */ +export interface MarketingCalendarDayDto { + day: number; + isToday: boolean; + isWeekend: boolean; +} + +/** 图例。 */ +export interface MarketingCalendarLegendDto { + color: string; + label: string; + type: string; +} + +/** 顶部统计。 */ +export interface MarketingCalendarStatsDto { + estimatedDiscountAmount: number; + maxConcurrentCount: number; + ongoingCount: number; + totalActivityCount: number; +} + +/** 活动条。 */ +export interface MarketingCalendarActivityBarDto { + barId: string; + endDay: number; + isDimmed: boolean; + isMilestone: boolean; + label: string; + startDay: number; +} + +/** 详情字段。 */ +export interface MarketingCalendarDetailFieldDto { + label: string; + value: string; +} + +/** 活动详情。 */ +export interface MarketingCalendarActivityDetailDto { + description: string; + fields: MarketingCalendarDetailFieldDto[]; + moduleName: string; +} + +/** 活动。 */ +export interface MarketingCalendarActivityDto { + activityId: string; + bars: MarketingCalendarActivityBarDto[]; + calendarType: string; + color: string; + detail: MarketingCalendarActivityDetailDto; + displayStatus: string; + endDate: string; + estimatedDiscountAmount: number; + isDimmed: boolean; + name: string; + sourceId: string; + sourceType: string; + startDate: string; + summary: string; +} + +/** 冲突活动摘要。 */ +export interface MarketingCalendarConflictActivityDto { + activityId: string; + calendarType: string; + color: string; + displayStatus: string; + name: string; + summary: string; +} + +/** 冲突区间。 */ +export interface MarketingCalendarConflictDto { + activities: MarketingCalendarConflictActivityDto[]; + activityCount: number; + activityIds: string[]; + conflictId: string; + endDay: number; + maxConcurrentCount: number; + startDay: number; +} + +/** 冲突横幅。 */ +export interface MarketingCalendarConflictBannerDto { + activityCount: number; + conflictCount: number; + conflictId: string; + endDay: number; + maxConcurrentCount: number; + startDay: number; +} + +/** 营销日历总览。 */ +export interface MarketingCalendarOverviewDto { + activities: MarketingCalendarActivityDto[]; + conflictBanner: MarketingCalendarConflictBannerDto | null; + conflicts: MarketingCalendarConflictDto[]; + days: MarketingCalendarDayDto[]; + legends: MarketingCalendarLegendDto[]; + month: string; + monthEndDate: string; + monthStartDate: string; + monthValue: number; + stats: MarketingCalendarStatsDto; + todayDay: number; + year: number; +} + +/** 查询营销日历总览。 */ +export async function getMarketingCalendarOverviewApi( + params: MarketingCalendarOverviewQuery, +) { + return requestClient.get( + '/marketing/calendar/overview', + { + params, + }, + ); +} diff --git a/apps/web-antd/src/api/marketing/index.ts b/apps/web-antd/src/api/marketing/index.ts index 1f468f6..f14ffd9 100644 --- a/apps/web-antd/src/api/marketing/index.ts +++ b/apps/web-antd/src/api/marketing/index.ts @@ -183,6 +183,7 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) { return requestClient.post('/marketing/coupon/delete', data); } +export * from './calendar'; export * from './flash-sale'; export * from './full-reduction'; export * from './new-customer'; diff --git a/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarActivityDetailDrawer.vue b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarActivityDetailDrawer.vue new file mode 100644 index 0000000..f8a81af --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarActivityDetailDrawer.vue @@ -0,0 +1,61 @@ + + + diff --git a/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictBanner.vue b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictBanner.vue new file mode 100644 index 0000000..2cb33e1 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictBanner.vue @@ -0,0 +1,31 @@ + + + diff --git a/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictDetailDrawer.vue b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictDetailDrawer.vue new file mode 100644 index 0000000..2591677 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarConflictDetailDrawer.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarMonthHeader.vue b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarMonthHeader.vue new file mode 100644 index 0000000..3e1198f --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarMonthHeader.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarStatsCards.vue b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarStatsCards.vue new file mode 100644 index 0000000..ae4a6b2 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarStatsCards.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarTimeline.vue b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarTimeline.vue new file mode 100644 index 0000000..4fe433a --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/components/MarketingCalendarTimeline.vue @@ -0,0 +1,106 @@ + + + diff --git a/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/constants.ts b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/constants.ts new file mode 100644 index 0000000..ce62847 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/constants.ts @@ -0,0 +1,56 @@ +import type { MarketingCalendarOverviewViewModel } from '#/views/marketing/calendar/types'; + +/** + * 文件职责:营销日历页面常量。 + */ + +/** 查看权限码。 */ +export const CALENDAR_VIEW_PERMISSION = 'tenant:marketing:calendar:view'; + +/** 管理权限码。 */ +export const CALENDAR_MANAGE_PERMISSION = 'tenant:marketing:calendar:manage'; + +/** 日历类型文案映射。 */ +export const CALENDAR_TYPE_TEXT_MAP: Record = { + reduce: '满减', + gift: '满赠', + second_half: '第二份半价', + flash_sale: '限时折扣', + seckill: '秒杀', + coupon: '优惠券', + punch_card: '次卡', +}; + +/** 状态文案映射。 */ +export const CALENDAR_STATUS_TEXT_MAP: Record = { + ongoing: '进行中', + upcoming: '未开始', + ended: '已结束', + disabled: '已停用', +}; + +/** 创建空总览。 */ +export function createEmptyOverview( + month: number, + year: number, +): MarketingCalendarOverviewViewModel { + return { + month: `${year}-${String(month).padStart(2, '0')}`, + year, + monthValue: month, + monthStartDate: '', + monthEndDate: '', + todayDay: 0, + days: [], + legends: [], + stats: { + totalActivityCount: 0, + ongoingCount: 0, + maxConcurrentCount: 0, + estimatedDiscountAmount: 0, + }, + conflictBanner: null, + conflicts: [], + activities: [], + }; +} diff --git a/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/data-actions.ts b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/data-actions.ts new file mode 100644 index 0000000..63a4326 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/data-actions.ts @@ -0,0 +1,101 @@ +import type { Ref } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; +import type { + MarketingCalendarMonthCursor, + MarketingCalendarOverviewViewModel, +} from '#/views/marketing/calendar/types'; + +/** + * 文件职责:营销日历页面数据拉取动作。 + */ +import { message } from 'ant-design-vue'; + +import { getMarketingCalendarOverviewApi } from '#/api/marketing'; +import { getStoreListApi } from '#/api/store'; + +import { createEmptyOverview } from './constants'; + +interface CreateDataActionsOptions { + cursor: Ref; + isOverviewLoading: Ref; + isStoreLoading: Ref; + overview: Ref; + selectedStoreId: Ref; + stores: 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.overview.value = createEmptyOverview( + options.cursor.value.month, + options.cursor.value.year, + ); + return; + } + + if (options.selectedStoreId.value === '') { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } else { + const exists = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (exists) { + return; + } + + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadOverview() { + if (!options.selectedStoreId.value) { + options.overview.value = createEmptyOverview( + options.cursor.value.month, + options.cursor.value.year, + ); + return; + } + + options.isOverviewLoading.value = true; + try { + const result = await getMarketingCalendarOverviewApi({ + storeId: options.selectedStoreId.value, + year: options.cursor.value.year, + month: options.cursor.value.month, + }); + + options.overview.value = result; + } catch (error) { + console.error(error); + options.overview.value = createEmptyOverview( + options.cursor.value.month, + options.cursor.value.year, + ); + message.error('加载营销日历失败'); + } finally { + options.isOverviewLoading.value = false; + } + } + + return { + loadOverview, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/drawer-actions.ts b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/drawer-actions.ts new file mode 100644 index 0000000..8d0ad7a --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/drawer-actions.ts @@ -0,0 +1,99 @@ +import type { Ref } from 'vue'; + +import type { + MarketingCalendarActivityViewModel, + MarketingCalendarConflictViewModel, + MarketingCalendarOverviewViewModel, +} from '#/views/marketing/calendar/types'; + +/** + * 文件职责:营销日历二级抽屉动作。 + */ +import { computed, ref } from 'vue'; + +interface CreateDrawerActionsOptions { + overview: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + const isActivityDrawerOpen = ref(false); + const isConflictDrawerOpen = ref(false); + const activeActivityId = ref(''); + const activeConflictId = ref(''); + + const activeActivity = computed( + () => + options.overview.value.activities.find( + (item) => item.activityId === activeActivityId.value, + ) ?? null, + ); + + const activeConflict = computed( + () => + options.overview.value.conflicts.find( + (item) => item.conflictId === activeConflictId.value, + ) ?? null, + ); + + const activeConflictActivities = computed< + MarketingCalendarActivityViewModel[] + >(() => { + if (!activeConflict.value) { + return []; + } + + const activityMap = new Map( + options.overview.value.activities.map((item) => [item.activityId, item]), + ); + return activeConflict.value.activityIds + .map((id) => activityMap.get(id)) + .filter( + (item): item is MarketingCalendarActivityViewModel => + item !== undefined, + ); + }); + + function openActivityDrawer(activityId: string) { + activeActivityId.value = activityId; + isActivityDrawerOpen.value = true; + } + + function closeActivityDrawer() { + isActivityDrawerOpen.value = false; + } + + function openConflictDrawer(conflictId?: string) { + const targetId = + conflictId || + options.overview.value.conflictBanner?.conflictId || + options.overview.value.conflicts[0]?.conflictId; + if (!targetId) { + return; + } + + activeConflictId.value = targetId; + isConflictDrawerOpen.value = true; + } + + function closeConflictDrawer() { + isConflictDrawerOpen.value = false; + } + + function viewActivityFromConflict(activityId: string) { + isConflictDrawerOpen.value = false; + openActivityDrawer(activityId); + } + + return { + activeActivity, + activeConflict, + activeConflictActivities, + closeActivityDrawer, + closeConflictDrawer, + isActivityDrawerOpen, + isConflictDrawerOpen, + openActivityDrawer, + openConflictDrawer, + viewActivityFromConflict, + }; +} diff --git a/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/helpers.ts b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/helpers.ts new file mode 100644 index 0000000..795b587 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/composables/marketing-calendar-page/helpers.ts @@ -0,0 +1,46 @@ +import type { MarketingCalendarMonthCursor } from '#/views/marketing/calendar/types'; + +/** + * 文件职责:营销日历页面工具函数。 + */ + +/** 创建当前月游标。 */ +export function createCurrentMonthCursor(): MarketingCalendarMonthCursor { + const now = new Date(); + return { + year: now.getFullYear(), + month: now.getMonth() + 1, + }; +} + +/** 偏移月份。 */ +export function shiftMonthCursor( + current: MarketingCalendarMonthCursor, + delta: number, +): MarketingCalendarMonthCursor { + const target = new Date(current.year, current.month - 1 + delta, 1); + return { + year: target.getFullYear(), + month: target.getMonth() + 1, + }; +} + +/** 格式化月份标题。 */ +export function formatMonthTitle(cursor: MarketingCalendarMonthCursor): string { + return `${cursor.year}年 ${cursor.month}月`; +} + +/** 格式化金额。 */ +export function formatCurrency(value: number): string { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(value || 0); +} + +/** 格式化起止日文案。 */ +export function formatDayRange(startDay: number, endDay: number): string { + return `${startDay}日~${endDay}日`; +} diff --git a/apps/web-antd/src/views/marketing/calendar/composables/useMarketingCalendarPage.ts b/apps/web-antd/src/views/marketing/calendar/composables/useMarketingCalendarPage.ts new file mode 100644 index 0000000..6097e3c --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/composables/useMarketingCalendarPage.ts @@ -0,0 +1,126 @@ +import type { StoreListItemDto } from '#/api/store'; +import type { + MarketingCalendarMonthCursor, + MarketingCalendarOverviewViewModel, +} from '#/views/marketing/calendar/types'; + +/** + * 文件职责:营销日历页面状态与行为编排。 + */ +import { computed, onMounted, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { + CALENDAR_MANAGE_PERMISSION, + createEmptyOverview, +} from './marketing-calendar-page/constants'; +import { createDataActions } from './marketing-calendar-page/data-actions'; +import { createDrawerActions } from './marketing-calendar-page/drawer-actions'; +import { + createCurrentMonthCursor, + formatMonthTitle, + shiftMonthCursor, +} from './marketing-calendar-page/helpers'; + +export function useMarketingCalendarPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + const isOverviewLoading = ref(false); + + const cursor = ref(createCurrentMonthCursor()); + const overview = ref( + createEmptyOverview(cursor.value.month, cursor.value.year), + ); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(CALENDAR_MANAGE_PERMISSION), + ); + const hasStore = computed(() => stores.value.length > 0); + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + const monthTitle = computed(() => formatMonthTitle(cursor.value)); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + const { loadOverview, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + isOverviewLoading, + cursor, + overview, + }); + + const { + activeActivity, + activeConflict, + activeConflictActivities, + closeActivityDrawer, + closeConflictDrawer, + isActivityDrawerOpen, + isConflictDrawerOpen, + openActivityDrawer, + openConflictDrawer, + viewActivityFromConflict, + } = createDrawerActions({ + overview, + }); + + async function prevMonth() { + cursor.value = shiftMonthCursor(cursor.value, -1); + await loadOverview(); + } + + async function nextMonth() { + cursor.value = shiftMonthCursor(cursor.value, 1); + await loadOverview(); + } + + watch(selectedStoreId, async () => { + await loadOverview(); + }); + + onMounted(async () => { + await loadStores(); + if (selectedStoreId.value) { + await loadOverview(); + } + }); + + return { + activeActivity, + activeConflict, + activeConflictActivities, + canManage, + closeActivityDrawer, + closeConflictDrawer, + hasStore, + isActivityDrawerOpen, + isConflictDrawerOpen, + isOverviewLoading, + isStoreLoading, + monthTitle, + nextMonth, + openActivityDrawer, + openConflictDrawer, + overview, + prevMonth, + selectedStoreId, + setSelectedStoreId, + storeOptions, + viewActivityFromConflict, + }; +} diff --git a/apps/web-antd/src/views/marketing/calendar/index.vue b/apps/web-antd/src/views/marketing/calendar/index.vue new file mode 100644 index 0000000..2c924df --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/index.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/apps/web-antd/src/views/marketing/calendar/styles/base.less b/apps/web-antd/src/views/marketing/calendar/styles/base.less new file mode 100644 index 0000000..d0bfe5f --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/styles/base.less @@ -0,0 +1,33 @@ +.page-marketing-calendar { + .mc-page { + max-width: 1100px; + margin: 0 auto; + } + + .mc-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + .mc-store-select { + width: 260px; + } + + .mc-readonly-tip { + font-size: 12px; + color: #94a3b8; + } + + .mc-empty { + padding: 24px; + font-size: 13px; + color: #94a3b8; + text-align: center; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + } +} diff --git a/apps/web-antd/src/views/marketing/calendar/styles/drawer.less b/apps/web-antd/src/views/marketing/calendar/styles/drawer.less new file mode 100644 index 0000000..d1feff7 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/styles/drawer.less @@ -0,0 +1,112 @@ +.mc-detail-drawer { + .mc-detail-title { + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + + .mc-detail-meta { + display: flex; + gap: 8px; + align-items: center; + margin-top: 12px; + } + + .mc-detail-desc { + margin-top: 12px; + font-size: 13px; + line-height: 1.6; + color: #64748b; + } + + .mc-detail-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 16px; + } + + .mc-detail-field { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + background: #f8fafc; + border-radius: 8px; + } + + .mc-detail-field .label { + flex-shrink: 0; + width: 88px; + color: #64748b; + } + + .mc-detail-field .value { + flex: 1; + color: #0f172a; + word-break: break-all; + } + + .mc-conflict-head { + padding: 10px 12px; + margin-bottom: 16px; + color: #9a3412; + background: #fff7ed; + border-left: 4px solid #f59e0b; + border-radius: 6px; + } + + .mc-conflict-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .mc-conflict-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + } + + .mc-conflict-item .left { + display: flex; + gap: 8px; + align-items: center; + min-width: 0; + } + + .mc-conflict-item .dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 2px; + } + + .mc-conflict-item .content { + min-width: 0; + } + + .mc-conflict-item .name { + font-weight: 600; + color: #0f172a; + } + + .mc-conflict-item .summary { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: #64748b; + white-space: nowrap; + } + + .mc-conflict-item .right { + display: inline-flex; + gap: 8px; + align-items: center; + margin-left: 8px; + } +} diff --git a/apps/web-antd/src/views/marketing/calendar/styles/index.less b/apps/web-antd/src/views/marketing/calendar/styles/index.less new file mode 100644 index 0000000..14ea60a --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/styles/index.less @@ -0,0 +1,5 @@ +@import './base.less'; +@import './layout.less'; +@import './timeline.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/marketing/calendar/styles/layout.less b/apps/web-antd/src/views/marketing/calendar/styles/layout.less new file mode 100644 index 0000000..bc7c069 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/styles/layout.less @@ -0,0 +1,138 @@ +.page-marketing-calendar { + .mc-conflict-banner { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + padding: 10px 16px; + margin-bottom: 16px; + font-size: 13px; + color: #9a3412; + text-align: left; + cursor: pointer; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 8px; + } + + .mc-conflict-banner-icon { + display: inline-flex; + flex-shrink: 0; + font-size: 16px; + color: #f59e0b; + } + + .mc-conflict-banner-text b { + font-weight: 700; + } + + .mc-header { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + margin-bottom: 16px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + } + + .mc-month-nav { + display: flex; + gap: 12px; + align-items: center; + } + + .mc-month-nav button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 16px; + color: #64748b; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: all 0.2s ease; + } + + .mc-month-nav button:hover { + color: var(--ant-primary-color, #1677ff); + border-color: var(--ant-primary-color, #1677ff); + } + + .mc-month-title { + min-width: 140px; + font-size: 18px; + font-weight: 700; + color: #0f172a; + text-align: center; + } + + .mc-legend { + display: flex; + flex-wrap: wrap; + gap: 10px 14px; + } + + .mc-legend-item { + display: inline-flex; + gap: 4px; + align-items: center; + font-size: 12px; + color: #64748b; + } + + .mc-legend-dot { + width: 10px; + height: 10px; + border-radius: 3px; + } + + .mc-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; + } + + .mc-stat-card { + padding: 14px 18px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + transition: all 0.2s ease; + } + + .mc-stat-card:hover { + box-shadow: 0 6px 14px rgb(15 23 42 / 10%); + transform: translateY(-1px); + } + + .mc-stat-label { + margin-bottom: 4px; + font-size: 12px; + color: #94a3b8; + } + + .mc-stat-value { + font-size: 20px; + font-weight: 700; + color: #0f172a; + } + + .mc-stat-value.primary { + color: #1677ff; + } + + .mc-stat-value.green { + color: #22c55e; + } + + .mc-stat-value.orange { + color: #f59e0b; + } +} diff --git a/apps/web-antd/src/views/marketing/calendar/styles/responsive.less b/apps/web-antd/src/views/marketing/calendar/styles/responsive.less new file mode 100644 index 0000000..827b6e2 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/styles/responsive.less @@ -0,0 +1,29 @@ +@media (max-width: 1200px) { + .page-marketing-calendar { + .mc-header { + flex-direction: column; + align-items: flex-start; + } + + .mc-legend { + width: 100%; + } + } +} + +@media (max-width: 768px) { + .page-marketing-calendar { + .mc-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .mc-store-select { + width: 100%; + } + + .mc-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } +} diff --git a/apps/web-antd/src/views/marketing/calendar/styles/timeline.less b/apps/web-antd/src/views/marketing/calendar/styles/timeline.less new file mode 100644 index 0000000..efa6f3d --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/styles/timeline.less @@ -0,0 +1,146 @@ +.page-marketing-calendar { + .mc-gantt { + overflow: auto; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + } + + .mc-dates { + display: grid; + min-width: 860px; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; + } + + .mc-dates-label { + display: flex; + align-items: center; + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + color: #64748b; + border-right: 1px solid #f0f0f0; + } + + .mc-date-cell { + padding: 8px 0; + font-size: 11px; + color: #94a3b8; + text-align: center; + border-right: 1px solid #f5f5f5; + } + + .mc-date-cell.weekend { + color: #cbd5e1; + } + + .mc-date-cell.today { + font-weight: 600; + color: #1677ff; + background: rgb(22 119 255 / 8%); + } + + .mc-row { + display: grid; + grid-template-columns: 180px 1fr; + min-width: 860px; + min-height: 44px; + border-bottom: 1px solid #f5f5f5; + transition: background 0.2s ease; + } + + .mc-row:last-child { + border-bottom: none; + } + + .mc-row:hover { + background: rgb(22 119 255 / 3%); + } + + .mc-row-label { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 16px; + font-size: 13px; + color: #0f172a; + border-right: 1px solid #f0f0f0; + } + + .mc-type-dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 2px; + } + + .mc-row-label-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mc-bar-area { + position: relative; + display: grid; + align-items: center; + } + + .mc-bar { + position: absolute; + top: 50%; + display: flex; + align-items: center; + height: 24px; + padding: 0 8px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + font-weight: 500; + color: #fff; + white-space: nowrap; + cursor: pointer; + border: none; + border-radius: 4px; + transform: translateY(-50%); + transition: filter 0.2s ease; + } + + .mc-bar:hover { + filter: brightness(1.08); + } + + .mc-bar.milestone { + padding: 0; + font-size: 0; + } + + .mc-today-line { + position: absolute; + top: 0; + bottom: 0; + z-index: 2; + width: 2px; + background: #ef4444; + opacity: 0.65; + } + + .mc-today-line::before { + position: absolute; + top: -18px; + left: 50%; + font-size: 10px; + font-weight: 600; + color: #ef4444; + content: '今'; + transform: translateX(-50%); + } + + .mc-empty-row { + padding: 40px 20px; + font-size: 13px; + color: #94a3b8; + text-align: center; + } +} diff --git a/apps/web-antd/src/views/marketing/calendar/types.ts b/apps/web-antd/src/views/marketing/calendar/types.ts new file mode 100644 index 0000000..981ac02 --- /dev/null +++ b/apps/web-antd/src/views/marketing/calendar/types.ts @@ -0,0 +1,53 @@ +import type { + MarketingCalendarActivityDto, + MarketingCalendarConflictDto, + MarketingCalendarOverviewDto, + MarketingCalendarStatsDto, +} from '#/api/marketing'; + +/** + * 文件职责:营销日历页面类型定义。 + */ + +/** 月份游标。 */ +export interface MarketingCalendarMonthCursor { + month: number; + year: number; +} + +/** 活动视图模型。 */ +export type MarketingCalendarActivityViewModel = MarketingCalendarActivityDto; + +/** 冲突视图模型。 */ +export type MarketingCalendarConflictViewModel = MarketingCalendarConflictDto; + +/** 统计视图模型。 */ +export type MarketingCalendarStatsViewModel = MarketingCalendarStatsDto; + +/** 页面总览视图模型。 */ +export type MarketingCalendarOverviewViewModel = MarketingCalendarOverviewDto; + +/** 创建默认总览。 */ +export function createDefaultMarketingCalendarOverview( + cursor: MarketingCalendarMonthCursor, +): MarketingCalendarOverviewViewModel { + return { + month: `${cursor.year}-${String(cursor.month).padStart(2, '0')}`, + year: cursor.year, + monthValue: cursor.month, + monthStartDate: '', + monthEndDate: '', + todayDay: 0, + days: [], + legends: [], + stats: { + totalActivityCount: 0, + ongoingCount: 0, + maxConcurrentCount: 0, + estimatedDiscountAmount: 0, + }, + conflictBanner: null, + conflicts: [], + activities: [], + }; +}