feat(project): implement marketing calendar module
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s

This commit is contained in:
2026-03-03 10:12:28 +08:00
parent be0a8e6914
commit 543b82ab5e
21 changed files with 1519 additions and 0 deletions

View File

@@ -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<MarketingCalendarOverviewDto>(
'/marketing/calendar/overview',
{
params,
},
);
}

View File

@@ -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';

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { MarketingCalendarActivityDto } from '#/api/marketing';
import { Drawer, Empty, Tag } from 'ant-design-vue';
import {
CALENDAR_STATUS_TEXT_MAP,
CALENDAR_TYPE_TEXT_MAP,
} from '../composables/marketing-calendar-page/constants';
defineProps<{
activity: MarketingCalendarActivityDto | null;
open: boolean;
}>();
const emit = defineEmits<{
(event: 'close'): void;
}>();
</script>
<template>
<Drawer
:open="open"
title="活动详情"
width="560"
class="mc-detail-drawer"
@close="emit('close')"
>
<template v-if="activity">
<div class="mc-detail-title">{{ activity.name }}</div>
<div class="mc-detail-meta">
<Tag color="blue">
{{
CALENDAR_TYPE_TEXT_MAP[activity.calendarType] ??
activity.calendarType
}}
</Tag>
<Tag :color="activity.isDimmed ? 'default' : 'green'">
{{
CALENDAR_STATUS_TEXT_MAP[activity.displayStatus] ??
activity.displayStatus
}}
</Tag>
</div>
<div class="mc-detail-desc">{{ activity.detail.description }}</div>
<div class="mc-detail-fields">
<div
v-for="field in activity.detail.fields"
:key="`${activity.activityId}-${field.label}`"
class="mc-detail-field"
>
<span class="label">{{ field.label }}</span>
<span class="value">{{ field.value }}</span>
</div>
</div>
</template>
<Empty v-else description="暂无活动详情" />
</Drawer>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { MarketingCalendarConflictBannerDto } from '#/api/marketing';
import { IconifyIcon } from '@vben/icons';
defineProps<{
banner: MarketingCalendarConflictBannerDto | null;
}>();
const emit = defineEmits<{
(event: 'openConflict', conflictId: string): void;
}>();
</script>
<template>
<button
v-if="banner"
type="button"
class="mc-conflict-banner"
@click="emit('openConflict', banner.conflictId)"
>
<span class="mc-conflict-banner-icon">
<IconifyIcon icon="lucide:alert-triangle" />
</span>
<span class="mc-conflict-banner-text">
{{ banner.startDay }}~{{ banner.endDay }}
<b>{{ banner.activityCount }}个活动</b>
同时进行请注意优惠叠加可能影响利润
</span>
</button>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import type {
MarketingCalendarActivityDto,
MarketingCalendarConflictDto,
} from '#/api/marketing';
import { Button, Drawer, Empty, Tag } from 'ant-design-vue';
import { CALENDAR_TYPE_TEXT_MAP } from '../composables/marketing-calendar-page/constants';
defineProps<{
activities: MarketingCalendarActivityDto[];
conflict: MarketingCalendarConflictDto | null;
open: boolean;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'viewActivity', activityId: string): void;
}>();
</script>
<template>
<Drawer
:open="open"
title="冲突详情"
width="620"
class="mc-detail-drawer"
@close="emit('close')"
>
<template v-if="conflict">
<div class="mc-conflict-head">
{{ conflict.startDay }}~{{ conflict.endDay }}最大并行
{{ conflict.maxConcurrentCount }} 个活动
</div>
<div v-if="activities.length > 0" class="mc-conflict-list">
<div
v-for="item in activities"
:key="item.activityId"
class="mc-conflict-item"
>
<div class="left">
<span class="dot" :style="{ background: item.color }"></span>
<div class="content">
<div class="name">{{ item.name }}</div>
<div class="summary">{{ item.summary }}</div>
</div>
</div>
<div class="right">
<Tag>
{{
CALENDAR_TYPE_TEXT_MAP[item.calendarType] ?? item.calendarType
}}
</Tag>
<Button
type="link"
size="small"
@click="emit('viewActivity', item.activityId)"
>
查看活动
</Button>
</div>
</div>
</div>
<Empty v-else description="暂无冲突活动明细" />
</template>
<Empty v-else description="暂无冲突详情" />
</Drawer>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { MarketingCalendarLegendDto } from '#/api/marketing';
import { IconifyIcon } from '@vben/icons';
defineProps<{
legends: MarketingCalendarLegendDto[];
monthTitle: string;
}>();
const emit = defineEmits<{
(event: 'nextMonth'): void;
(event: 'prevMonth'): void;
}>();
</script>
<template>
<div class="mc-header">
<div class="mc-month-nav">
<button type="button" @click="emit('prevMonth')">
<IconifyIcon icon="lucide:chevron-left" />
</button>
<div class="mc-month-title">{{ monthTitle }}</div>
<button type="button" @click="emit('nextMonth')">
<IconifyIcon icon="lucide:chevron-right" />
</button>
</div>
<div class="mc-legend">
<span v-for="item in legends" :key="item.type" class="mc-legend-item">
<span class="mc-legend-dot" :style="{ background: item.color }"></span>
{{ item.label }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { MarketingCalendarStatsDto } from '#/api/marketing';
import { formatCurrency } from '../composables/marketing-calendar-page/helpers';
defineProps<{
stats: MarketingCalendarStatsDto;
}>();
</script>
<template>
<div class="mc-stats">
<div class="mc-stat-card">
<div class="mc-stat-label">本月活动</div>
<div class="mc-stat-value primary">{{ stats.totalActivityCount }}</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-label">进行中</div>
<div class="mc-stat-value green">{{ stats.ongoingCount }}</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-label">最大并行数</div>
<div class="mc-stat-value orange">{{ stats.maxConcurrentCount }}</div>
</div>
<div class="mc-stat-card">
<div class="mc-stat-label">本月预计优惠</div>
<div class="mc-stat-value">
{{ formatCurrency(stats.estimatedDiscountAmount) }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type {
MarketingCalendarActivityBarDto,
MarketingCalendarActivityDto,
MarketingCalendarDayDto,
} from '#/api/marketing';
import { Empty } from 'ant-design-vue';
defineProps<{
activities: MarketingCalendarActivityDto[];
days: MarketingCalendarDayDto[];
todayDay: number;
}>();
const emit = defineEmits<{
(event: 'selectActivity', activityId: string): void;
}>();
function buildBarStyle(
bar: MarketingCalendarActivityBarDto,
color: string,
dayCount: number,
) {
const safeDayCount = Math.max(1, dayCount);
const start = Math.max(1, bar.startDay);
const end = Math.max(start, bar.endDay);
const span = Math.max(1, end - start + 1);
const left = ((start - 1) / safeDayCount) * 100;
const width = (span / safeDayCount) * 100;
return {
left: `${left}%`,
width: `${width}%`,
background: color,
opacity: bar.isDimmed ? '0.65' : '1',
};
}
function buildTodayLineStyle(todayDay: number, dayCount: number) {
const safeDayCount = Math.max(1, dayCount);
return {
left: `calc((${todayDay} - 0.5) / ${safeDayCount} * 100%)`,
};
}
</script>
<template>
<div class="mc-gantt">
<div
class="mc-dates"
:style="{ gridTemplateColumns: `180px repeat(${days.length || 1}, 1fr)` }"
>
<div class="mc-dates-label">活动名称</div>
<div
v-for="item in days"
:key="item.day"
class="mc-date-cell"
:class="{ weekend: item.isWeekend, today: item.isToday }"
>
{{ item.day }}
</div>
</div>
<div v-if="activities.length === 0" class="mc-empty-row">
<Empty description="当月暂无活动" />
</div>
<div
v-for="activity in activities"
:key="activity.activityId"
class="mc-row"
>
<div class="mc-row-label">
<span
class="mc-type-dot"
:style="{ background: activity.color }"
></span>
<span class="mc-row-label-name">{{ activity.name }}</span>
</div>
<div
class="mc-bar-area"
:style="{ gridTemplateColumns: `repeat(${days.length || 1}, 1fr)` }"
>
<button
v-for="bar in activity.bars"
:key="bar.barId"
type="button"
class="mc-bar"
:class="{ milestone: bar.isMilestone }"
:style="buildBarStyle(bar, activity.color, days.length)"
@click="emit('selectActivity', activity.activityId)"
>
{{ bar.label }}
</button>
<div
v-if="todayDay > 0"
class="mc-today-line"
:style="buildTodayLineStyle(todayDay, days.length)"
></div>
</div>
</div>
</div>
</template>

View File

@@ -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<string, string> = {
reduce: '满减',
gift: '满赠',
second_half: '第二份半价',
flash_sale: '限时折扣',
seckill: '秒杀',
coupon: '优惠券',
punch_card: '次卡',
};
/** 状态文案映射。 */
export const CALENDAR_STATUS_TEXT_MAP: Record<string, string> = {
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: [],
};
}

View File

@@ -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<MarketingCalendarMonthCursor>;
isOverviewLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
overview: Ref<MarketingCalendarOverviewViewModel>;
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
}
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,
};
}

