feat(@vben/web-antd): implement full reduction activity 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:
@@ -26,7 +26,7 @@
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@microsoft/signalr": "catalog:",
|
||||
"@vben/access": "workspace:*",
|
||||
"@vben/common-ui": "workspace:*",
|
||||
"@vben/constants": "workspace:*",
|
||||
|
||||
240
apps/web-antd/src/api/marketing/full-reduction.ts
Normal file
240
apps/web-antd/src/api/marketing/full-reduction.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 文件职责:营销中心满减活动 API 与 DTO 定义。
|
||||
* 1. 维护满减活动列表、详情、保存、状态切换与删除契约。
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 活动类型。 */
|
||||
export type MarketingFullReductionActivityType =
|
||||
| 'gift'
|
||||
| 'reduce'
|
||||
| 'second_half';
|
||||
|
||||
/** 展示状态。 */
|
||||
export type MarketingFullReductionDisplayStatus =
|
||||
| 'ended'
|
||||
| 'ongoing'
|
||||
| 'upcoming';
|
||||
|
||||
/** 编辑状态。 */
|
||||
export type MarketingFullReductionEditorStatus = 'active' | 'completed';
|
||||
|
||||
/** 适用渠道。 */
|
||||
export type MarketingFullReductionChannel = 'delivery' | 'dine_in' | 'pickup';
|
||||
|
||||
/** 门店范围。 */
|
||||
export type MarketingFullReductionStoreScopeMode = 'all' | 'stores';
|
||||
|
||||
/** 商品范围。 */
|
||||
export type MarketingFullReductionScopeType = 'all' | 'category' | 'product';
|
||||
|
||||
/** 满赠赠品类型。 */
|
||||
export type MarketingFullReductionGiftScopeType = 'same_lowest' | 'specified';
|
||||
|
||||
/** 第二份折扣类型。 */
|
||||
export type MarketingFullReductionSecondHalfDiscountType =
|
||||
| 'free'
|
||||
| 'half'
|
||||
| 'seventy'
|
||||
| 'sixty';
|
||||
|
||||
/** 满减阶梯规则。 */
|
||||
export interface MarketingFullReductionTierRuleDto {
|
||||
meetAmount: number;
|
||||
reduceAmount: number;
|
||||
}
|
||||
|
||||
/** 商品范围规则。 */
|
||||
export interface MarketingFullReductionScopeRuleDto {
|
||||
categoryIds: string[];
|
||||
productIds: string[];
|
||||
scopeType: MarketingFullReductionScopeType;
|
||||
}
|
||||
|
||||
/** 满赠规则。 */
|
||||
export interface MarketingFullReductionGiftRuleDto {
|
||||
applicableScope: MarketingFullReductionScopeRuleDto;
|
||||
buyQuantity: number;
|
||||
giftQuantity: number;
|
||||
giftScope: MarketingFullReductionScopeRuleDto;
|
||||
giftScopeType: MarketingFullReductionGiftScopeType;
|
||||
}
|
||||
|
||||
/** 第二份半价规则。 */
|
||||
export interface MarketingFullReductionSecondHalfRuleDto {
|
||||
applicableScope: MarketingFullReductionScopeRuleDto;
|
||||
discountType: MarketingFullReductionSecondHalfDiscountType;
|
||||
}
|
||||
|
||||
/** 活动指标。 */
|
||||
export interface MarketingFullReductionMetricsDto {
|
||||
attachRateIncreasePercent: number;
|
||||
averageTicketIncrease: number;
|
||||
discountTotalAmount: number;
|
||||
drivenSalesAmount: number;
|
||||
giftedCount: number;
|
||||
monthlyDrivenSalesAmount: number;
|
||||
participatingOrderCount: number;
|
||||
ticketIncreaseAmount: number;
|
||||
}
|
||||
|
||||
/** 列表查询参数。 */
|
||||
export interface MarketingFullReductionListQuery {
|
||||
activityType?: '' | MarketingFullReductionActivityType;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: '' | MarketingFullReductionDisplayStatus;
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
/** 详情查询参数。 */
|
||||
export interface MarketingFullReductionDetailQuery {
|
||||
activityId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存请求。 */
|
||||
export interface SaveMarketingFullReductionDto {
|
||||
activityType: MarketingFullReductionActivityType;
|
||||
channels: MarketingFullReductionChannel[];
|
||||
description?: string;
|
||||
endDate: string;
|
||||
giftRule?: MarketingFullReductionGiftRuleDto;
|
||||
id?: string;
|
||||
metrics?: MarketingFullReductionMetricsDto;
|
||||
name: string;
|
||||
reduceTiers: MarketingFullReductionTierRuleDto[];
|
||||
scopeStoreId: string;
|
||||
secondHalfRule?: MarketingFullReductionSecondHalfRuleDto;
|
||||
stackWithCoupon: boolean;
|
||||
startDate: string;
|
||||
storeId: string;
|
||||
storeIds?: string[];
|
||||
storeScopeMode: MarketingFullReductionStoreScopeMode;
|
||||
}
|
||||
|
||||
/** 状态修改请求。 */
|
||||
export interface ChangeMarketingFullReductionStatusDto {
|
||||
activityId: string;
|
||||
status: MarketingFullReductionEditorStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除请求。 */
|
||||
export interface DeleteMarketingFullReductionDto {
|
||||
activityId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 统计数据。 */
|
||||
export interface MarketingFullReductionStatsDto {
|
||||
averageTicketIncrease: number;
|
||||
monthlyDrivenSalesAmount: number;
|
||||
ongoingCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 列表项。 */
|
||||
export interface MarketingFullReductionListItemDto {
|
||||
activityType: MarketingFullReductionActivityType;
|
||||
channels: MarketingFullReductionChannel[];
|
||||
description?: string;
|
||||
displayStatus: MarketingFullReductionDisplayStatus;
|
||||
endDate: string;
|
||||
giftRule?: MarketingFullReductionGiftRuleDto;
|
||||
id: string;
|
||||
isDimmed: boolean;
|
||||
metrics: MarketingFullReductionMetricsDto;
|
||||
name: string;
|
||||
reduceTiers: MarketingFullReductionTierRuleDto[];
|
||||
scopeStoreId: string;
|
||||
secondHalfRule?: MarketingFullReductionSecondHalfRuleDto;
|
||||
stackWithCoupon: boolean;
|
||||
startDate: string;
|
||||
storeIds: string[];
|
||||
storeScopeMode: MarketingFullReductionStoreScopeMode;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 列表结果。 */
|
||||
export interface MarketingFullReductionListResultDto {
|
||||
items: MarketingFullReductionListItemDto[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
stats: MarketingFullReductionStatsDto;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 详情数据。 */
|
||||
export interface MarketingFullReductionDetailDto {
|
||||
activityType: MarketingFullReductionActivityType;
|
||||
channels: MarketingFullReductionChannel[];
|
||||
description?: string;
|
||||
displayStatus: MarketingFullReductionDisplayStatus;
|
||||
endDate: string;
|
||||
giftRule?: MarketingFullReductionGiftRuleDto;
|
||||
id: string;
|
||||
metrics: MarketingFullReductionMetricsDto;
|
||||
name: string;
|
||||
reduceTiers: MarketingFullReductionTierRuleDto[];
|
||||
scopeStoreId: string;
|
||||
secondHalfRule?: MarketingFullReductionSecondHalfRuleDto;
|
||||
stackWithCoupon: boolean;
|
||||
startDate: string;
|
||||
status: MarketingFullReductionEditorStatus;
|
||||
storeIds: string[];
|
||||
storeScopeMode: MarketingFullReductionStoreScopeMode;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 获取列表。 */
|
||||
export async function getMarketingFullReductionListApi(
|
||||
params: MarketingFullReductionListQuery,
|
||||
) {
|
||||
return requestClient.get<MarketingFullReductionListResultDto>(
|
||||
'/marketing/full-reduction/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取详情。 */
|
||||
export async function getMarketingFullReductionDetailApi(
|
||||
params: MarketingFullReductionDetailQuery,
|
||||
) {
|
||||
return requestClient.get<MarketingFullReductionDetailDto>(
|
||||
'/marketing/full-reduction/detail',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存活动。 */
|
||||
export async function saveMarketingFullReductionApi(
|
||||
data: SaveMarketingFullReductionDto,
|
||||
) {
|
||||
return requestClient.post<MarketingFullReductionDetailDto>(
|
||||
'/marketing/full-reduction/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 修改状态。 */
|
||||
export async function changeMarketingFullReductionStatusApi(
|
||||
data: ChangeMarketingFullReductionStatusDto,
|
||||
) {
|
||||
return requestClient.post<MarketingFullReductionDetailDto>(
|
||||
'/marketing/full-reduction/status',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除活动。 */
|
||||
export async function deleteMarketingFullReductionApi(
|
||||
data: DeleteMarketingFullReductionDto,
|
||||
) {
|
||||
return requestClient.post('/marketing/full-reduction/delete', data);
|
||||
}
|
||||
@@ -182,3 +182,5 @@ export async function changeMarketingCouponStatusApi(
|
||||
export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) {
|
||||
return requestClient.post('/marketing/coupon/delete', data);
|
||||
}
|
||||
|
||||
export * from './full-reduction';
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:满减活动卡片项。
|
||||
* 1. 还原活动规则、渠道/门店信息与数据行。
|
||||
* 2. 对外抛出编辑、停用、删除动作。
|
||||
*/
|
||||
import type { FullReductionCardViewModel } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
FULL_REDUCTION_ACTIVITY_TAG_CLASS_MAP,
|
||||
FULL_REDUCTION_STATUS_TAG_CLASS_MAP,
|
||||
FULL_REDUCTION_STATUS_TEXT_MAP,
|
||||
} from '../composables/full-reduction-page/constants';
|
||||
import {
|
||||
isEndedStatus,
|
||||
resolveActivityTypeLabel,
|
||||
resolveCardMetrics,
|
||||
resolveChannelsText,
|
||||
resolveExtraMetaText,
|
||||
resolveRulePills,
|
||||
resolveStoreScopeText,
|
||||
} from '../composables/full-reduction-page/helpers';
|
||||
|
||||
const props = defineProps<{
|
||||
item: FullReductionCardViewModel;
|
||||
storeNameMap: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
disable: [item: FullReductionCardViewModel];
|
||||
edit: [item: FullReductionCardViewModel];
|
||||
remove: [item: FullReductionCardViewModel];
|
||||
}>();
|
||||
|
||||
const rulePills = computed(() => resolveRulePills(props.item));
|
||||
const extraMetaText = computed(() => resolveExtraMetaText(props.item));
|
||||
const cardMetrics = computed(() => resolveCardMetrics(props.item));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mfr-card" :class="{ 'mfr-dimmed': item.isDimmed }">
|
||||
<div class="mfr-card-head">
|
||||
<span class="mfr-card-name">{{ item.name }}</span>
|
||||
<span
|
||||
class="mfr-type-tag"
|
||||
:class="FULL_REDUCTION_ACTIVITY_TAG_CLASS_MAP[item.activityType]"
|
||||
>
|
||||
{{ resolveActivityTypeLabel(item.activityType) }}
|
||||
</span>
|
||||
<span
|
||||
class="mfr-status-tag"
|
||||
:class="FULL_REDUCTION_STATUS_TAG_CLASS_MAP[item.displayStatus]"
|
||||
>
|
||||
{{ FULL_REDUCTION_STATUS_TEXT_MAP[item.displayStatus] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mfr-tiers">
|
||||
<template
|
||||
v-for="(tierText, tierIndex) in rulePills"
|
||||
:key="`${tierIndex}-${tierText}`"
|
||||
>
|
||||
<span class="mfr-tier-pill">{{ tierText }}</span>
|
||||
<span v-if="tierIndex < rulePills.length - 1" class="mfr-tier-arrow">
|
||||
<IconifyIcon icon="lucide:chevron-right" />
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mfr-meta">
|
||||
<span class="mfr-meta-item">
|
||||
<IconifyIcon icon="lucide:calendar" />
|
||||
{{ item.startDate }} ~ {{ item.endDate }}
|
||||
</span>
|
||||
<span class="mfr-meta-item">
|
||||
<IconifyIcon icon="lucide:truck" />
|
||||
{{ resolveChannelsText(item.channels) }}
|
||||
</span>
|
||||
<span class="mfr-meta-item">
|
||||
<IconifyIcon icon="lucide:store" />
|
||||
{{ resolveStoreScopeText(item, storeNameMap) }}
|
||||
</span>
|
||||
<span v-if="extraMetaText" class="mfr-meta-item">
|
||||
<IconifyIcon icon="lucide:tag" />
|
||||
{{ extraMetaText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mfr-data">
|
||||
<span
|
||||
v-for="metric in cardMetrics"
|
||||
:key="metric.label"
|
||||
class="mfr-data-item"
|
||||
>
|
||||
{{ metric.label }} <strong>{{ metric.value }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mfr-card-actions">
|
||||
<button type="button" class="g-action" @click="emit('edit', props.item)">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="!isEndedStatus(item.displayStatus)"
|
||||
type="button"
|
||||
class="g-action"
|
||||
@click="emit('disable', props.item)"
|
||||
>
|
||||
停用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="g-action g-action-danger"
|
||||
@click="emit('remove', props.item)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,449 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { FullReductionEditorForm } from '../types';
|
||||
|
||||
import type {
|
||||
MarketingFullReductionActivityType,
|
||||
MarketingFullReductionChannel,
|
||||
MarketingFullReductionGiftScopeType,
|
||||
MarketingFullReductionSecondHalfDiscountType,
|
||||
MarketingFullReductionStoreScopeMode,
|
||||
} from '#/api/marketing';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
/**
|
||||
* 文件职责:满减活动编辑抽屉。
|
||||
* 1. 承载活动类型、规则、时间、范围等编辑 UI。
|
||||
* 2. 抛出字段变更、范围选择与保存事件。
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
FULL_REDUCTION_CHANNEL_OPTIONS,
|
||||
FULL_REDUCTION_EDITOR_ACTIVITY_OPTIONS,
|
||||
FULL_REDUCTION_GIFT_SCOPE_OPTIONS,
|
||||
FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS,
|
||||
FULL_REDUCTION_STACK_OPTIONS,
|
||||
FULL_REDUCTION_STORE_SCOPE_OPTIONS,
|
||||
} from '../composables/full-reduction-page/constants';
|
||||
import { resolveScopeSummary } from '../composables/full-reduction-page/helpers';
|
||||
|
||||
defineProps<{
|
||||
form: FullReductionEditorForm;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
storeOptions: Array<{ label: string; value: string }>;
|
||||
submitText: string;
|
||||
submitting: boolean;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'addReduceTier'): void;
|
||||
(event: 'close'): void;
|
||||
(event: 'openGiftApplicablePicker'): void;
|
||||
(event: 'openGiftScopePicker'): void;
|
||||
(event: 'openSecondHalfApplicablePicker'): void;
|
||||
(event: 'removeReduceTier', index: number): void;
|
||||
(event: 'setActivityType', value: MarketingFullReductionActivityType): void;
|
||||
(event: 'setChannels', value: MarketingFullReductionChannel[]): void;
|
||||
(event: 'setDescription', value: string): void;
|
||||
(event: 'setFormStoreIds', value: string[]): void;
|
||||
(event: 'setGiftBuyQuantity', value: null | number): void;
|
||||
(event: 'setGiftGiftQuantity', value: null | number): void;
|
||||
(event: 'setGiftScopeType', value: MarketingFullReductionGiftScopeType): void;
|
||||
(event: 'setName', value: string): void;
|
||||
(event: 'setReduceTierMeetAmount', index: number, value: null | number): void;
|
||||
(
|
||||
event: 'setReduceTierReduceAmount',
|
||||
index: number,
|
||||
value: null | number,
|
||||
): void;
|
||||
(
|
||||
event: 'setSecondHalfDiscountType',
|
||||
value: MarketingFullReductionSecondHalfDiscountType,
|
||||
): void;
|
||||
(event: 'setStackWithCoupon', value: boolean): void;
|
||||
(
|
||||
event: 'setStoreScopeMode',
|
||||
value: MarketingFullReductionStoreScopeMode,
|
||||
): void;
|
||||
(event: 'setValidDateRange', value: [Dayjs, Dayjs] | null): void;
|
||||
(event: 'submit'): void;
|
||||
}>();
|
||||
|
||||
function onActivityTypeChange(value: unknown) {
|
||||
if (value === 'reduce' || value === 'gift' || value === 'second_half') {
|
||||
emit('setActivityType', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onGiftScopeTypeChange(value: unknown) {
|
||||
if (value === 'same_lowest' || value === 'specified') {
|
||||
emit('setGiftScopeType', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onSecondHalfDiscountTypeChange(value: unknown) {
|
||||
if (
|
||||
value === 'half' ||
|
||||
value === 'sixty' ||
|
||||
value === 'seventy' ||
|
||||
value === 'free'
|
||||
) {
|
||||
emit('setSecondHalfDiscountType', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onStoreScopeModeChange(value: unknown) {
|
||||
if (value === 'all' || value === 'stores') {
|
||||
emit('setStoreScopeMode', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onDateRangeChange(value: [Dayjs, Dayjs] | [string, string] | null) {
|
||||
if (!value) {
|
||||
emit('setValidDateRange', null);
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, end] = value;
|
||||
if (typeof start === 'string' || typeof end === 'string') {
|
||||
emit('setValidDateRange', [dayjs(String(start)), dayjs(String(end))]);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('setValidDateRange', value as [Dayjs, Dayjs]);
|
||||
}
|
||||
|
||||
function onStoreIdsChange(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
emit('setFormStoreIds', []);
|
||||
return;
|
||||
}
|
||||
emit('setFormStoreIds', value.map(String));
|
||||
}
|
||||
|
||||
function resolveToggledChannels(
|
||||
channels: MarketingFullReductionChannel[],
|
||||
channel: MarketingFullReductionChannel,
|
||||
) {
|
||||
if (channels.includes(channel)) {
|
||||
return channels.filter((item) => item !== channel);
|
||||
}
|
||||
return [...channels, channel];
|
||||
}
|
||||
|
||||
function parseNullableNumber(value: null | number | string) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isNaN(numeric) ? null : numeric;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="open"
|
||||
:title="title"
|
||||
width="560"
|
||||
:destroy-on-close="false"
|
||||
class="mfr-editor-drawer"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<Form layout="vertical" class="mfr-editor-form">
|
||||
<Form.Item label="活动类型" required>
|
||||
<Radio.Group
|
||||
:value="form.activityType"
|
||||
button-style="solid"
|
||||
@update:value="onActivityTypeChange"
|
||||
>
|
||||
<Radio.Button
|
||||
v-for="item in FULL_REDUCTION_EDITOR_ACTIVITY_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="活动名称" required>
|
||||
<Input
|
||||
:value="form.name"
|
||||
:maxlength="64"
|
||||
placeholder="如:午市满减优惠"
|
||||
@update:value="(value) => emit('setName', String(value ?? ''))"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<template v-if="form.activityType === 'reduce'">
|
||||
<div class="mfr-section-title">满减规则</div>
|
||||
<Form.Item
|
||||
v-for="(tier, index) in form.reduceTiers"
|
||||
:key="`tier-${index}`"
|
||||
class="mfr-tier-item"
|
||||
>
|
||||
<div class="mfr-inline-fields">
|
||||
<span>满</span>
|
||||
<InputNumber
|
||||
:value="tier.meetAmount ?? undefined"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="金额"
|
||||
@update:value="
|
||||
(value) =>
|
||||
emit(
|
||||
'setReduceTierMeetAmount',
|
||||
index,
|
||||
parseNullableNumber(value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>元减</span>
|
||||
<InputNumber
|
||||
:value="tier.reduceAmount ?? undefined"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="金额"
|
||||
@update:value="
|
||||
(value) =>
|
||||
emit(
|
||||
'setReduceTierReduceAmount',
|
||||
index,
|
||||
parseNullableNumber(value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>元</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mfr-tier-delete"
|
||||
:disabled="form.reduceTiers.length <= 1"
|
||||
@click="emit('removeReduceTier', index)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<button
|
||||
type="button"
|
||||
class="mfr-add-tier"
|
||||
@click="emit('addReduceTier')"
|
||||
>
|
||||
+ 添加阶梯
|
||||
</button>
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.activityType === 'gift'">
|
||||
<div class="mfr-section-title">满赠规则</div>
|
||||
<Form.Item label="赠送条件" required>
|
||||
<div class="mfr-inline-fields">
|
||||
<span>购买满</span>
|
||||
<InputNumber
|
||||
:value="form.giftRule.buyQuantity ?? undefined"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
placeholder="如:2"
|
||||
@update:value="
|
||||
(value) =>
|
||||
emit('setGiftBuyQuantity', parseNullableNumber(value))
|
||||
"
|
||||
/>
|
||||
<span>件赠</span>
|
||||
<InputNumber
|
||||
:value="form.giftRule.giftQuantity ?? undefined"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
placeholder="如:1"
|
||||
@update:value="
|
||||
(value) =>
|
||||
emit('setGiftGiftQuantity', parseNullableNumber(value))
|
||||
"
|
||||
/>
|
||||
<span>件</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="赠品范围类型" required>
|
||||
<Radio.Group
|
||||
:value="form.giftRule.giftScopeType"
|
||||
button-style="solid"
|
||||
@update:value="onGiftScopeTypeChange"
|
||||
>
|
||||
<Radio.Button
|
||||
v-for="item in FULL_REDUCTION_GIFT_SCOPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="适用商品范围" required>
|
||||
<div class="mfr-scope-line">
|
||||
<Button @click="emit('openGiftApplicablePicker')">
|
||||
选择商品/分类
|
||||
</Button>
|
||||
<span class="mfr-scope-text">
|
||||
{{ resolveScopeSummary(form.giftRule.applicableScope) }}
|
||||
</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
v-if="form.giftRule.giftScopeType === 'specified'"
|
||||
label="指定赠品范围"
|
||||
required
|
||||
>
|
||||
<div class="mfr-scope-line">
|
||||
<Button @click="emit('openGiftScopePicker')">选择赠品</Button>
|
||||
<span class="mfr-scope-text">
|
||||
{{ resolveScopeSummary(form.giftRule.giftScope) }}
|
||||
</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.activityType === 'second_half'">
|
||||
<div class="mfr-section-title">第二份优惠</div>
|
||||
<Form.Item label="第二份折扣" required>
|
||||
<Radio.Group
|
||||
:value="form.secondHalfRule.discountType"
|
||||
button-style="solid"
|
||||
@update:value="onSecondHalfDiscountTypeChange"
|
||||
>
|
||||
<Radio.Button
|
||||
v-for="item in FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="适用商品范围" required>
|
||||
<div class="mfr-scope-line">
|
||||
<Button @click="emit('openSecondHalfApplicablePicker')">
|
||||
选择商品/分类
|
||||
</Button>
|
||||
<span class="mfr-scope-text">
|
||||
{{ resolveScopeSummary(form.secondHalfRule.applicableScope) }}
|
||||
</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
||||
<div class="mfr-section-divider"></div>
|
||||
|
||||
<Form.Item label="活动时间" required>
|
||||
<DatePicker.RangePicker
|
||||
:value="form.validDateRange ?? undefined"
|
||||
format="YYYY-MM-DD"
|
||||
class="mfr-range-picker"
|
||||
@update:value="onDateRangeChange"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="适用渠道" required>
|
||||
<div class="mfr-channel-pills">
|
||||
<button
|
||||
v-for="item in FULL_REDUCTION_CHANNEL_OPTIONS"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="mfr-channel-pill"
|
||||
:class="{ checked: form.channels.includes(item.value) }"
|
||||
@click="
|
||||
emit(
|
||||
'setChannels',
|
||||
resolveToggledChannels(form.channels, item.value),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="适用门店" required>
|
||||
<Radio.Group
|
||||
:value="form.storeScopeMode"
|
||||
@update:value="onStoreScopeModeChange"
|
||||
>
|
||||
<Radio
|
||||
v-for="item in FULL_REDUCTION_STORE_SCOPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<Select
|
||||
v-if="form.storeScopeMode === 'stores'"
|
||||
mode="multiple"
|
||||
:value="form.storeIds"
|
||||
:options="storeOptions"
|
||||
class="mfr-store-multi"
|
||||
placeholder="请选择适用门店"
|
||||
@update:value="onStoreIdsChange"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="叠加规则">
|
||||
<div class="mfr-channel-pills">
|
||||
<button
|
||||
v-for="item in FULL_REDUCTION_STACK_OPTIONS"
|
||||
:key="String(item.value)"
|
||||
type="button"
|
||||
class="mfr-channel-pill"
|
||||
:class="{ checked: form.stackWithCoupon === item.value }"
|
||||
@click="emit('setStackWithCoupon', item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="活动说明">
|
||||
<Input.TextArea
|
||||
:value="form.description"
|
||||
:maxlength="512"
|
||||
:rows="3"
|
||||
placeholder="如:每单限享一次活动优惠"
|
||||
@update:value="
|
||||
(value) => emit('setDescription', String(value ?? ''))
|
||||
"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
|
||||
<template #footer>
|
||||
<div class="mfr-drawer-footer">
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="emit('submit')">
|
||||
{{ submitText }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
FullReductionPickerCategoryItem,
|
||||
FullReductionPickerProductItem,
|
||||
FullReductionScopeForm,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动二级范围选择抽屉。
|
||||
* 1. 支持分类/商品选择、搜索、筛选与全选。
|
||||
* 2. 输出统一 scope 结果供主抽屉回填。
|
||||
*/
|
||||
import type { ProductStatus } from '#/api/product';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Input,
|
||||
Radio,
|
||||
Select,
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { formatCurrency } from '../composables/full-reduction-page/helpers';
|
||||
|
||||
const props = defineProps<{
|
||||
allowAll: boolean;
|
||||
categories: FullReductionPickerCategoryItem[];
|
||||
categoryFilterId: string;
|
||||
keyword: string;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
products: FullReductionPickerProductItem[];
|
||||
scope: FullReductionScopeForm;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void;
|
||||
(event: 'search'): void;
|
||||
(event: 'setCategoryFilterId', value: string): void;
|
||||
(event: 'setCategoryIds', value: string[]): void;
|
||||
(event: 'setKeyword', value: string): void;
|
||||
(event: 'setProductIds', value: string[]): void;
|
||||
(event: 'setScopeType', value: FullReductionScopeForm['scopeType']): void;
|
||||
(event: 'submit'): void;
|
||||
}>();
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ label: '全部分类', value: '' },
|
||||
...props.categories.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
]);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const keyword = props.keyword.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return props.categories;
|
||||
}
|
||||
return props.categories.filter((item) =>
|
||||
item.name.toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const keyword = props.keyword.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return props.products;
|
||||
}
|
||||
|
||||
return props.products.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.spuCode.toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
|
||||
const isCategoryMode = computed(() => props.scope.scopeType === 'category');
|
||||
const isProductMode = computed(() => props.scope.scopeType === 'product');
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
if (props.scope.scopeType === 'category') {
|
||||
return props.scope.categoryIds.length;
|
||||
}
|
||||
if (props.scope.scopeType === 'product') {
|
||||
return props.scope.productIds.length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const categoryAllChecked = computed(
|
||||
() =>
|
||||
filteredCategories.value.length > 0 &&
|
||||
filteredCategories.value.every((item) =>
|
||||
props.scope.categoryIds.includes(item.id),
|
||||
),
|
||||
);
|
||||
|
||||
const productAllChecked = computed(
|
||||
() =>
|
||||
filteredProducts.value.length > 0 &&
|
||||
filteredProducts.value.every((item) =>
|
||||
props.scope.productIds.includes(item.id),
|
||||
),
|
||||
);
|
||||
|
||||
function setScopeType(value: unknown) {
|
||||
if (value === 'all' || value === 'category' || value === 'product') {
|
||||
emit('setScopeType', value);
|
||||
}
|
||||
}
|
||||
|
||||
function setKeyword(value: string) {
|
||||
emit('setKeyword', value);
|
||||
}
|
||||
|
||||
function setCategoryFilterId(value: unknown) {
|
||||
emit('setCategoryFilterId', String(value ?? ''));
|
||||
}
|
||||
|
||||
function toggleCategory(id: string) {
|
||||
if (props.scope.categoryIds.includes(id)) {
|
||||
emit(
|
||||
'setCategoryIds',
|
||||
props.scope.categoryIds.filter((item) => item !== id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit('setCategoryIds', [...props.scope.categoryIds, id]);
|
||||
}
|
||||
|
||||
function toggleProduct(id: string) {
|
||||
if (props.scope.productIds.includes(id)) {
|
||||
emit(
|
||||
'setProductIds',
|
||||
props.scope.productIds.filter((item) => item !== id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit('setProductIds', [...props.scope.productIds, id]);
|
||||
}
|
||||
|
||||
function toggleAllCategories() {
|
||||
if (categoryAllChecked.value) {
|
||||
const visibleIds = new Set(filteredCategories.value.map((item) => item.id));
|
||||
emit(
|
||||
'setCategoryIds',
|
||||
props.scope.categoryIds.filter((id) => !visibleIds.has(id)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = new Set(props.scope.categoryIds);
|
||||
for (const item of filteredCategories.value) {
|
||||
merged.add(item.id);
|
||||
}
|
||||
emit('setCategoryIds', [...merged]);
|
||||
}
|
||||
|
||||
function toggleAllProducts() {
|
||||
if (productAllChecked.value) {
|
||||
const visibleIds = new Set(filteredProducts.value.map((item) => item.id));
|
||||
emit(
|
||||
'setProductIds',
|
||||
props.scope.productIds.filter((id) => !visibleIds.has(id)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = new Set(props.scope.productIds);
|
||||
for (const item of filteredProducts.value) {
|
||||
merged.add(item.id);
|
||||
}
|
||||
emit('setProductIds', [...merged]);
|
||||
}
|
||||
|
||||
function resolveProductStatusText(status: ProductStatus) {
|
||||
if (status === 'on_sale') {
|
||||
return '在售';
|
||||
}
|
||||
if (status === 'sold_out') {
|
||||
return '沽清';
|
||||
}
|
||||
return '下架';
|
||||
}
|
||||
|
||||
function resolveProductStatusClass(status: ProductStatus) {
|
||||
if (status === 'on_sale') {
|
||||
return 'mfr-prod-status-on';
|
||||
}
|
||||
if (status === 'sold_out') {
|
||||
return 'mfr-prod-status-soldout';
|
||||
}
|
||||
return 'mfr-prod-status-off';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="open"
|
||||
:title="title"
|
||||
width="760"
|
||||
class="mfr-picker-drawer"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="mfr-picker-toolbar">
|
||||
<Radio.Group :value="scope.scopeType" @update:value="setScopeType">
|
||||
<Radio.Button v-if="allowAll" value="all">全部商品</Radio.Button>
|
||||
<Radio.Button value="category">按分类选择</Radio.Button>
|
||||
<Radio.Button value="product">按商品选择</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<template v-if="scope.scopeType !== 'all'">
|
||||
<div class="mfr-picker-search">
|
||||
<Input
|
||||
:value="keyword"
|
||||
placeholder="搜索商品名称/SPU"
|
||||
allow-clear
|
||||
@update:value="setKeyword"
|
||||
@press-enter="emit('search')"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-if="isProductMode"
|
||||
:value="categoryFilterId"
|
||||
:options="categoryOptions"
|
||||
class="mfr-picker-category"
|
||||
@update:value="setCategoryFilterId"
|
||||
/>
|
||||
<Button v-if="isProductMode" @click="emit('search')">搜索</Button>
|
||||
<span class="mfr-picker-count">已选 {{ selectedCount }} 个</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="scope.scopeType === 'all'" class="mfr-picker-all">
|
||||
当前规则将作用于全部商品
|
||||
</div>
|
||||
|
||||
<div v-else class="mfr-picker-body">
|
||||
<div
|
||||
v-if="
|
||||
(isCategoryMode && filteredCategories.length === 0) ||
|
||||
(isProductMode && filteredProducts.length === 0)
|
||||
"
|
||||
class="mfr-picker-empty"
|
||||
>
|
||||
<Empty description="暂无可选数据" />
|
||||
</div>
|
||||
|
||||
<table v-else class="mfr-picker-table">
|
||||
<thead>
|
||||
<tr v-if="isCategoryMode">
|
||||
<th class="mfr-picker-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="categoryAllChecked"
|
||||
@change="toggleAllCategories"
|
||||
/>
|
||||
</th>
|
||||
<th>分类名称</th>
|
||||
<th class="mfr-picker-col-count">商品数</th>
|
||||
</tr>
|
||||
|
||||
<tr v-else>
|
||||
<th class="mfr-picker-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="productAllChecked"
|
||||
@change="toggleAllProducts"
|
||||
/>
|
||||
</th>
|
||||
<th>商品</th>
|
||||
<th>分类</th>
|
||||
<th class="mfr-picker-col-price">售价</th>
|
||||
<th class="mfr-picker-col-status">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody v-if="isCategoryMode">
|
||||
<tr
|
||||
v-for="item in filteredCategories"
|
||||
:key="`category-${item.id}`"
|
||||
:class="{ checked: scope.categoryIds.includes(item.id) }"
|
||||
@click="toggleCategory(item.id)"
|
||||
>
|
||||
<td class="mfr-picker-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="scope.categoryIds.includes(item.id)"
|
||||
@change.stop="toggleCategory(item.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="mfr-picker-col-count">{{ item.productCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<tbody v-else>
|
||||
<tr
|
||||
v-for="item in filteredProducts"
|
||||
:key="`product-${item.id}`"
|
||||
:class="{ checked: scope.productIds.includes(item.id) }"
|
||||
@click="toggleProduct(item.id)"
|
||||
>
|
||||
<td class="mfr-picker-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="scope.productIds.includes(item.id)"
|
||||
@change.stop="toggleProduct(item.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="mfr-picker-product-name">{{ item.name }}</div>
|
||||
<div class="mfr-picker-product-spu">{{ item.spuCode }}</div>
|
||||
</td>
|
||||
<td>{{ item.categoryName }}</td>
|
||||
<td class="mfr-picker-col-price">
|
||||
{{ formatCurrency(item.price) }}
|
||||
</td>
|
||||
<td class="mfr-picker-col-status">
|
||||
<span
|
||||
class="mfr-prod-status"
|
||||
:class="resolveProductStatusClass(item.status)"
|
||||
>
|
||||
{{ resolveProductStatusText(item.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<template #footer>
|
||||
<div class="mfr-picker-footer">
|
||||
<span class="mfr-picker-footer-info">
|
||||
已选择 {{ selectedCount }} 个
|
||||
</span>
|
||||
<div class="mfr-picker-footer-actions">
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button type="primary" @click="emit('submit')">确认选择</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:满减活动统计卡片。
|
||||
* 1. 展示活动总数、进行中、本月带动销售额和客单价提升。
|
||||
*/
|
||||
import type { FullReductionStatsViewModel } from '../types';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatInteger,
|
||||
} from '../composables/full-reduction-page/helpers';
|
||||
|
||||
defineProps<{
|
||||
stats: FullReductionStatsViewModel;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mfr-stats">
|
||||
<div class="mfr-stat-card">
|
||||
<span class="mfr-stat-icon mfr-stat-blue">
|
||||
<IconifyIcon icon="lucide:badge-percent" />
|
||||
</span>
|
||||
<div class="mfr-stat-main">
|
||||
<div class="mfr-stat-value">{{ formatInteger(stats.totalCount) }}</div>
|
||||
<div class="mfr-stat-label">活动总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mfr-stat-card">
|
||||
<span class="mfr-stat-icon mfr-stat-green">
|
||||
<IconifyIcon icon="lucide:play-circle" />
|
||||
</span>
|
||||
<div class="mfr-stat-main">
|
||||
<div class="mfr-stat-value mfr-stat-value-green">
|
||||
{{ formatInteger(stats.ongoingCount) }}
|
||||
</div>
|
||||
<div class="mfr-stat-label">进行中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mfr-stat-card">
|
||||
<span class="mfr-stat-icon mfr-stat-orange">
|
||||
<IconifyIcon icon="lucide:chart-column" />
|
||||
</span>
|
||||
<div class="mfr-stat-main">
|
||||
<div class="mfr-stat-value mfr-stat-value-orange">
|
||||
{{ formatCurrency(stats.monthlyDrivenSalesAmount) }}
|
||||
</div>
|
||||
<div class="mfr-stat-label">本月带动销售额</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mfr-stat-card">
|
||||
<span class="mfr-stat-icon mfr-stat-cyan">
|
||||
<IconifyIcon icon="lucide:trending-up" />
|
||||
</span>
|
||||
<div class="mfr-stat-main">
|
||||
<div class="mfr-stat-value">
|
||||
{{ formatCurrency(stats.averageTicketIncrease) }}
|
||||
</div>
|
||||
<div class="mfr-stat-label">平均客单价提升</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { FullReductionCardViewModel } from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动卡片行操作。
|
||||
* 1. 封装停用(完成)与删除动作。
|
||||
* 2. 统一确认弹窗与反馈提示。
|
||||
*/
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeMarketingFullReductionStatusApi,
|
||||
deleteMarketingFullReductionApi,
|
||||
} from '#/api/marketing';
|
||||
|
||||
interface CreateCardActionsOptions {
|
||||
loadActivities: () => Promise<void>;
|
||||
resolveOperationStoreId: (preferredStoreIds?: string[]) => string;
|
||||
}
|
||||
|
||||
export function createCardActions(options: CreateCardActionsOptions) {
|
||||
function disableActivity(item: FullReductionCardViewModel) {
|
||||
const operationStoreId = options.resolveOperationStoreId(item.storeIds);
|
||||
if (!operationStoreId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认停用活动「${item.name}」吗?`,
|
||||
content: '停用后活动将直接结束,且不可恢复。',
|
||||
okText: '确认停用',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
const feedbackKey = `full-reduction-status-${item.id}`;
|
||||
try {
|
||||
message.loading({
|
||||
key: feedbackKey,
|
||||
duration: 0,
|
||||
content: '正在停用活动...',
|
||||
});
|
||||
await changeMarketingFullReductionStatusApi({
|
||||
storeId: operationStoreId,
|
||||
activityId: item.id,
|
||||
status: 'completed',
|
||||
});
|
||||
message.success({
|
||||
key: feedbackKey,
|
||||
content: '活动已停用',
|
||||
});
|
||||
await options.loadActivities();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error({
|
||||
key: feedbackKey,
|
||||
content: '停用失败,请稍后重试',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function removeActivity(item: FullReductionCardViewModel) {
|
||||
const operationStoreId = options.resolveOperationStoreId(item.storeIds);
|
||||
if (!operationStoreId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认删除活动「${item.name}」吗?`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteMarketingFullReductionApi({
|
||||
storeId: operationStoreId,
|
||||
activityId: item.id,
|
||||
});
|
||||
message.success('活动已删除');
|
||||
await options.loadActivities();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
disableActivity,
|
||||
removeActivity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
MarketingFullReductionActivityType,
|
||||
MarketingFullReductionChannel,
|
||||
MarketingFullReductionDisplayStatus,
|
||||
MarketingFullReductionGiftScopeType,
|
||||
MarketingFullReductionSecondHalfDiscountType,
|
||||
MarketingFullReductionStoreScopeMode,
|
||||
} from '#/api/marketing';
|
||||
import type {
|
||||
FullReductionEditorForm,
|
||||
FullReductionFilterForm,
|
||||
FullReductionGiftRuleForm,
|
||||
FullReductionScopeForm,
|
||||
FullReductionSecondHalfRuleForm,
|
||||
FullReductionTierForm,
|
||||
} from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动页面常量与默认表单构造。
|
||||
*/
|
||||
|
||||
/** 活动类型筛选项。 */
|
||||
export const FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: '' | MarketingFullReductionActivityType;
|
||||
}> = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '满减', value: 'reduce' },
|
||||
{ label: '满赠', value: 'gift' },
|
||||
{ label: '第二份半价', value: 'second_half' },
|
||||
];
|
||||
|
||||
/** 活动类型单选项。 */
|
||||
export const FULL_REDUCTION_EDITOR_ACTIVITY_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingFullReductionActivityType;
|
||||
}> = [
|
||||
{ label: '满减', value: 'reduce' },
|
||||
{ label: '满赠', value: 'gift' },
|
||||
{ label: '第二份半价', value: 'second_half' },
|
||||
];
|
||||
|
||||
/** 展示状态筛选项。 */
|
||||
export const FULL_REDUCTION_STATUS_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: '' | MarketingFullReductionDisplayStatus;
|
||||
}> = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '进行中', value: 'ongoing' },
|
||||
{ label: '未开始', value: 'upcoming' },
|
||||
{ label: '已结束', value: 'ended' },
|
||||
];
|
||||
|
||||
/** 渠道选项。 */
|
||||
export const FULL_REDUCTION_CHANNEL_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingFullReductionChannel;
|
||||
}> = [
|
||||
{ label: '外卖', value: 'delivery' },
|
||||
{ label: '自提', value: 'pickup' },
|
||||
{ label: '堂食', value: 'dine_in' },
|
||||
];
|
||||
|
||||
/** 门店范围选项。 */
|
||||
export const FULL_REDUCTION_STORE_SCOPE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingFullReductionStoreScopeMode;
|
||||
}> = [
|
||||
{ label: '全部门店', value: 'all' },
|
||||
{ label: '指定门店', value: 'stores' },
|
||||
];
|
||||
|
||||
/** 满赠赠品范围类型。 */
|
||||
export const FULL_REDUCTION_GIFT_SCOPE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingFullReductionGiftScopeType;
|
||||
}> = [
|
||||
{ label: '同商品(价低者)', value: 'same_lowest' },
|
||||
{ label: '指定赠品', value: 'specified' },
|
||||
];
|
||||
|
||||
/** 第二份优惠折扣类型。 */
|
||||
export const FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingFullReductionSecondHalfDiscountType;
|
||||
}> = [
|
||||
{ label: '5折', value: 'half' },
|
||||
{ label: '6折', value: 'sixty' },
|
||||
{ label: '7折', value: 'seventy' },
|
||||
{ label: '免费', value: 'free' },
|
||||
];
|
||||
|
||||
/** 叠加规则选项。 */
|
||||
export const FULL_REDUCTION_STACK_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: boolean;
|
||||
}> = [
|
||||
{ label: '不可叠加优惠券', value: false },
|
||||
{ label: '可叠加优惠券', value: true },
|
||||
];
|
||||
|
||||
/** 活动类型文案。 */
|
||||
export const FULL_REDUCTION_ACTIVITY_TEXT_MAP: Record<
|
||||
MarketingFullReductionActivityType,
|
||||
string
|
||||
> = {
|
||||
reduce: '满减',
|
||||
gift: '满赠',
|
||||
second_half: '第二份半价',
|
||||
};
|
||||
|
||||
/** 活动类型标签样式类。 */
|
||||
export const FULL_REDUCTION_ACTIVITY_TAG_CLASS_MAP: Record<
|
||||
MarketingFullReductionActivityType,
|
||||
string
|
||||
> = {
|
||||
reduce: 'mfr-type-reduce',
|
||||
gift: 'mfr-type-gift',
|
||||
second_half: 'mfr-type-second-half',
|
||||
};
|
||||
|
||||
/** 活动状态文案。 */
|
||||
export const FULL_REDUCTION_STATUS_TEXT_MAP: Record<
|
||||
MarketingFullReductionDisplayStatus,
|
||||
string
|
||||
> = {
|
||||
ongoing: '进行中',
|
||||
upcoming: '未开始',
|
||||
ended: '已结束',
|
||||
};
|
||||
|
||||
/** 活动状态徽标样式类。 */
|
||||
export const FULL_REDUCTION_STATUS_TAG_CLASS_MAP: Record<
|
||||
MarketingFullReductionDisplayStatus,
|
||||
string
|
||||
> = {
|
||||
ongoing: 'mfr-status-ongoing',
|
||||
upcoming: 'mfr-status-upcoming',
|
||||
ended: 'mfr-status-ended',
|
||||
};
|
||||
|
||||
/** 渠道文案。 */
|
||||
export const FULL_REDUCTION_CHANNEL_TEXT_MAP: Record<
|
||||
MarketingFullReductionChannel,
|
||||
string
|
||||
> = {
|
||||
delivery: '外卖',
|
||||
pickup: '自提',
|
||||
dine_in: '堂食',
|
||||
};
|
||||
|
||||
/** 构建默认筛选表单。 */
|
||||
export function createDefaultFullReductionFilterForm(): FullReductionFilterForm {
|
||||
return {
|
||||
activityType: '',
|
||||
status: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建空指标对象。 */
|
||||
export function createEmptyFullReductionMetrics() {
|
||||
return {
|
||||
participatingOrderCount: 0,
|
||||
discountTotalAmount: 0,
|
||||
ticketIncreaseAmount: 0,
|
||||
giftedCount: 0,
|
||||
drivenSalesAmount: 0,
|
||||
attachRateIncreasePercent: 0,
|
||||
monthlyDrivenSalesAmount: 0,
|
||||
averageTicketIncrease: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建默认商品范围。 */
|
||||
export function createDefaultScopeForm(
|
||||
scopeType: FullReductionScopeForm['scopeType'] = 'all',
|
||||
): FullReductionScopeForm {
|
||||
return {
|
||||
scopeType,
|
||||
categoryIds: [],
|
||||
productIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建默认阶梯行。 */
|
||||
export function createDefaultTierForm(
|
||||
meetAmount: null | number = null,
|
||||
reduceAmount: null | number = null,
|
||||
): FullReductionTierForm {
|
||||
return {
|
||||
meetAmount,
|
||||
reduceAmount,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建默认满赠规则。 */
|
||||
export function createDefaultGiftRuleForm(): FullReductionGiftRuleForm {
|
||||
return {
|
||||
buyQuantity: 2,
|
||||
giftQuantity: 1,
|
||||
giftScopeType: 'same_lowest',
|
||||
applicableScope: createDefaultScopeForm('all'),
|
||||
giftScope: createDefaultScopeForm('all'),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建默认第二份半价规则。 */
|
||||
export function createDefaultSecondHalfRuleForm(): FullReductionSecondHalfRuleForm {
|
||||
return {
|
||||
discountType: 'half',
|
||||
applicableScope: createDefaultScopeForm('category'),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建默认编辑表单。 */
|
||||
export function createDefaultFullReductionEditorForm(): FullReductionEditorForm {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
activityType: 'reduce',
|
||||
reduceTiers: [createDefaultTierForm(30, 5)],
|
||||
giftRule: createDefaultGiftRuleForm(),
|
||||
secondHalfRule: createDefaultSecondHalfRuleForm(),
|
||||
validDateRange: null,
|
||||
channels: ['delivery', 'pickup', 'dine_in'],
|
||||
storeScopeMode: 'all',
|
||||
storeIds: [],
|
||||
scopeStoreId: '',
|
||||
stackWithCoupon: false,
|
||||
description: '',
|
||||
metrics: createEmptyFullReductionMetrics(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { MarketingFullReductionStatsDto } from '#/api/marketing';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
FullReductionCardViewModel,
|
||||
FullReductionFilterForm,
|
||||
} from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动页面数据读取动作。
|
||||
* 1. 加载门店与活动列表(支持全部门店视角)。
|
||||
* 2. 维护分页、统计与加载状态。
|
||||
*/
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getMarketingFullReductionListApi } from '#/api/marketing';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
interface CreateDataActionsOptions {
|
||||
filterForm: FullReductionFilterForm;
|
||||
isLoading: Ref<boolean>;
|
||||
isStoreLoading: Ref<boolean>;
|
||||
keyword: Ref<string>;
|
||||
page: Ref<number>;
|
||||
pageSize: Ref<number>;
|
||||
rows: Ref<FullReductionCardViewModel[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
stats: Ref<MarketingFullReductionStatsDto>;
|
||||
stores: Ref<StoreListItemDto[]>;
|
||||
total: Ref<number>;
|
||||
}
|
||||
|
||||
export function createDataActions(options: CreateDataActionsOptions) {
|
||||
async function loadStores() {
|
||||
options.isStoreLoading.value = true;
|
||||
try {
|
||||
const result = await getStoreListApi({
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
});
|
||||
|
||||
options.stores.value = result.items ?? [];
|
||||
if (options.stores.value.length === 0) {
|
||||
options.selectedStoreId.value = '';
|
||||
options.rows.value = [];
|
||||
options.total.value = 0;
|
||||
options.stats.value = createEmptyStats();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.selectedStoreId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelectedStore = options.stores.value.some(
|
||||
(item) => item.id === options.selectedStoreId.value,
|
||||
);
|
||||
if (!hasSelectedStore) {
|
||||
options.selectedStoreId.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('加载门店失败');
|
||||
} finally {
|
||||
options.isStoreLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivities() {
|
||||
if (options.stores.value.length === 0) {
|
||||
options.rows.value = [];
|
||||
options.total.value = 0;
|
||||
options.stats.value = createEmptyStats();
|
||||
return;
|
||||
}
|
||||
|
||||
options.isLoading.value = true;
|
||||
try {
|
||||
const result = await getMarketingFullReductionListApi({
|
||||
storeId: options.selectedStoreId.value || undefined,
|
||||
activityType: options.filterForm.activityType,
|
||||
status: options.filterForm.status,
|
||||
keyword: options.keyword.value.trim() || undefined,
|
||||
page: options.page.value,
|
||||
pageSize: options.pageSize.value,
|
||||
});
|
||||
|
||||
options.rows.value = result.items ?? [];
|
||||
options.total.value = result.total;
|
||||
options.page.value = result.page;
|
||||
options.pageSize.value = result.pageSize;
|
||||
options.stats.value = result.stats;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.rows.value = [];
|
||||
options.total.value = 0;
|
||||
options.stats.value = createEmptyStats();
|
||||
message.error('加载满减活动失败');
|
||||
} finally {
|
||||
options.isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadActivities,
|
||||
loadStores,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyStats(): MarketingFullReductionStatsDto {
|
||||
return {
|
||||
totalCount: 0,
|
||||
ongoingCount: 0,
|
||||
monthlyDrivenSalesAmount: 0,
|
||||
averageTicketIncrease: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
MarketingFullReductionActivityType,
|
||||
MarketingFullReductionChannel,
|
||||
MarketingFullReductionGiftScopeType,
|
||||
MarketingFullReductionSecondHalfDiscountType,
|
||||
MarketingFullReductionStoreScopeMode,
|
||||
} from '#/api/marketing';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
FullReductionEditorForm,
|
||||
FullReductionPickerTarget,
|
||||
FullReductionScopeForm,
|
||||
} from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动编辑抽屉动作。
|
||||
* 1. 管理新增/编辑抽屉与字段更新。
|
||||
* 2. 负责详情加载、范围选择回填、表单校验与保存提交。
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getMarketingFullReductionDetailApi,
|
||||
saveMarketingFullReductionApi,
|
||||
} from '#/api/marketing';
|
||||
|
||||
import {
|
||||
createDefaultFullReductionEditorForm,
|
||||
createDefaultScopeForm,
|
||||
createDefaultTierForm,
|
||||
} from './constants';
|
||||
import {
|
||||
buildSaveFullReductionPayload,
|
||||
cloneScopeForm,
|
||||
mapDetailToEditorForm,
|
||||
} from './helpers';
|
||||
|
||||
interface CreateDrawerActionsOptions {
|
||||
form: FullReductionEditorForm;
|
||||
isDrawerLoading: Ref<boolean>;
|
||||
isDrawerOpen: Ref<boolean>;
|
||||
isDrawerSubmitting: Ref<boolean>;
|
||||
loadActivities: () => Promise<void>;
|
||||
openPicker: (
|
||||
params: {
|
||||
allowAll: boolean;
|
||||
initialScope: FullReductionScopeForm;
|
||||
scopeStoreId: string;
|
||||
target: FullReductionPickerTarget;
|
||||
title: string;
|
||||
},
|
||||
onConfirm: (scope: FullReductionScopeForm) => void,
|
||||
) => Promise<void>;
|
||||
resolveOperationStoreId: (preferredStoreIds?: string[]) => string;
|
||||
selectedStoreId: Ref<string>;
|
||||
stores: Ref<StoreListItemDto[]>;
|
||||
}
|
||||
|
||||
export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
const drawerMode = ref<'create' | 'edit'>('create');
|
||||
|
||||
function setDrawerOpen(value: boolean) {
|
||||
options.isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
function applyForm(next: FullReductionEditorForm) {
|
||||
options.form.id = next.id;
|
||||
options.form.name = next.name;
|
||||
options.form.activityType = next.activityType;
|
||||
options.form.reduceTiers = next.reduceTiers.map((item) => ({
|
||||
meetAmount: item.meetAmount,
|
||||
reduceAmount: item.reduceAmount,
|
||||
}));
|
||||
options.form.giftRule = {
|
||||
buyQuantity: next.giftRule.buyQuantity,
|
||||
giftQuantity: next.giftRule.giftQuantity,
|
||||
giftScopeType: next.giftRule.giftScopeType,
|
||||
applicableScope: cloneScopeForm(next.giftRule.applicableScope),
|
||||
giftScope: cloneScopeForm(next.giftRule.giftScope),
|
||||
};
|
||||
options.form.secondHalfRule = {
|
||||
discountType: next.secondHalfRule.discountType,
|
||||
applicableScope: cloneScopeForm(next.secondHalfRule.applicableScope),
|
||||
};
|
||||
options.form.validDateRange = next.validDateRange;
|
||||
options.form.channels = [...next.channels];
|
||||
options.form.storeScopeMode = next.storeScopeMode;
|
||||
options.form.storeIds = [...next.storeIds];
|
||||
options.form.scopeStoreId = next.scopeStoreId;
|
||||
options.form.stackWithCoupon = next.stackWithCoupon;
|
||||
options.form.description = next.description;
|
||||
options.form.metrics = { ...next.metrics };
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
applyForm(createDefaultFullReductionEditorForm());
|
||||
}
|
||||
|
||||
function setFormName(value: string) {
|
||||
options.form.name = value;
|
||||
}
|
||||
|
||||
function setFormActivityType(value: MarketingFullReductionActivityType) {
|
||||
options.form.activityType = value;
|
||||
}
|
||||
|
||||
function addReduceTier() {
|
||||
options.form.reduceTiers = [
|
||||
...options.form.reduceTiers,
|
||||
createDefaultTierForm(),
|
||||
];
|
||||
}
|
||||
|
||||
function removeReduceTier(index: number) {
|
||||
if (options.form.reduceTiers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
options.form.reduceTiers = options.form.reduceTiers.filter(
|
||||
(_, rowIndex) => rowIndex !== index,
|
||||
);
|
||||
}
|
||||
|
||||
function setReduceTierMeetAmount(index: number, value: null | number) {
|
||||
const next = [...options.form.reduceTiers];
|
||||
const row = next[index];
|
||||
if (!row) return;
|
||||
|
||||
row.meetAmount = normalizeNullableNumber(value);
|
||||
options.form.reduceTiers = next;
|
||||
}
|
||||
|
||||
function setReduceTierReduceAmount(index: number, value: null | number) {
|
||||
const next = [...options.form.reduceTiers];
|
||||
const row = next[index];
|
||||
if (!row) return;
|
||||
|
||||
row.reduceAmount = normalizeNullableNumber(value);
|
||||
options.form.reduceTiers = next;
|
||||
}
|
||||
|
||||
function setGiftBuyQuantity(value: null | number) {
|
||||
options.form.giftRule.buyQuantity = normalizeNullableInteger(value);
|
||||
}
|
||||
|
||||
function setGiftGiftQuantity(value: null | number) {
|
||||
options.form.giftRule.giftQuantity = normalizeNullableInteger(value);
|
||||
}
|
||||
|
||||
function setGiftScopeType(value: MarketingFullReductionGiftScopeType) {
|
||||
options.form.giftRule.giftScopeType = value;
|
||||
if (value === 'same_lowest') {
|
||||
options.form.giftRule.giftScope = createDefaultScopeForm('all');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.form.giftRule.giftScope.scopeType === 'all') {
|
||||
options.form.giftRule.giftScope = createDefaultScopeForm('product');
|
||||
}
|
||||
}
|
||||
|
||||
function setSecondHalfDiscountType(
|
||||
value: MarketingFullReductionSecondHalfDiscountType,
|
||||
) {
|
||||
options.form.secondHalfRule.discountType = value;
|
||||
}
|
||||
|
||||
function setFormValidDateRange(
|
||||
value: FullReductionEditorForm['validDateRange'],
|
||||
) {
|
||||
options.form.validDateRange = value;
|
||||
}
|
||||
|
||||
function setFormChannels(value: MarketingFullReductionChannel[]) {
|
||||
options.form.channels = [...value];
|
||||
}
|
||||
|
||||
function setFormStoreScopeMode(value: MarketingFullReductionStoreScopeMode) {
|
||||
options.form.storeScopeMode = value;
|
||||
if (value === 'all') {
|
||||
options.form.storeIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
options.selectedStoreId.value &&
|
||||
!options.form.storeIds.includes(options.selectedStoreId.value)
|
||||
) {
|
||||
options.form.storeIds = [
|
||||
...options.form.storeIds,
|
||||
options.selectedStoreId.value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function setFormStoreIds(value: string[]) {
|
||||
options.form.storeIds = normalizeStoreIds(value);
|
||||
}
|
||||
|
||||
function setFormStackWithCoupon(value: boolean) {
|
||||
options.form.stackWithCoupon = value;
|
||||
}
|
||||
|
||||
function setFormDescription(value: string) {
|
||||
options.form.description = value;
|
||||
}
|
||||
|
||||
async function openGiftApplicablePicker() {
|
||||
await options.openPicker(
|
||||
{
|
||||
title: '选择适用商品范围',
|
||||
target: 'giftApplicable',
|
||||
allowAll: true,
|
||||
initialScope: options.form.giftRule.applicableScope,
|
||||
scopeStoreId: resolveScopeStoreId(),
|
||||
},
|
||||
(scope) => {
|
||||
options.form.giftRule.applicableScope = scope;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function openGiftScopePicker() {
|
||||
await options.openPicker(
|
||||
{
|
||||
title: '选择指定赠品范围',
|
||||
target: 'giftScope',
|
||||
allowAll: false,
|
||||
initialScope: options.form.giftRule.giftScope,
|
||||
scopeStoreId: resolveScopeStoreId(),
|
||||
},
|
||||
(scope) => {
|
||||
options.form.giftRule.giftScope = scope;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function openSecondHalfApplicablePicker() {
|
||||
await options.openPicker(
|
||||
{
|
||||
title: '选择第二份半价适用范围',
|
||||
target: 'secondHalfApplicable',
|
||||
allowAll: false,
|
||||
initialScope: options.form.secondHalfRule.applicableScope,
|
||||
scopeStoreId: resolveScopeStoreId(),
|
||||
},
|
||||
(scope) => {
|
||||
options.form.secondHalfRule.applicableScope = scope;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function openCreateDrawer() {
|
||||
resetForm();
|
||||
options.form.scopeStoreId = resolveScopeStoreId();
|
||||
drawerMode.value = 'create';
|
||||
options.isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function openEditDrawer(
|
||||
activityId: string,
|
||||
preferredStoreIds: string[] = [],
|
||||
) {
|
||||
const operationStoreId = options.resolveOperationStoreId(preferredStoreIds);
|
||||
if (!operationStoreId) return;
|
||||
|
||||
options.isDrawerLoading.value = true;
|
||||
try {
|
||||
const detail = await getMarketingFullReductionDetailApi({
|
||||
storeId: operationStoreId,
|
||||
activityId,
|
||||
});
|
||||
|
||||
const mapped = mapDetailToEditorForm(detail);
|
||||
mapped.scopeStoreId = resolveScopeStoreId();
|
||||
applyForm(mapped);
|
||||
drawerMode.value = 'edit';
|
||||
options.isDrawerOpen.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('加载活动详情失败');
|
||||
} finally {
|
||||
options.isDrawerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDrawer() {
|
||||
const operationStoreId = options.resolveOperationStoreId(
|
||||
options.form.storeScopeMode === 'stores' ? options.form.storeIds : [],
|
||||
);
|
||||
if (!operationStoreId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateBeforeSubmit(operationStoreId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.isDrawerSubmitting.value = true;
|
||||
try {
|
||||
await saveMarketingFullReductionApi(
|
||||
buildSaveFullReductionPayload(
|
||||
options.form,
|
||||
operationStoreId,
|
||||
resolveScopeStoreId(),
|
||||
),
|
||||
);
|
||||
message.success(
|
||||
drawerMode.value === 'create' ? '活动已创建' : '活动已更新',
|
||||
);
|
||||
options.isDrawerOpen.value = false;
|
||||
await options.loadActivities();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBeforeSubmit(operationStoreId: string) {
|
||||
if (!options.form.name.trim()) {
|
||||
message.warning('请输入活动名称');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.name.trim().length > 64) {
|
||||
message.warning('活动名称长度不能超过 64 个字符');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!options.form.validDateRange) {
|
||||
message.warning('请选择活动时间');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.channels.length === 0) {
|
||||
message.warning('请至少选择一个适用渠道');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.storeScopeMode === 'stores' &&
|
||||
options.form.storeIds.length === 0
|
||||
) {
|
||||
message.warning('请选择至少一个适用门店');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.storeScopeMode === 'stores' &&
|
||||
!options.form.storeIds.includes(operationStoreId)
|
||||
) {
|
||||
message.warning('指定门店需包含当前门店(用于选品基准)');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.description.trim().length > 512) {
|
||||
message.warning('活动说明长度不能超过 512 个字符');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.activityType === 'reduce' && !validateReduceRules()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.activityType === 'gift' && !validateGiftRules()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.activityType === 'second_half' &&
|
||||
!validateSecondHalfRules()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateReduceRules() {
|
||||
if (options.form.reduceTiers.length === 0) {
|
||||
message.warning('请至少配置一条满减阶梯');
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = options.form.reduceTiers
|
||||
.map((tier, index) => ({
|
||||
index,
|
||||
meetAmount: Number(tier.meetAmount || 0),
|
||||
reduceAmount: Number(tier.reduceAmount || 0),
|
||||
}))
|
||||
.toSorted((first, second) => first.meetAmount - second.meetAmount);
|
||||
|
||||
for (const tier of normalized) {
|
||||
if (tier.meetAmount <= 0 || tier.reduceAmount <= 0) {
|
||||
message.warning('满减门槛与优惠金额必须大于 0');
|
||||
return false;
|
||||
}
|
||||
if (tier.reduceAmount >= tier.meetAmount) {
|
||||
message.warning('满减优惠金额必须小于门槛金额');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
const current = normalized[index];
|
||||
const previous = normalized[index - 1];
|
||||
if (!current || !previous) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.meetAmount <= previous.meetAmount) {
|
||||
message.warning('满减门槛金额必须严格递增');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateGiftRules() {
|
||||
if (
|
||||
!options.form.giftRule.buyQuantity ||
|
||||
options.form.giftRule.buyQuantity <= 0
|
||||
) {
|
||||
message.warning('购买数量门槛必须大于 0');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!options.form.giftRule.giftQuantity ||
|
||||
options.form.giftRule.giftQuantity <= 0
|
||||
) {
|
||||
message.warning('赠送数量必须大于 0');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateScope(
|
||||
options.form.giftRule.applicableScope,
|
||||
true,
|
||||
'适用商品范围',
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.giftRule.giftScopeType === 'specified' &&
|
||||
!validateScope(options.form.giftRule.giftScope, false, '指定赠品范围')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateSecondHalfRules() {
|
||||
if (
|
||||
!validateScope(
|
||||
options.form.secondHalfRule.applicableScope,
|
||||
false,
|
||||
'第二份半价适用范围',
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateScope(
|
||||
scope: FullReductionScopeForm,
|
||||
allowAll: boolean,
|
||||
scopeName: string,
|
||||
) {
|
||||
if (!allowAll && scope.scopeType === 'all') {
|
||||
message.warning(`${scopeName}不支持全部商品,请选择分类或商品`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scope.scopeType === 'category' && scope.categoryIds.length === 0) {
|
||||
message.warning(`${scopeName}至少选择一个分类`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scope.scopeType === 'product' && scope.productIds.length === 0) {
|
||||
message.warning(`${scopeName}至少选择一个商品`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveScopeStoreId() {
|
||||
const operationStoreId = options.resolveOperationStoreId(
|
||||
options.form.storeScopeMode === 'stores' ? options.form.storeIds : [],
|
||||
);
|
||||
if (operationStoreId) {
|
||||
return operationStoreId;
|
||||
}
|
||||
return options.stores.value[0]?.id ?? '';
|
||||
}
|
||||
|
||||
return {
|
||||
addReduceTier,
|
||||
drawerMode,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openGiftApplicablePicker,
|
||||
openGiftScopePicker,
|
||||
openSecondHalfApplicablePicker,
|
||||
removeReduceTier,
|
||||
setDrawerOpen,
|
||||
setFormActivityType,
|
||||
setFormChannels,
|
||||
setFormDescription,
|
||||
setFormName,
|
||||
setFormStackWithCoupon,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormValidDateRange,
|
||||
setGiftBuyQuantity,
|
||||
setGiftGiftQuantity,
|
||||
setGiftScopeType,
|
||||
setReduceTierMeetAmount,
|
||||
setReduceTierReduceAmount,
|
||||
setSecondHalfDiscountType,
|
||||
submitDrawer,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNullableNumber(value: null | number) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(numeric.toFixed(2));
|
||||
}
|
||||
|
||||
function normalizeNullableInteger(value: null | number) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(numeric));
|
||||
}
|
||||
|
||||
function normalizeStoreIds(value: string[]) {
|
||||
return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type {
|
||||
MarketingFullReductionDetailDto,
|
||||
MarketingFullReductionDisplayStatus,
|
||||
MarketingFullReductionGiftRuleDto,
|
||||
MarketingFullReductionScopeRuleDto,
|
||||
MarketingFullReductionSecondHalfDiscountType,
|
||||
MarketingFullReductionSecondHalfRuleDto,
|
||||
SaveMarketingFullReductionDto,
|
||||
} from '#/api/marketing';
|
||||
import type {
|
||||
FullReductionCardViewModel,
|
||||
FullReductionEditorForm,
|
||||
FullReductionScopeForm,
|
||||
} from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动页面纯函数。
|
||||
* 1. 负责列表文案、样式、规则可视化映射。
|
||||
* 2. 负责详情与编辑表单互转、保存请求构建。
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
createDefaultFullReductionEditorForm,
|
||||
createDefaultGiftRuleForm,
|
||||
createDefaultScopeForm,
|
||||
createDefaultSecondHalfRuleForm,
|
||||
createDefaultTierForm,
|
||||
FULL_REDUCTION_ACTIVITY_TEXT_MAP,
|
||||
FULL_REDUCTION_CHANNEL_TEXT_MAP,
|
||||
FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
/** 千分位整数格式化。 */
|
||||
export function formatInteger(value: number) {
|
||||
return Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** 货币格式化。 */
|
||||
export function formatCurrency(value: number, withSymbol = true) {
|
||||
const result = trimDecimal(Number(value || 0));
|
||||
return withSymbol ? `¥${result}` : result;
|
||||
}
|
||||
|
||||
/** 去除末尾小数 0。 */
|
||||
export function trimDecimal(value: number) {
|
||||
return Number(value || 0)
|
||||
.toFixed(2)
|
||||
.replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
/** 活动类型文案。 */
|
||||
export function resolveActivityTypeLabel(
|
||||
value: FullReductionCardViewModel['activityType'],
|
||||
) {
|
||||
return FULL_REDUCTION_ACTIVITY_TEXT_MAP[value];
|
||||
}
|
||||
|
||||
/** 第二份折扣文案。 */
|
||||
export function resolveSecondHalfDiscountLabel(
|
||||
value: MarketingFullReductionSecondHalfDiscountType,
|
||||
) {
|
||||
return (
|
||||
FULL_REDUCTION_SECOND_HALF_DISCOUNT_OPTIONS.find(
|
||||
(item) => item.value === value,
|
||||
)?.label ?? '5折'
|
||||
);
|
||||
}
|
||||
|
||||
/** 活动规则短标签。 */
|
||||
export function resolveRulePills(item: FullReductionCardViewModel) {
|
||||
if (item.activityType === 'reduce') {
|
||||
return [...item.reduceTiers]
|
||||
.toSorted((first, second) => first.meetAmount - second.meetAmount)
|
||||
.map(
|
||||
(tier) =>
|
||||
`满${trimDecimal(tier.meetAmount)}减${trimDecimal(tier.reduceAmount)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.activityType === 'gift' && item.giftRule) {
|
||||
return [
|
||||
`买${item.giftRule.buyQuantity}件赠${item.giftRule.giftQuantity}件`,
|
||||
];
|
||||
}
|
||||
|
||||
const discountType = item.secondHalfRule?.discountType ?? 'half';
|
||||
return [`第2份${resolveSecondHalfDiscountLabel(discountType)}`];
|
||||
}
|
||||
|
||||
/** 渠道文案。 */
|
||||
export function resolveChannelsText(
|
||||
channels: FullReductionCardViewModel['channels'],
|
||||
) {
|
||||
const ordered = ['delivery', 'pickup', 'dine_in'] as const;
|
||||
const labels = ordered
|
||||
.filter((channel) => channels.includes(channel))
|
||||
.map((channel) => FULL_REDUCTION_CHANNEL_TEXT_MAP[channel]);
|
||||
|
||||
return labels.length > 0 ? labels.join(' / ') : '--';
|
||||
}
|
||||
|
||||
/** 门店范围文案。 */
|
||||
export function resolveStoreScopeText(
|
||||
item: FullReductionCardViewModel,
|
||||
storeNameMap: Record<string, string>,
|
||||
) {
|
||||
if (item.storeScopeMode === 'all') {
|
||||
return '全部门店';
|
||||
}
|
||||
|
||||
const storeNames = item.storeIds
|
||||
.map((storeId) => storeNameMap[storeId])
|
||||
.filter((name) => typeof name === 'string' && name.length > 0);
|
||||
|
||||
if (storeNames.length === 0) {
|
||||
return '指定门店';
|
||||
}
|
||||
|
||||
return storeNames.join(' / ');
|
||||
}
|
||||
|
||||
/** 商品范围摘要文案。 */
|
||||
export function resolveScopeSummary(
|
||||
scope: FullReductionScopeForm | MarketingFullReductionScopeRuleDto,
|
||||
) {
|
||||
if (scope.scopeType === 'all') {
|
||||
return '全部商品';
|
||||
}
|
||||
|
||||
if (scope.scopeType === 'category') {
|
||||
return `已选 ${scope.categoryIds.length} 个分类`;
|
||||
}
|
||||
|
||||
return `已选 ${scope.productIds.length} 个商品`;
|
||||
}
|
||||
|
||||
/** 卡片附加信息文案。 */
|
||||
export function resolveExtraMetaText(item: FullReductionCardViewModel) {
|
||||
if (item.activityType === 'gift' && item.giftRule) {
|
||||
return item.giftRule.giftScopeType === 'same_lowest'
|
||||
? '赠品:同商品(价低者)'
|
||||
: `赠品:${resolveScopeSummary(item.giftRule.giftScope)}`;
|
||||
}
|
||||
|
||||
if (item.activityType === 'second_half' && item.secondHalfRule) {
|
||||
return `适用:${resolveScopeSummary(item.secondHalfRule.applicableScope)}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 卡片数据指标。 */
|
||||
export function resolveCardMetrics(item: FullReductionCardViewModel) {
|
||||
if (item.activityType === 'gift') {
|
||||
return [
|
||||
{
|
||||
label: '参与订单',
|
||||
value: `${formatInteger(item.metrics.participatingOrderCount)}单`,
|
||||
},
|
||||
{
|
||||
label: '赠出商品',
|
||||
value: `${formatInteger(item.metrics.giftedCount)}件`,
|
||||
},
|
||||
{
|
||||
label: '带动销售',
|
||||
value: formatCurrency(item.metrics.drivenSalesAmount),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (item.activityType === 'second_half') {
|
||||
return [
|
||||
{
|
||||
label: '参与订单',
|
||||
value: `${formatInteger(item.metrics.participatingOrderCount)}单`,
|
||||
},
|
||||
{
|
||||
label: '优惠总额',
|
||||
value: formatCurrency(item.metrics.discountTotalAmount),
|
||||
},
|
||||
{
|
||||
label: '连带率提升',
|
||||
value:
|
||||
item.metrics.attachRateIncreasePercent > 0
|
||||
? `${trimDecimal(item.metrics.attachRateIncreasePercent)}%`
|
||||
: '--',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '参与订单',
|
||||
value: `${formatInteger(item.metrics.participatingOrderCount)}单`,
|
||||
},
|
||||
{
|
||||
label: '优惠总额',
|
||||
value: formatCurrency(item.metrics.discountTotalAmount),
|
||||
},
|
||||
{
|
||||
label: '客单价提升',
|
||||
value:
|
||||
item.metrics.ticketIncreaseAmount > 0
|
||||
? formatCurrency(item.metrics.ticketIncreaseAmount)
|
||||
: '--',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 详情映射为编辑表单。 */
|
||||
export function mapDetailToEditorForm(
|
||||
detail: MarketingFullReductionDetailDto,
|
||||
): FullReductionEditorForm {
|
||||
const form = createDefaultFullReductionEditorForm();
|
||||
form.id = detail.id;
|
||||
form.name = detail.name;
|
||||
form.activityType = detail.activityType;
|
||||
form.reduceTiers =
|
||||
detail.reduceTiers.length > 0
|
||||
? detail.reduceTiers.map((tier) =>
|
||||
createDefaultTierForm(tier.meetAmount, tier.reduceAmount),
|
||||
)
|
||||
: [createDefaultTierForm()];
|
||||
form.giftRule = mapGiftRuleToForm(detail.giftRule);
|
||||
form.secondHalfRule = mapSecondHalfRuleToForm(detail.secondHalfRule);
|
||||
form.validDateRange = [dayjs(detail.startDate), dayjs(detail.endDate)];
|
||||
form.channels = [...detail.channels];
|
||||
form.storeScopeMode = detail.storeScopeMode;
|
||||
form.storeIds = [...detail.storeIds];
|
||||
form.scopeStoreId = detail.scopeStoreId;
|
||||
form.stackWithCoupon = detail.stackWithCoupon;
|
||||
form.description = detail.description ?? '';
|
||||
form.metrics = { ...detail.metrics };
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 编辑表单构建保存请求。 */
|
||||
export function buildSaveFullReductionPayload(
|
||||
form: FullReductionEditorForm,
|
||||
storeId: string,
|
||||
scopeStoreId: string,
|
||||
): SaveMarketingFullReductionDto {
|
||||
const [start, end] = (form.validDateRange ?? []) as [Dayjs, Dayjs];
|
||||
|
||||
return {
|
||||
id: form.id || undefined,
|
||||
storeId,
|
||||
scopeStoreId,
|
||||
name: form.name.trim(),
|
||||
activityType: form.activityType,
|
||||
reduceTiers:
|
||||
form.activityType === 'reduce'
|
||||
? form.reduceTiers.map((tier) => ({
|
||||
meetAmount: Number(tier.meetAmount || 0),
|
||||
reduceAmount: Number(tier.reduceAmount || 0),
|
||||
}))
|
||||
: [],
|
||||
giftRule:
|
||||
form.activityType === 'gift'
|
||||
? {
|
||||
buyQuantity: Math.floor(Number(form.giftRule.buyQuantity || 0)),
|
||||
giftQuantity: Math.floor(Number(form.giftRule.giftQuantity || 0)),
|
||||
giftScopeType: form.giftRule.giftScopeType,
|
||||
applicableScope: normalizeScopeForSave(
|
||||
form.giftRule.applicableScope,
|
||||
),
|
||||
giftScope:
|
||||
form.giftRule.giftScopeType === 'specified'
|
||||
? normalizeScopeForSave(form.giftRule.giftScope)
|
||||
: createDefaultScopeForm('all'),
|
||||
}
|
||||
: undefined,
|
||||
secondHalfRule:
|
||||
form.activityType === 'second_half'
|
||||
? {
|
||||
discountType: form.secondHalfRule.discountType,
|
||||
applicableScope: normalizeScopeForSave(
|
||||
form.secondHalfRule.applicableScope,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
startDate: start.format('YYYY-MM-DD'),
|
||||
endDate: end.format('YYYY-MM-DD'),
|
||||
channels: [...form.channels],
|
||||
storeScopeMode: form.storeScopeMode,
|
||||
storeIds: form.storeScopeMode === 'stores' ? [...form.storeIds] : undefined,
|
||||
stackWithCoupon: form.stackWithCoupon,
|
||||
description: form.description.trim() || undefined,
|
||||
metrics: { ...form.metrics },
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝商品范围。 */
|
||||
export function cloneScopeForm(
|
||||
scope: FullReductionScopeForm,
|
||||
): FullReductionScopeForm {
|
||||
return {
|
||||
scopeType: scope.scopeType,
|
||||
categoryIds: [...scope.categoryIds],
|
||||
productIds: [...scope.productIds],
|
||||
};
|
||||
}
|
||||
|
||||
/** 校验状态映射。 */
|
||||
export function isEndedStatus(status: MarketingFullReductionDisplayStatus) {
|
||||
return status === 'ended';
|
||||
}
|
||||
|
||||
function mapGiftRuleToForm(rule?: MarketingFullReductionGiftRuleDto) {
|
||||
const form = createDefaultGiftRuleForm();
|
||||
if (!rule) {
|
||||
return form;
|
||||
}
|
||||
|
||||
form.buyQuantity = rule.buyQuantity;
|
||||
form.giftQuantity = rule.giftQuantity;
|
||||
form.giftScopeType = rule.giftScopeType;
|
||||
form.applicableScope = mapScopeToForm(rule.applicableScope, 'all');
|
||||
form.giftScope =
|
||||
rule.giftScopeType === 'specified'
|
||||
? mapScopeToForm(rule.giftScope, 'product')
|
||||
: createDefaultScopeForm('all');
|
||||
return form;
|
||||
}
|
||||
|
||||
function mapSecondHalfRuleToForm(
|
||||
rule?: MarketingFullReductionSecondHalfRuleDto,
|
||||
) {
|
||||
const form = createDefaultSecondHalfRuleForm();
|
||||
if (!rule) {
|
||||
return form;
|
||||
}
|
||||
form.discountType = rule.discountType;
|
||||
form.applicableScope = mapScopeToForm(rule.applicableScope, 'category');
|
||||
return form;
|
||||
}
|
||||
|
||||
function mapScopeToForm(
|
||||
scope: MarketingFullReductionScopeRuleDto | undefined,
|
||||
fallbackScopeType: FullReductionScopeForm['scopeType'],
|
||||
): FullReductionScopeForm {
|
||||
if (!scope) {
|
||||
return createDefaultScopeForm(fallbackScopeType);
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: scope.scopeType,
|
||||
categoryIds: [...scope.categoryIds],
|
||||
productIds: [...scope.productIds],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScopeForSave(scope: FullReductionScopeForm) {
|
||||
if (scope.scopeType === 'all') {
|
||||
return createDefaultScopeForm('all');
|
||||
}
|
||||
|
||||
if (scope.scopeType === 'category') {
|
||||
return {
|
||||
scopeType: 'category' as const,
|
||||
categoryIds: [...scope.categoryIds],
|
||||
productIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'product' as const,
|
||||
categoryIds: [],
|
||||
productIds: [...scope.productIds],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
FullReductionPickerCategoryItem,
|
||||
FullReductionPickerProductItem,
|
||||
FullReductionPickerTarget,
|
||||
FullReductionScopeForm,
|
||||
} from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动二级抽屉(商品范围选择)动作。
|
||||
* 1. 管理范围选择抽屉开关、检索和列表加载。
|
||||
* 2. 统一分类/商品选择结果回填。
|
||||
*/
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getProductCategoryListApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
|
||||
import { cloneScopeForm } from './helpers';
|
||||
|
||||
interface OpenPickerOptions {
|
||||
allowAll: boolean;
|
||||
initialScope: FullReductionScopeForm;
|
||||
scopeStoreId: string;
|
||||
target: FullReductionPickerTarget;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CreatePickerActionsOptions {
|
||||
isPickerLoading: Ref<boolean>;
|
||||
isPickerOpen: Ref<boolean>;
|
||||
pickerAllowAll: Ref<boolean>;
|
||||
pickerCategories: Ref<FullReductionPickerCategoryItem[]>;
|
||||
pickerCategoryFilterId: Ref<string>;
|
||||
pickerKeyword: Ref<string>;
|
||||
pickerProducts: Ref<FullReductionPickerProductItem[]>;
|
||||
pickerScope: Ref<FullReductionScopeForm>;
|
||||
pickerTarget: Ref<FullReductionPickerTarget>;
|
||||
pickerTitle: Ref<string>;
|
||||
resolveOperationStoreId: () => string;
|
||||
}
|
||||
|
||||
export function createPickerActions(options: CreatePickerActionsOptions) {
|
||||
let activeScopeStoreId = '';
|
||||
let onConfirmScope: ((scope: FullReductionScopeForm) => void) | null = null;
|
||||
|
||||
function setPickerOpen(value: boolean) {
|
||||
options.isPickerOpen.value = value;
|
||||
if (!value) {
|
||||
onConfirmScope = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setPickerKeyword(value: string) {
|
||||
options.pickerKeyword.value = value;
|
||||
}
|
||||
|
||||
function setPickerCategoryFilterId(value: string) {
|
||||
options.pickerCategoryFilterId.value = value;
|
||||
}
|
||||
|
||||
function setPickerScopeType(value: FullReductionScopeForm['scopeType']) {
|
||||
if (!options.pickerAllowAll.value && value === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
options.pickerScope.value = {
|
||||
scopeType: value,
|
||||
categoryIds:
|
||||
value === 'category' ? options.pickerScope.value.categoryIds : [],
|
||||
productIds:
|
||||
value === 'product' ? options.pickerScope.value.productIds : [],
|
||||
};
|
||||
}
|
||||
|
||||
function setPickerCategoryIds(ids: string[]) {
|
||||
options.pickerScope.value = {
|
||||
...options.pickerScope.value,
|
||||
categoryIds: [...ids],
|
||||
};
|
||||
}
|
||||
|
||||
function setPickerProductIds(ids: string[]) {
|
||||
options.pickerScope.value = {
|
||||
...options.pickerScope.value,
|
||||
productIds: [...ids],
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPickerCategories() {
|
||||
const storeId = activeScopeStoreId || options.resolveOperationStoreId();
|
||||
if (!storeId) {
|
||||
options.pickerCategories.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
options.pickerCategories.value = await getProductCategoryListApi(storeId);
|
||||
}
|
||||
|
||||
async function loadPickerProducts() {
|
||||
const storeId = activeScopeStoreId || options.resolveOperationStoreId();
|
||||
if (!storeId) {
|
||||
options.pickerProducts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
options.pickerProducts.value = await searchProductPickerApi({
|
||||
storeId,
|
||||
keyword: options.pickerKeyword.value.trim() || undefined,
|
||||
categoryId: options.pickerCategoryFilterId.value || undefined,
|
||||
limit: 500,
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadPickerList() {
|
||||
if (!options.isPickerOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.isPickerLoading.value = true;
|
||||
try {
|
||||
await loadPickerCategories();
|
||||
if (options.pickerScope.value.scopeType === 'product') {
|
||||
await loadPickerProducts();
|
||||
} else {
|
||||
options.pickerProducts.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.pickerCategories.value = [];
|
||||
options.pickerProducts.value = [];
|
||||
message.error('加载可选范围失败');
|
||||
} finally {
|
||||
options.isPickerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchPickerProducts() {
|
||||
if (options.pickerScope.value.scopeType !== 'product') {
|
||||
return;
|
||||
}
|
||||
|
||||
options.isPickerLoading.value = true;
|
||||
try {
|
||||
await loadPickerProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.pickerProducts.value = [];
|
||||
message.error('加载可选商品失败');
|
||||
} finally {
|
||||
options.isPickerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPicker(
|
||||
params: OpenPickerOptions,
|
||||
onConfirm: (scope: FullReductionScopeForm) => void,
|
||||
) {
|
||||
const resolvedStoreId =
|
||||
params.scopeStoreId || options.resolveOperationStoreId();
|
||||
if (!resolvedStoreId) {
|
||||
message.warning('请先选择门店后再配置商品范围');
|
||||
return;
|
||||
}
|
||||
|
||||
activeScopeStoreId = resolvedStoreId;
|
||||
onConfirmScope = onConfirm;
|
||||
options.pickerTitle.value = params.title;
|
||||
options.pickerTarget.value = params.target;
|
||||
options.pickerAllowAll.value = params.allowAll;
|
||||
options.pickerKeyword.value = '';
|
||||
options.pickerCategoryFilterId.value = '';
|
||||
|
||||
const scope = cloneScopeForm(params.initialScope);
|
||||
if (!params.allowAll && scope.scopeType === 'all') {
|
||||
scope.scopeType = 'category';
|
||||
}
|
||||
options.pickerScope.value = scope;
|
||||
options.isPickerOpen.value = true;
|
||||
|
||||
await reloadPickerList();
|
||||
}
|
||||
|
||||
function submitPicker() {
|
||||
const scope = options.pickerScope.value;
|
||||
if (!options.pickerAllowAll.value && scope.scopeType === 'all') {
|
||||
message.warning('当前范围不支持全部商品,请选择分类或商品');
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope.scopeType === 'category' && scope.categoryIds.length === 0) {
|
||||
message.warning('请至少选择一个分类');
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope.scopeType === 'product' && scope.productIds.length === 0) {
|
||||
message.warning('请至少选择一个商品');
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirmScope?.(cloneScopeForm(scope));
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
openPicker,
|
||||
reloadPickerList,
|
||||
searchPickerProducts,
|
||||
setPickerCategoryFilterId,
|
||||
setPickerCategoryIds,
|
||||
setPickerKeyword,
|
||||
setPickerOpen,
|
||||
setPickerProductIds,
|
||||
setPickerScopeType,
|
||||
submitPicker,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
FullReductionCardViewModel,
|
||||
FullReductionPickerCategoryItem,
|
||||
FullReductionPickerProductItem,
|
||||
FullReductionPickerTarget,
|
||||
FullReductionScopeForm,
|
||||
FullReductionStatsViewModel,
|
||||
} from '#/views/marketing/full-reduction/types';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动页面状态与行为编排。
|
||||
* 1. 管理门店、筛选、分页、统计与加载状态。
|
||||
* 2. 编排主抽屉编辑与二级范围抽屉选择。
|
||||
* 3. 封装卡片停用/删除动作。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { createCardActions } from './full-reduction-page/card-actions';
|
||||
import {
|
||||
createDefaultFullReductionEditorForm,
|
||||
createDefaultFullReductionFilterForm,
|
||||
createDefaultScopeForm,
|
||||
FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS,
|
||||
FULL_REDUCTION_STATUS_FILTER_OPTIONS,
|
||||
} from './full-reduction-page/constants';
|
||||
import {
|
||||
createDataActions,
|
||||
createEmptyStats,
|
||||
} from './full-reduction-page/data-actions';
|
||||
import { createDrawerActions } from './full-reduction-page/drawer-actions';
|
||||
import { createPickerActions } from './full-reduction-page/picker-actions';
|
||||
|
||||
export function useMarketingFullReductionPage() {
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const filterForm = reactive(createDefaultFullReductionFilterForm());
|
||||
const keyword = ref('');
|
||||
|
||||
const rows = ref<FullReductionCardViewModel[]>([]);
|
||||
const stats = ref<FullReductionStatsViewModel>(createEmptyStats());
|
||||
const page = ref(1);
|
||||
const pageSize = ref(4);
|
||||
const total = ref(0);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const isDrawerOpen = ref(false);
|
||||
const isDrawerLoading = ref(false);
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const form = reactive(createDefaultFullReductionEditorForm());
|
||||
|
||||
const isPickerOpen = ref(false);
|
||||
const isPickerLoading = ref(false);
|
||||
const pickerTitle = ref('选择商品范围');
|
||||
const pickerTarget = ref<FullReductionPickerTarget>('giftApplicable');
|
||||
const pickerAllowAll = ref(true);
|
||||
const pickerScope = ref<FullReductionScopeForm>(
|
||||
createDefaultScopeForm('all'),
|
||||
);
|
||||
const pickerKeyword = ref('');
|
||||
const pickerCategoryFilterId = ref('');
|
||||
const pickerCategories = ref<FullReductionPickerCategoryItem[]>([]);
|
||||
const pickerProducts = ref<FullReductionPickerProductItem[]>([]);
|
||||
|
||||
const storeOptions = computed(() => [
|
||||
{ label: '全部门店', value: '' },
|
||||
...stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
]);
|
||||
const scopedStoreOptions = computed(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
const storeNameMap = computed<Record<string, string>>(() =>
|
||||
Object.fromEntries(stores.value.map((item) => [item.id, item.name])),
|
||||
);
|
||||
|
||||
const hasStore = computed(() => stores.value.length > 0);
|
||||
|
||||
function resolveOperationStoreId(preferredStoreIds: string[] = []) {
|
||||
if (selectedStoreId.value) {
|
||||
return selectedStoreId.value;
|
||||
}
|
||||
|
||||
for (const storeId of preferredStoreIds) {
|
||||
if (stores.value.some((item) => item.id === storeId)) {
|
||||
return storeId;
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredStoreIds.length > 0) {
|
||||
return preferredStoreIds[0] ?? '';
|
||||
}
|
||||
|
||||
return stores.value[0]?.id ?? '';
|
||||
}
|
||||
|
||||
const { loadActivities, loadStores } = createDataActions({
|
||||
stores,
|
||||
selectedStoreId,
|
||||
isStoreLoading,
|
||||
filterForm,
|
||||
keyword,
|
||||
rows,
|
||||
stats,
|
||||
isLoading,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
});
|
||||
|
||||
const {
|
||||
openPicker,
|
||||
reloadPickerList,
|
||||
searchPickerProducts,
|
||||
setPickerCategoryFilterId,
|
||||
setPickerCategoryIds,
|
||||
setPickerKeyword,
|
||||
setPickerOpen,
|
||||
setPickerProductIds,
|
||||
setPickerScopeType,
|
||||
submitPicker,
|
||||
} = createPickerActions({
|
||||
isPickerLoading,
|
||||
isPickerOpen,
|
||||
pickerAllowAll,
|
||||
pickerCategories,
|
||||
pickerCategoryFilterId,
|
||||
pickerKeyword,
|
||||
pickerProducts,
|
||||
pickerScope,
|
||||
pickerTarget,
|
||||
pickerTitle,
|
||||
resolveOperationStoreId,
|
||||
});
|
||||
|
||||
const {
|
||||
addReduceTier,
|
||||
drawerMode,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openGiftApplicablePicker,
|
||||
openGiftScopePicker,
|
||||
openSecondHalfApplicablePicker,
|
||||
removeReduceTier,
|
||||
setDrawerOpen,
|
||||
setFormActivityType,
|
||||
setFormChannels,
|
||||
setFormDescription,
|
||||
setFormName,
|
||||
setFormStackWithCoupon,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormValidDateRange,
|
||||
setGiftBuyQuantity,
|
||||
setGiftGiftQuantity,
|
||||
setGiftScopeType,
|
||||
setReduceTierMeetAmount,
|
||||
setReduceTierReduceAmount,
|
||||
setSecondHalfDiscountType,
|
||||
submitDrawer,
|
||||
} = createDrawerActions({
|
||||
form,
|
||||
isDrawerLoading,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
loadActivities,
|
||||
openPicker,
|
||||
resolveOperationStoreId,
|
||||
selectedStoreId,
|
||||
stores,
|
||||
});
|
||||
|
||||
const { disableActivity, removeActivity } = createCardActions({
|
||||
loadActivities,
|
||||
resolveOperationStoreId,
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
drawerMode.value === 'create' ? '创建活动' : '编辑活动',
|
||||
);
|
||||
const drawerSubmitText = computed(() => '保存');
|
||||
|
||||
function setSelectedStoreId(value: string) {
|
||||
selectedStoreId.value = value;
|
||||
}
|
||||
|
||||
function setKeywordValue(value: string) {
|
||||
keyword.value = value;
|
||||
}
|
||||
|
||||
function setStatusFilter(
|
||||
value: '' | FullReductionCardViewModel['displayStatus'],
|
||||
) {
|
||||
filterForm.status = value;
|
||||
}
|
||||
|
||||
function setActivityTypeFilter(
|
||||
value: '' | FullReductionCardViewModel['activityType'],
|
||||
) {
|
||||
filterForm.activityType = value;
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
page.value = 1;
|
||||
await loadActivities();
|
||||
}
|
||||
|
||||
async function resetFilters() {
|
||||
filterForm.activityType = '';
|
||||
filterForm.status = '';
|
||||
keyword.value = '';
|
||||
page.value = 1;
|
||||
await loadActivities();
|
||||
}
|
||||
|
||||
async function handlePageChange(nextPage: number, nextPageSize: number) {
|
||||
page.value = nextPage;
|
||||
pageSize.value = nextPageSize;
|
||||
await loadActivities();
|
||||
}
|
||||
|
||||
async function handlePickerScopeTypeChange(
|
||||
value: FullReductionScopeForm['scopeType'],
|
||||
) {
|
||||
setPickerScopeType(value);
|
||||
if (value === 'product') {
|
||||
await searchPickerProducts();
|
||||
return;
|
||||
}
|
||||
await reloadPickerList();
|
||||
}
|
||||
|
||||
async function handlePickerCategoryFilterChange(value: string) {
|
||||
setPickerCategoryFilterId(value);
|
||||
await searchPickerProducts();
|
||||
}
|
||||
|
||||
async function handlePickerSearch() {
|
||||
await searchPickerProducts();
|
||||
}
|
||||
|
||||
watch(selectedStoreId, () => {
|
||||
page.value = 1;
|
||||
filterForm.activityType = '';
|
||||
filterForm.status = '';
|
||||
keyword.value = '';
|
||||
void loadActivities();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStores();
|
||||
await loadActivities();
|
||||
});
|
||||
|
||||
return {
|
||||
addReduceTier,
|
||||
applyFilters,
|
||||
disableActivity,
|
||||
drawerSubmitText,
|
||||
drawerTitle,
|
||||
filterForm,
|
||||
form,
|
||||
FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS,
|
||||
FULL_REDUCTION_STATUS_FILTER_OPTIONS,
|
||||
handlePageChange,
|
||||
handlePickerCategoryFilterChange,
|
||||
handlePickerScopeTypeChange,
|
||||
handlePickerSearch,
|
||||
hasStore,
|
||||
isDrawerLoading,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
isLoading,
|
||||
isPickerLoading,
|
||||
isPickerOpen,
|
||||
isStoreLoading,
|
||||
keyword,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openGiftApplicablePicker,
|
||||
openGiftScopePicker,
|
||||
openSecondHalfApplicablePicker,
|
||||
page,
|
||||
pageSize,
|
||||
pickerAllowAll,
|
||||
pickerCategories,
|
||||
pickerCategoryFilterId,
|
||||
pickerKeyword,
|
||||
pickerProducts,
|
||||
pickerScope,
|
||||
pickerTarget,
|
||||
pickerTitle,
|
||||
removeActivity,
|
||||
removeReduceTier,
|
||||
resetFilters,
|
||||
rows,
|
||||
scopedStoreOptions,
|
||||
selectedStoreId,
|
||||
setDrawerOpen,
|
||||
setFormActivityType,
|
||||
setFormChannels,
|
||||
setFormDescription,
|
||||
setFormName,
|
||||
setFormStackWithCoupon,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormValidDateRange,
|
||||
setGiftBuyQuantity,
|
||||
setGiftGiftQuantity,
|
||||
setGiftScopeType,
|
||||
setKeyword: setKeywordValue,
|
||||
setPickerCategoryIds,
|
||||
setPickerKeyword,
|
||||
setPickerOpen,
|
||||
setPickerProductIds,
|
||||
setReduceTierMeetAmount,
|
||||
setReduceTierReduceAmount,
|
||||
setSecondHalfDiscountType,
|
||||
setSelectedStoreId,
|
||||
setStatusFilter,
|
||||
setTypeFilter: setActivityTypeFilter,
|
||||
stats,
|
||||
storeNameMap,
|
||||
storeOptions,
|
||||
stores,
|
||||
submitDrawer,
|
||||
submitPicker,
|
||||
total,
|
||||
};
|
||||
}
|
||||
250
apps/web-antd/src/views/marketing/full-reduction/index.vue
Normal file
250
apps/web-antd/src/views/marketing/full-reduction/index.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:营销中心-满减活动页面主视图。
|
||||
* 1. 还原原型工具栏、统计、活动卡片列表和分页。
|
||||
* 2. 编排主编辑抽屉与二级商品范围抽屉。
|
||||
*/
|
||||
import type {
|
||||
MarketingFullReductionActivityType,
|
||||
MarketingFullReductionDisplayStatus,
|
||||
} from '#/api/marketing';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Empty, Input, Pagination, Select, Spin } from 'ant-design-vue';
|
||||
|
||||
import FullReductionActivityCard from './components/FullReductionActivityCard.vue';
|
||||
import FullReductionEditorDrawer from './components/FullReductionEditorDrawer.vue';
|
||||
import FullReductionScopePickerDrawer from './components/FullReductionScopePickerDrawer.vue';
|
||||
import FullReductionStatsCards from './components/FullReductionStatsCards.vue';
|
||||
import { useMarketingFullReductionPage } from './composables/useMarketingFullReductionPage';
|
||||
|
||||
const {
|
||||
addReduceTier,
|
||||
applyFilters,
|
||||
disableActivity,
|
||||
drawerSubmitText,
|
||||
drawerTitle,
|
||||
filterForm,
|
||||
form,
|
||||
FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS,
|
||||
FULL_REDUCTION_STATUS_FILTER_OPTIONS,
|
||||
handlePageChange,
|
||||
handlePickerCategoryFilterChange,
|
||||
handlePickerScopeTypeChange,
|
||||
handlePickerSearch,
|
||||
hasStore,
|
||||
isDrawerLoading,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
isLoading,
|
||||
isPickerLoading,
|
||||
isPickerOpen,
|
||||
isStoreLoading,
|
||||
keyword,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openGiftApplicablePicker,
|
||||
openGiftScopePicker,
|
||||
openSecondHalfApplicablePicker,
|
||||
page,
|
||||
pageSize,
|
||||
pickerAllowAll,
|
||||
pickerCategories,
|
||||
pickerCategoryFilterId,
|
||||
pickerKeyword,
|
||||
pickerProducts,
|
||||
pickerScope,
|
||||
pickerTitle,
|
||||
removeActivity,
|
||||
removeReduceTier,
|
||||
resetFilters,
|
||||
rows,
|
||||
scopedStoreOptions,
|
||||
selectedStoreId,
|
||||
setDrawerOpen,
|
||||
setFormActivityType,
|
||||
setFormChannels,
|
||||
setFormDescription,
|
||||
setFormName,
|
||||
setFormStackWithCoupon,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormValidDateRange,
|
||||
setGiftBuyQuantity,
|
||||
setGiftGiftQuantity,
|
||||
setGiftScopeType,
|
||||
setKeyword,
|
||||
setPickerCategoryIds,
|
||||
setPickerKeyword,
|
||||
setPickerOpen,
|
||||
setPickerProductIds,
|
||||
setReduceTierMeetAmount,
|
||||
setReduceTierReduceAmount,
|
||||
setSecondHalfDiscountType,
|
||||
setSelectedStoreId,
|
||||
setStatusFilter,
|
||||
setTypeFilter,
|
||||
stats,
|
||||
storeNameMap,
|
||||
storeOptions,
|
||||
submitDrawer,
|
||||
submitPicker,
|
||||
total,
|
||||
} = useMarketingFullReductionPage();
|
||||
|
||||
function onStatusFilterChange(value: unknown) {
|
||||
const next =
|
||||
typeof value === 'string' && value
|
||||
? (value as MarketingFullReductionDisplayStatus)
|
||||
: '';
|
||||
setStatusFilter(next);
|
||||
void applyFilters();
|
||||
}
|
||||
|
||||
function onTypeFilterChange(value: unknown) {
|
||||
const next =
|
||||
typeof value === 'string' && value
|
||||
? (value as MarketingFullReductionActivityType)
|
||||
: '';
|
||||
setTypeFilter(next);
|
||||
void applyFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="满减活动" content-class="page-marketing-full-reduction">
|
||||
<div class="mfr-page">
|
||||
<div class="mfr-toolbar">
|
||||
<Select
|
||||
class="mfr-store-select"
|
||||
:value="selectedStoreId"
|
||||
:options="storeOptions"
|
||||
:loading="isStoreLoading"
|
||||
placeholder="全部门店"
|
||||
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
|
||||
/>
|
||||
|
||||
<Select
|
||||
class="mfr-filter-select"
|
||||
:value="filterForm.activityType"
|
||||
:options="FULL_REDUCTION_ACTIVITY_FILTER_OPTIONS"
|
||||
placeholder="全部类型"
|
||||
@update:value="onTypeFilterChange"
|
||||
/>
|
||||
|
||||
<Select
|
||||
class="mfr-filter-select"
|
||||
:value="filterForm.status"
|
||||
:options="FULL_REDUCTION_STATUS_FILTER_OPTIONS"
|
||||
placeholder="全部状态"
|
||||
@update:value="onStatusFilterChange"
|
||||
/>
|
||||
|
||||
<Input
|
||||
class="mfr-search"
|
||||
:value="keyword"
|
||||
placeholder="搜索活动名称"
|
||||
allow-clear
|
||||
@update:value="(value) => setKeyword(String(value ?? ''))"
|
||||
@press-enter="applyFilters"
|
||||
/>
|
||||
|
||||
<Button @click="applyFilters">搜索</Button>
|
||||
<Button @click="resetFilters">重置</Button>
|
||||
|
||||
<span class="mfr-spacer"></span>
|
||||
<Button type="primary" @click="openCreateDrawer">创建活动</Button>
|
||||
</div>
|
||||
|
||||
<FullReductionStatsCards v-if="hasStore" :stats="stats" />
|
||||
|
||||
<div v-if="!hasStore" class="mfr-empty">暂无门店,请先创建门店</div>
|
||||
|
||||
<Spin v-else :spinning="isLoading">
|
||||
<div v-if="rows.length > 0" class="mfr-list">
|
||||
<FullReductionActivityCard
|
||||
v-for="item in rows"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:store-name-map="storeNameMap"
|
||||
@edit="(row) => openEditDrawer(row.id, row.storeIds)"
|
||||
@disable="disableActivity"
|
||||
@remove="removeActivity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mfr-empty">
|
||||
<Empty description="暂无满减活动" />
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length > 0" class="mfr-pagination">
|
||||
<Pagination
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['4', '10', '20', '50']"
|
||||
:show-total="(value) => `共 ${value} 条`"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<FullReductionEditorDrawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
:submit-text="drawerSubmitText"
|
||||
:submitting="isDrawerSubmitting"
|
||||
:loading="isDrawerLoading"
|
||||
:form="form"
|
||||
:store-options="scopedStoreOptions"
|
||||
@close="setDrawerOpen(false)"
|
||||
@set-name="setFormName"
|
||||
@set-activity-type="setFormActivityType"
|
||||
@add-reduce-tier="addReduceTier"
|
||||
@remove-reduce-tier="removeReduceTier"
|
||||
@set-reduce-tier-meet-amount="setReduceTierMeetAmount"
|
||||
@set-reduce-tier-reduce-amount="setReduceTierReduceAmount"
|
||||
@set-gift-buy-quantity="setGiftBuyQuantity"
|
||||
@set-gift-gift-quantity="setGiftGiftQuantity"
|
||||
@set-gift-scope-type="setGiftScopeType"
|
||||
@set-second-half-discount-type="setSecondHalfDiscountType"
|
||||
@set-valid-date-range="setFormValidDateRange"
|
||||
@set-channels="setFormChannels"
|
||||
@set-store-scope-mode="setFormStoreScopeMode"
|
||||
@set-form-store-ids="setFormStoreIds"
|
||||
@set-stack-with-coupon="setFormStackWithCoupon"
|
||||
@set-description="setFormDescription"
|
||||
@open-gift-applicable-picker="openGiftApplicablePicker"
|
||||
@open-gift-scope-picker="openGiftScopePicker"
|
||||
@open-second-half-applicable-picker="openSecondHalfApplicablePicker"
|
||||
@submit="submitDrawer"
|
||||
/>
|
||||
|
||||
<FullReductionScopePickerDrawer
|
||||
:open="isPickerOpen"
|
||||
:loading="isPickerLoading"
|
||||
:title="pickerTitle"
|
||||
:allow-all="pickerAllowAll"
|
||||
:scope="pickerScope"
|
||||
:keyword="pickerKeyword"
|
||||
:category-filter-id="pickerCategoryFilterId"
|
||||
:categories="pickerCategories"
|
||||
:products="pickerProducts"
|
||||
@close="setPickerOpen(false)"
|
||||
@set-scope-type="handlePickerScopeTypeChange"
|
||||
@set-keyword="setPickerKeyword"
|
||||
@set-category-filter-id="handlePickerCategoryFilterChange"
|
||||
@set-category-ids="setPickerCategoryIds"
|
||||
@set-product-ids="setPickerProductIds"
|
||||
@search="handlePickerSearch"
|
||||
@submit="submitPicker"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 文件职责:满减活动页面基础样式变量。
|
||||
*/
|
||||
.page-marketing-full-reduction {
|
||||
--mfr-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--mfr-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
--mfr-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%);
|
||||
--mfr-border: #e7eaf0;
|
||||
--mfr-text: #1f2937;
|
||||
--mfr-subtext: #6b7280;
|
||||
--mfr-muted: #9ca3af;
|
||||
|
||||
.g-action {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.g-action + .g-action {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.g-action-danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 文件职责:满减活动卡片样式。
|
||||
* 1. 对齐原型卡片结构、规则胶囊与状态标签。
|
||||
*/
|
||||
.page-marketing-full-reduction {
|
||||
.mfr-card {
|
||||
padding: 18px 22px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mfr-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--mfr-shadow-sm);
|
||||
transition: box-shadow var(--mfr-transition);
|
||||
}
|
||||
|
||||
.mfr-card:hover {
|
||||
box-shadow: var(--mfr-shadow-md);
|
||||
}
|
||||
|
||||
.mfr-card.mfr-dimmed {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.mfr-card-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mfr-card-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mfr-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mfr-type-tag,
|
||||
.mfr-status-tag {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mfr-type-reduce {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.mfr-type-gift {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.mfr-type-second-half {
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.mfr-status-ongoing {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.mfr-status-upcoming {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.mfr-status-ended {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.mfr-tiers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mfr-tier-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
background: #f2f8ff;
|
||||
border: 1px solid #cfe4ff;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mfr-tier-arrow {
|
||||
display: inline-flex;
|
||||
padding: 0 6px;
|
||||
color: #c0c6cf;
|
||||
}
|
||||
|
||||
.mfr-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 24px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--mfr-subtext);
|
||||
}
|
||||
|
||||
.mfr-meta-item {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mfr-meta-item .iconify {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--mfr-muted);
|
||||
}
|
||||
|
||||
.mfr-data {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mfr-subtext);
|
||||
background: #f8f9fb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mfr-data-item strong {
|
||||
font-weight: 600;
|
||||
color: var(--mfr-text);
|
||||
}
|
||||
|
||||
.mfr-card-actions {
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 文件职责:满减活动主编辑抽屉样式。
|
||||
*/
|
||||
.mfr-editor-drawer {
|
||||
.ant-drawer-header {
|
||||
min-height: 54px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.mfr-editor-form {
|
||||
.ant-form-item {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-number,
|
||||
.ant-picker,
|
||||
.ant-select-selector {
|
||||
border-color: #e5e7eb !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-number-input,
|
||||
.ant-picker-input > input {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.ant-input-number-input-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-input-number-input {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ant-input-number:focus-within,
|
||||
.ant-picker-focused,
|
||||
.ant-select-focused .ant-select-selector,
|
||||
.ant-input:focus {
|
||||
border-color: #1677ff !important;
|
||||
box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important;
|
||||
}
|
||||
|
||||
.mfr-section-title {
|
||||
padding-left: 10px;
|
||||
margin: 2px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.mfr-tier-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mfr-inline-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.mfr-inline-fields .ant-input-number {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.mfr-tier-delete {
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mfr-tier-delete:hover {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.mfr-tier-delete:disabled {
|
||||
color: #c4c7cf;
|
||||
cursor: not-allowed;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mfr-add-tier {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px dashed #d0d5dd;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mfr-add-tier:hover {
|
||||
color: #1677ff;
|
||||
background: #f2f8ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.mfr-scope-line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mfr-scope-text {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mfr-section-divider {
|
||||
height: 1px;
|
||||
margin: 18px 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(229 231 235 / 0%) 0%,
|
||||
rgb(229 231 235 / 100%) 18%,
|
||||
rgb(229 231 235 / 100%) 82%,
|
||||
rgb(229 231 235 / 0%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.mfr-channel-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mfr-channel-pill {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mfr-channel-pill:hover {
|
||||
color: #1677ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.mfr-channel-pill.checked {
|
||||
color: #1677ff;
|
||||
background: #e8f3ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.mfr-range-picker {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.mfr-store-multi {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mfr-store-multi .ant-select-selector {
|
||||
min-height: 34px !important;
|
||||
padding: 1px 8px !important;
|
||||
}
|
||||
|
||||
.mfr-drawer-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mfr-drawer-footer .ant-btn {
|
||||
min-width: 64px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import './base.less';
|
||||
@import './layout.less';
|
||||
@import './card.less';
|
||||
@import './drawer.less';
|
||||
@import './picker.less';
|
||||
@import './responsive.less';
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 文件职责:满减活动页面布局样式。
|
||||
* 1. 工具栏、统计、列表与分页布局。
|
||||
*/
|
||||
.page-marketing-full-reduction {
|
||||
.mfr-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mfr-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mfr-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--mfr-shadow-sm);
|
||||
}
|
||||
|
||||
.mfr-store-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.mfr-filter-select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.mfr-search {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mfr-store-select .ant-select-selector,
|
||||
.mfr-filter-select .ant-select-selector {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.mfr-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mfr-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mfr-stat-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mfr-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--mfr-shadow-sm);
|
||||
transition: box-shadow var(--mfr-transition);
|
||||
}
|
||||
|
||||
.mfr-stat-card:hover {
|
||||
box-shadow: var(--mfr-shadow-md);
|
||||
}
|
||||
|
||||
.mfr-stat-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mfr-stat-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mfr-stat-blue {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.mfr-stat-green {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.mfr-stat-orange {
|
||||
color: #fa8c16;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.mfr-stat-cyan {
|
||||
color: #0891b2;
|
||||
background: #ecfeff;
|
||||
}
|
||||
|
||||
.mfr-stat-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--mfr-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mfr-stat-value-green {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.mfr-stat-value-orange {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.mfr-stat-label {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: var(--mfr-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mfr-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mfr-empty {
|
||||
padding: 28px 14px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mfr-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--mfr-shadow-sm);
|
||||
}
|
||||
|
||||
.mfr-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 4px 2px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 文件职责:满减活动二级范围抽屉样式。
|
||||
*/
|
||||
.mfr-picker-drawer {
|
||||
.ant-drawer-header {
|
||||
min-height: 54px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.mfr-picker-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.mfr-picker-toolbar .ant-radio-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mfr-picker-toolbar .ant-radio-button-wrapper {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
color: #4b5563;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.mfr-picker-toolbar .ant-radio-button-wrapper::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mfr-picker-toolbar
|
||||
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
|
||||
color: #1677ff;
|
||||
background: #e8f3ff;
|
||||
border-color: #91caff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mfr-picker-search {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.mfr-picker-category {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.mfr-picker-count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.mfr-picker-all {
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.mfr-picker-body {
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mfr-picker-empty {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.mfr-picker-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.mfr-picker-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.mfr-picker-table td {
|
||||
padding: 10px 14px;
|
||||
color: #1f2937;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.mfr-picker-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mfr-picker-table tbody tr:hover td {
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
.mfr-picker-table tbody tr.checked td {
|
||||
background: #e8f3ff;
|
||||
}
|
||||
|
||||
.mfr-picker-col-check {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.mfr-picker-col-count,
|
||||
.mfr-picker-col-price,
|
||||
.mfr-picker-col-status {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mfr-picker-product-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mfr-picker-product-spu {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.mfr-prod-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mfr-prod-status-on {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.mfr-prod-status-soldout {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.mfr-prod-status-off {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.mfr-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mfr-picker-footer-info {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mfr-picker-footer-actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 文件职责:满减活动页面响应式样式。
|
||||
*/
|
||||
.page-marketing-full-reduction {
|
||||
@media (width <= 1200px) {
|
||||
.mfr-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mfr-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mfr-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.mfr-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mfr-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.mfr-card-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mfr-data {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mfr-meta {
|
||||
gap: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
92
apps/web-antd/src/views/marketing/full-reduction/types.ts
Normal file
92
apps/web-antd/src/views/marketing/full-reduction/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type {
|
||||
MarketingFullReductionActivityType,
|
||||
MarketingFullReductionChannel,
|
||||
MarketingFullReductionDisplayStatus,
|
||||
MarketingFullReductionGiftScopeType,
|
||||
MarketingFullReductionListItemDto,
|
||||
MarketingFullReductionMetricsDto,
|
||||
MarketingFullReductionScopeType,
|
||||
MarketingFullReductionSecondHalfDiscountType,
|
||||
MarketingFullReductionStatsDto,
|
||||
MarketingFullReductionStoreScopeMode,
|
||||
} from '#/api/marketing';
|
||||
import type { ProductCategoryDto, ProductPickerItemDto } from '#/api/product';
|
||||
|
||||
/**
|
||||
* 文件职责:满减活动页面类型定义。
|
||||
*/
|
||||
|
||||
/** 列表筛选表单。 */
|
||||
export interface FullReductionFilterForm {
|
||||
activityType: '' | MarketingFullReductionActivityType;
|
||||
status: '' | MarketingFullReductionDisplayStatus;
|
||||
}
|
||||
|
||||
/** 商品范围表单。 */
|
||||
export interface FullReductionScopeForm {
|
||||
categoryIds: string[];
|
||||
productIds: string[];
|
||||
scopeType: MarketingFullReductionScopeType;
|
||||
}
|
||||
|
||||
/** 满减阶梯表单。 */
|
||||
export interface FullReductionTierForm {
|
||||
meetAmount: null | number;
|
||||
reduceAmount: null | number;
|
||||
}
|
||||
|
||||
/** 满赠规则表单。 */
|
||||
export interface FullReductionGiftRuleForm {
|
||||
applicableScope: FullReductionScopeForm;
|
||||
buyQuantity: null | number;
|
||||
giftQuantity: null | number;
|
||||
giftScope: FullReductionScopeForm;
|
||||
giftScopeType: MarketingFullReductionGiftScopeType;
|
||||
}
|
||||
|
||||
/** 第二份半价规则表单。 */
|
||||
export interface FullReductionSecondHalfRuleForm {
|
||||
applicableScope: FullReductionScopeForm;
|
||||
discountType: MarketingFullReductionSecondHalfDiscountType;
|
||||
}
|
||||
|
||||
/** 满减编辑抽屉表单。 */
|
||||
export interface FullReductionEditorForm {
|
||||
activityType: MarketingFullReductionActivityType;
|
||||
channels: MarketingFullReductionChannel[];
|
||||
description: string;
|
||||
id: string;
|
||||
giftRule: FullReductionGiftRuleForm;
|
||||
metrics: MarketingFullReductionMetricsDto;
|
||||
name: string;
|
||||
reduceTiers: FullReductionTierForm[];
|
||||
scopeStoreId: string;
|
||||
secondHalfRule: FullReductionSecondHalfRuleForm;
|
||||
stackWithCoupon: boolean;
|
||||
storeIds: string[];
|
||||
storeScopeMode: MarketingFullReductionStoreScopeMode;
|
||||
validDateRange: [Dayjs, Dayjs] | null;
|
||||
}
|
||||
|
||||
/** 列表卡片视图模型。 */
|
||||
export type FullReductionCardViewModel = MarketingFullReductionListItemDto;
|
||||
|
||||
/** 统计视图模型。 */
|
||||
export type FullReductionStatsViewModel = MarketingFullReductionStatsDto;
|
||||
|
||||
/** 二级抽屉选择目标。 */
|
||||
export type FullReductionPickerTarget =
|
||||
| 'giftApplicable'
|
||||
| 'giftScope'
|
||||
| 'secondHalfApplicable';
|
||||
|
||||
/** 二级抽屉选择模式。 */
|
||||
export type FullReductionPickerMode = 'category' | 'product';
|
||||
|
||||
/** 二级抽屉分类项。 */
|
||||
export type FullReductionPickerCategoryItem = ProductCategoryDto;
|
||||
|
||||
/** 二级抽屉商品项。 */
|
||||
export type FullReductionPickerProductItem = ProductPickerItemDto;
|
||||
Reference in New Issue
Block a user