feat(project): implement marketing calendar module
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
This commit is contained in:
133
apps/web-antd/src/api/marketing/calendar.ts
Normal file
133
apps/web-antd/src/api/marketing/calendar.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}日`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
103
apps/web-antd/src/views/marketing/calendar/index.vue
Normal file
103
apps/web-antd/src/views/marketing/calendar/index.vue
Normal 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>
|
||||
33
apps/web-antd/src/views/marketing/calendar/styles/base.less
Normal file
33
apps/web-antd/src/views/marketing/calendar/styles/base.less
Normal 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%);
|
||||
}
|
||||
}
|
||||
112
apps/web-antd/src/views/marketing/calendar/styles/drawer.less
Normal file
112
apps/web-antd/src/views/marketing/calendar/styles/drawer.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import './base.less';
|
||||
@import './layout.less';
|
||||
@import './timeline.less';
|
||||
@import './drawer.less';
|
||||
@import './responsive.less';
|
||||
138
apps/web-antd/src/views/marketing/calendar/styles/layout.less
Normal file
138
apps/web-antd/src/views/marketing/calendar/styles/layout.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
146
apps/web-antd/src/views/marketing/calendar/styles/timeline.less
Normal file
146
apps/web-antd/src/views/marketing/calendar/styles/timeline.less
Normal 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;
|
||||
}
|
||||
}
|
||||
53
apps/web-antd/src/views/marketing/calendar/types.ts
Normal file
53
apps/web-antd/src/views/marketing/calendar/types.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user