feat(@vben/web-antd): implement full reduction activity module
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s

This commit is contained in:
2026-02-28 15:44:41 +08:00
parent 50274f2361
commit d5e03c0949
23 changed files with 4310 additions and 1 deletions

View File

@@ -26,7 +26,7 @@
"#/*": "./src/*"
},
"dependencies": {
"@microsoft/signalr": "^8.0.7",
"@microsoft/signalr": "catalog:",
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/constants": "workspace:*",

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
@import './base.less';
@import './layout.less';
@import './card.less';
@import './drawer.less';
@import './picker.less';
@import './responsive.less';

View File

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

View File

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

View File

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

View 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;