View File

@@ -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<MarketingCalendarOverviewViewModel>;
}
export function createDrawerActions(options: CreateDrawerActionsOptions) {
const isActivityDrawerOpen = ref(false);
const isConflictDrawerOpen = ref(false);
const activeActivityId = ref('');
const activeConflictId = ref('');
const activeActivity = computed<MarketingCalendarActivityViewModel | null>(
() =>
options.overview.value.activities.find(
(item) => item.activityId === activeActivityId.value,
) ?? null,
);
const activeConflict = computed<MarketingCalendarConflictViewModel | null>(
() =>
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,
};
}

View File

@@ -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}`;
}

View File

@@ -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<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const isOverviewLoading = ref(false);
const cursor = ref<MarketingCalendarMonthCursor>(createCurrentMonthCursor());
const overview = ref<MarketingCalendarOverviewViewModel>(
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,
};
}

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* 文件职责:营销中心-营销日历页面主视图。
*/
import { Page } from '@vben/common-ui';
import { Select, Spin } from 'ant-design-vue';
import MarketingCalendarActivityDetailDrawer from './components/MarketingCalendarActivityDetailDrawer.vue';
import MarketingCalendarConflictBanner from './components/MarketingCalendarConflictBanner.vue';
import MarketingCalendarConflictDetailDrawer from './components/MarketingCalendarConflictDetailDrawer.vue';
import MarketingCalendarMonthHeader from './components/MarketingCalendarMonthHeader.vue';
import MarketingCalendarStatsCards from './components/MarketingCalendarStatsCards.vue';
import MarketingCalendarTimeline from './components/MarketingCalendarTimeline.vue';
import { useMarketingCalendarPage } from './composables/useMarketingCalendarPage';
const {
activeActivity,
activeConflict,
activeConflictActivities,
canManage,
closeActivityDrawer,
closeConflictDrawer,
hasStore,
isActivityDrawerOpen,
isConflictDrawerOpen,
isOverviewLoading,
isStoreLoading,
monthTitle,
nextMonth,
openActivityDrawer,
openConflictDrawer,
overview,
prevMonth,
selectedStoreId,
setSelectedStoreId,
storeOptions,
viewActivityFromConflict,
} = useMarketingCalendarPage();
</script>
<template>
<Page title="营销日历" content-class="page-marketing-calendar">
<div class="mc-page">
<div class="mc-toolbar">
<Select
class="mc-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<span v-if="!canManage" class="mc-readonly-tip">当前为只读权限</span>
</div>
<div v-if="!hasStore" class="mc-empty">暂无门店请先创建门店</div>
<template v-else>
<Spin :spinning="isOverviewLoading">
<MarketingCalendarConflictBanner
:banner="overview.conflictBanner"
@open-conflict="openConflictDrawer"
/>
<MarketingCalendarMonthHeader
:month-title="monthTitle"
:legends="overview.legends"
@prev-month="prevMonth"
@next-month="nextMonth"
/>
<MarketingCalendarStatsCards :stats="overview.stats" />
<MarketingCalendarTimeline
:days="overview.days"
:activities="overview.activities"
:today-day="overview.todayDay"
@select-activity="openActivityDrawer"
/>
</Spin>
</template>
</div>
<MarketingCalendarActivityDetailDrawer
:open="isActivityDrawerOpen"
:activity="activeActivity"
@close="closeActivityDrawer"
/>
<MarketingCalendarConflictDetailDrawer
:open="isConflictDrawerOpen"
:conflict="activeConflict"
:activities="activeConflictActivities"
@close="closeConflictDrawer"
@view-activity="viewActivityFromConflict"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -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%);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
@import './base.less';
@import './layout.less';
@import './timeline.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -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;
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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: [],
};
}