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);
|
return requestClient.post('/marketing/coupon/delete', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from './calendar';
|
||||||
export * from './flash-sale';
|
export * from './flash-sale';
|
||||||
export * from './full-reduction';
|
export * from './full-reduction';
|
||||||
export * from './new-customer';
|
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