feat(@vben/web-antd): implement marketing flash sale page
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 2s

This commit is contained in:
2026-03-02 11:10:17 +08:00
parent 18c9175845
commit d81b670148
22 changed files with 3503 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
/**
* 文件职责:营销中心限时折扣 API 与 DTO 定义。
* 1. 维护限时折扣列表、详情、保存、状态切换、删除和选品契约。
*/
import { requestClient } from '#/api/request';
/** 活动展示状态。 */
export type MarketingFlashSaleDisplayStatus = 'ended' | 'ongoing' | 'upcoming';
/** 活动编辑状态。 */
export type MarketingFlashSaleEditorStatus = 'active' | 'completed';
/** 活动周期。 */
export type MarketingFlashSaleCycleType = 'once' | 'recurring';
/** 周期日期模式。 */
export type MarketingFlashSaleRecurringDateMode = 'fixed' | 'long_term';
/** 适用渠道。 */
export type MarketingFlashSaleChannel = 'delivery' | 'dine_in' | 'pickup';
/** 商品状态。 */
export type MarketingFlashSaleProductStatus =
| 'off_shelf'
| 'on_sale'
| 'sold_out';
/** 限时折扣商品。 */
export interface MarketingFlashSaleProductDto {
categoryId: string;
categoryName: string;
discountPrice: number;
name: string;
originalPrice: number;
perUserLimit: null | number;
productId: string;
soldCount: number;
spuCode: string;
status: MarketingFlashSaleProductStatus;
}
/** 活动指标。 */
export interface MarketingFlashSaleMetricsDto {
activitySalesCount: number;
discountTotalAmount: number;
loopedWeeks: number;
monthlyDiscountSalesCount: number;
}
/** 列表查询参数。 */
export interface MarketingFlashSaleListQuery {
keyword?: string;
page: number;
pageSize: number;
status?: '' | MarketingFlashSaleDisplayStatus;
storeId?: string;
}
/** 详情查询参数。 */
export interface MarketingFlashSaleDetailQuery {
activityId: string;
storeId: string;
}
/** 保存请求。 */
export interface SaveMarketingFlashSaleDto {
channels: MarketingFlashSaleChannel[];
cycleType: MarketingFlashSaleCycleType;
endDate?: string;
id?: string;
metrics?: MarketingFlashSaleMetricsDto;
name: string;
perUserLimit: null | number;
products: Array<{
discountPrice: number;
perUserLimit: null | number;
productId: string;
}>;
recurringDateMode: MarketingFlashSaleRecurringDateMode;
startDate?: string;
storeId: string;
storeIds: string[];
timeEnd?: string;
timeStart?: string;
weekDays: number[];
}
/** 状态修改请求。 */
export interface ChangeMarketingFlashSaleStatusDto {
activityId: string;
status: MarketingFlashSaleEditorStatus;
storeId: string;
}
/** 删除请求。 */
export interface DeleteMarketingFlashSaleDto {
activityId: string;
storeId: string;
}
/** 列表统计。 */
export interface MarketingFlashSaleStatsDto {
monthlyDiscountSalesCount: number;
ongoingCount: number;
participatingProductCount: number;
totalCount: number;
}
/** 列表项。 */
export interface MarketingFlashSaleListItemDto {
channels: MarketingFlashSaleChannel[];
cycleType: MarketingFlashSaleCycleType;
displayStatus: MarketingFlashSaleDisplayStatus;
endDate?: string;
id: string;
isDimmed: boolean;
metrics: MarketingFlashSaleMetricsDto;
name: string;
perUserLimit: null | number;
products: MarketingFlashSaleProductDto[];
recurringDateMode: MarketingFlashSaleRecurringDateMode;
startDate?: string;
status: MarketingFlashSaleEditorStatus;
storeIds: string[];
timeEnd?: string;
timeStart?: string;
updatedAt: string;
weekDays: number[];
}
/** 列表结果。 */
export interface MarketingFlashSaleListResultDto {
items: MarketingFlashSaleListItemDto[];
page: number;
pageSize: number;
stats: MarketingFlashSaleStatsDto;
total: number;
}
/** 详情数据。 */
export interface MarketingFlashSaleDetailDto {
channels: MarketingFlashSaleChannel[];
cycleType: MarketingFlashSaleCycleType;
displayStatus: MarketingFlashSaleDisplayStatus;
endDate?: string;
id: string;
metrics: MarketingFlashSaleMetricsDto;
name: string;
perUserLimit: null | number;
products: MarketingFlashSaleProductDto[];
recurringDateMode: MarketingFlashSaleRecurringDateMode;
startDate?: string;
status: MarketingFlashSaleEditorStatus;
storeIds: string[];
timeEnd?: string;
timeStart?: string;
updatedAt: string;
weekDays: number[];
}
/** 选品分类查询参数。 */
export interface MarketingFlashSalePickerCategoryQuery {
storeId: string;
}
/** 选品分类项。 */
export interface MarketingFlashSalePickerCategoryItemDto {
id: string;
name: string;
productCount: number;
}
/** 选品商品查询参数。 */
export interface MarketingFlashSalePickerProductQuery {
categoryId?: string;
keyword?: string;
limit?: number;
storeId: string;
}
/** 选品商品项。 */
export interface MarketingFlashSalePickerProductItemDto {
categoryId: string;
categoryName: string;
id: string;
name: string;
price: number;
spuCode: string;
status: MarketingFlashSaleProductStatus;
stock: number;
}
/** 获取列表。 */
export async function getMarketingFlashSaleListApi(
params: MarketingFlashSaleListQuery,
) {
return requestClient.get<MarketingFlashSaleListResultDto>(
'/marketing/flash-sale/list',
{
params,
},
);
}
/** 获取详情。 */
export async function getMarketingFlashSaleDetailApi(
params: MarketingFlashSaleDetailQuery,
) {
return requestClient.get<MarketingFlashSaleDetailDto>(
'/marketing/flash-sale/detail',
{
params,
},
);
}
/** 保存活动。 */
export async function saveMarketingFlashSaleApi(
data: SaveMarketingFlashSaleDto,
) {
return requestClient.post<MarketingFlashSaleDetailDto>(
'/marketing/flash-sale/save',
data,
);
}
/** 修改状态。 */
export async function changeMarketingFlashSaleStatusApi(
data: ChangeMarketingFlashSaleStatusDto,
) {
return requestClient.post<MarketingFlashSaleDetailDto>(
'/marketing/flash-sale/status',
data,
);
}
/** 删除活动。 */
export async function deleteMarketingFlashSaleApi(
data: DeleteMarketingFlashSaleDto,
) {
return requestClient.post('/marketing/flash-sale/delete', data);
}
/** 获取选品分类。 */
export async function getMarketingFlashSalePickerCategoriesApi(
params: MarketingFlashSalePickerCategoryQuery,
) {
return requestClient.get<MarketingFlashSalePickerCategoryItemDto[]>(
'/marketing/flash-sale/picker/categories',
{
params,
},
);
}
/** 获取选品商品。 */
export async function getMarketingFlashSalePickerProductsApi(
params: MarketingFlashSalePickerProductQuery,
) {
return requestClient.get<MarketingFlashSalePickerProductItemDto[]>(
'/marketing/flash-sale/picker/products',
{
params,
},
);
}

View File

@@ -183,4 +183,5 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) {
return requestClient.post('/marketing/coupon/delete', data); return requestClient.post('/marketing/coupon/delete', data);
} }
export * from './flash-sale';
export * from './full-reduction'; export * from './full-reduction';

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
/**
* 文件职责:限时折扣活动卡片。
*/
import type { FlashSaleCardViewModel } from '../types';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
FLASH_SALE_PRODUCT_STATUS_TEXT_MAP,
FLASH_SALE_STATUS_CLASS_MAP,
FLASH_SALE_STATUS_TEXT_MAP,
} from '../composables/flash-sale-page/constants';
import {
formatCurrency,
formatInteger,
resolveCardSummary,
resolveDateRangeText,
resolveDiscountRateLabel,
resolveProductLimitText,
resolveRecurringBadgeText,
resolveTimeRangeText,
} from '../composables/flash-sale-page/helpers';
const props = defineProps<{
item: FlashSaleCardViewModel;
}>();
const emit = defineEmits<{
edit: [item: FlashSaleCardViewModel];
remove: [item: FlashSaleCardViewModel];
toggleStatus: [item: FlashSaleCardViewModel];
}>();
const recurringBadgeText = computed(() =>
resolveRecurringBadgeText(props.item),
);
const summaryItems = computed(() => resolveCardSummary(props.item));
const statusActionText = computed(() =>
props.item.status === 'completed' ? '启用' : '停用',
);
</script>
<template>
<div class="fs-card" :class="{ ended: item.isDimmed }">
<div class="fs-card-hd">
<span class="fs-card-name">{{ item.name }}</span>
<span
class="g-tag"
:class="FLASH_SALE_STATUS_CLASS_MAP[item.displayStatus]"
>
{{ FLASH_SALE_STATUS_TEXT_MAP[item.displayStatus] }}
</span>
<span v-if="recurringBadgeText" class="fs-recur-badge">
<IconifyIcon icon="lucide:repeat" />
{{ recurringBadgeText }}
</span>
<span class="fs-card-time">
<IconifyIcon icon="lucide:clock-3" />
{{ resolveTimeRangeText(item) }}
</span>
<span class="fs-card-time">
<IconifyIcon icon="lucide:calendar-days" />
{{ resolveDateRangeText(item) }}
</span>
</div>
<table class="fs-prod-table">
<thead>
<tr>
<th>商品名</th>
<th>原价</th>
<th>折扣价</th>
<th>折扣力度</th>
<th>限购</th>
<th>已售</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="product in item.products" :key="product.productId">
<td>{{ product.name }}</td>
<td class="fs-orig-price">
{{ formatCurrency(product.originalPrice) }}
</td>
<td class="fs-disc-price">
{{ formatCurrency(product.discountPrice) }}
</td>
<td>
<span class="fs-disc-rate">
{{
resolveDiscountRateLabel(
product.originalPrice,
product.discountPrice,
)
}}
</span>
</td>
<td>{{ resolveProductLimitText(product.perUserLimit) }}</td>
<td>{{ formatInteger(product.soldCount) }}</td>
<td>{{ FLASH_SALE_PRODUCT_STATUS_TEXT_MAP[product.status] }}</td>
</tr>
</tbody>
</table>
<div class="fs-card-summary">
<span v-for="summary in summaryItems" :key="summary.label">
{{ summary.label }}
<strong>{{ summary.value }}</strong>
</span>
</div>
<div class="fs-card-ft">
<button type="button" class="g-action" @click="emit('edit', props.item)">
编辑
</button>
<button
type="button"
class="g-action"
@click="emit('toggleStatus', props.item)"
>
{{ statusActionText }}
</button>
<button
type="button"
class="g-action g-action-danger"
@click="emit('remove', props.item)"
>
删除
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,346 @@
<script setup lang="ts">
import type { Dayjs } from 'dayjs';
import type { FlashSaleEditorForm } from '../types';
import type {
MarketingFlashSaleChannel,
MarketingFlashSaleCycleType,
MarketingFlashSaleRecurringDateMode,
} from '#/api/marketing';
import {
Button,
DatePicker,
Drawer,
Form,
Input,
InputNumber,
Spin,
TimePicker,
} from 'ant-design-vue';
/**
* 文件职责:限时折扣主编辑抽屉。
*/
import {
FLASH_SALE_CHANNEL_OPTIONS,
FLASH_SALE_CYCLE_OPTIONS,
FLASH_SALE_RECURRING_DATE_MODE_OPTIONS,
FLASH_SALE_WEEKDAY_OPTIONS,
} from '../composables/flash-sale-page/constants';
import {
formatCurrency,
resolveDiscountRateLabel,
} from '../composables/flash-sale-page/helpers';
defineProps<{
form: FlashSaleEditorForm;
loading: boolean;
open: boolean;
submitText: string;
submitting: boolean;
title: string;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'openProductPicker'): void;
(event: 'quickSelectWeekDays', mode: 'all' | 'weekday' | 'weekend'): void;
(event: 'removeProduct', index: number): void;
(event: 'setCycleType', value: MarketingFlashSaleCycleType): void;
(event: 'setName', value: string): void;
(event: 'setPerUserLimit', value: null | number): void;
(event: 'setProductDiscountPrice', index: number, value: null | number): void;
(event: 'setProductPerUserLimit', index: number, value: null | number): void;
(
event: 'setRecurringDateMode',
value: MarketingFlashSaleRecurringDateMode,
): void;
(event: 'setTimeRange', value: [Dayjs, Dayjs] | null): void;
(event: 'setValidDateRange', value: [Dayjs, Dayjs] | null): void;
(event: 'submit'): void;
(event: 'toggleChannel', channel: MarketingFlashSaleChannel): void;
(event: 'toggleWeekDay', day: number): void;
}>();
function onDateRangeChange(value: [Dayjs, Dayjs] | null) {
emit('setValidDateRange', value);
}
function onTimeRangeChange(value: [Dayjs, Dayjs] | null) {
emit('setTimeRange', value);
}
function parseNullableNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = Number(value);
return Number.isNaN(numeric) ? null : numeric;
}
function parseNullableInteger(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return null;
}
return Math.floor(numeric);
}
</script>
<template>
<Drawer
:open="open"
:title="title"
width="620"
:destroy-on-close="false"
class="fs-editor-drawer"
@close="emit('close')"
>
<Spin :spinning="loading">
<Form layout="vertical" class="fs-editor-form">
<Form.Item label="活动名称" required>
<Input
:value="form.name"
:maxlength="64"
placeholder="请输入活动名称,如:午市特惠、新品尝鲜"
@update:value="(value) => emit('setName', String(value ?? ''))"
/>
</Form.Item>
<Form.Item label="活动周期" required>
<div class="fs-pill-group">
<button
v-for="item in FLASH_SALE_CYCLE_OPTIONS"
:key="item.value"
type="button"
class="fs-pill"
:class="{ checked: form.cycleType === item.value }"
@click="emit('setCycleType', item.value)"
>
{{ item.label }}
</button>
</div>
<div class="g-hint">
周期循环适合固定日期的促销疯狂星期四周末特惠
</div>
</Form.Item>
<Form.Item
v-if="form.cycleType === 'recurring'"
label="日期规则"
required
>
<div class="fs-pill-group">
<button
v-for="item in FLASH_SALE_RECURRING_DATE_MODE_OPTIONS"
:key="item.value"
type="button"
class="fs-pill"
:class="{ checked: form.recurringDateMode === item.value }"
@click="emit('setRecurringDateMode', item.value)"
>
{{ item.label }}
</button>
</div>
</Form.Item>
<Form.Item
v-if="form.cycleType === 'once' || form.recurringDateMode === 'fixed'"
label="活动时间"
required
>
<DatePicker.RangePicker
:value="form.validDateRange ?? undefined"
format="YYYY-MM-DD"
class="fs-range-picker"
@update:value="onDateRangeChange"
/>
</Form.Item>
<Form.Item v-else label="活动时间(可选)">
<DatePicker.RangePicker
:value="form.validDateRange ?? undefined"
format="YYYY-MM-DD"
class="fs-range-picker"
@update:value="onDateRangeChange"
/>
<div class="g-hint">留空表示长期有效</div>
</Form.Item>
<Form.Item label="每日时段">
<TimePicker.RangePicker
:value="form.timeRange ?? undefined"
format="HH:mm"
class="fs-range-picker"
@update:value="onTimeRangeChange"
/>
<div class="g-hint">留空则全天有效</div>
</Form.Item>
<Form.Item
v-if="form.cycleType === 'recurring'"
label="循环日期"
required
>
<div class="fs-day-sel">
<button
v-for="item in FLASH_SALE_WEEKDAY_OPTIONS"
:key="item.value"
type="button"
class="fs-day"
:class="{ active: form.weekDays.includes(item.value) }"
@click="emit('toggleWeekDay', item.value)"
>
{{ item.label }}
</button>
</div>
<div class="fs-day-quick">
<Button
size="small"
@click="emit('quickSelectWeekDays', 'weekday')"
>
工作日
</Button>
<Button
size="small"
@click="emit('quickSelectWeekDays', 'weekend')"
>
周末
</Button>
<Button size="small" @click="emit('quickSelectWeekDays', 'all')">
全选
</Button>
</div>
<div class="g-hint">
活动将在选中的每个星期自动生效无需手动开启
</div>
</Form.Item>
<Form.Item label="折扣商品" required>
<div v-if="form.products.length === 0" class="fs-product-empty">
暂未添加折扣商品
</div>
<div
v-for="(item, index) in form.products"
:key="item.productId"
class="fs-drawer-prod"
>
<div class="fs-drawer-prod-hd">
<span>{{ item.name }}</span>
<button
type="button"
class="fs-drawer-prod-remove"
@click="emit('removeProduct', index)"
>
×
</button>
</div>
<div class="fs-drawer-prod-bd">
<div class="fs-field">
<label>原价</label>
<Input :value="formatCurrency(item.originalPrice)" readonly />
</div>
<div class="fs-field">
<label>折扣价</label>
<InputNumber
:value="item.discountPrice ?? undefined"
:min="0.01"
:precision="2"
placeholder="请输入折扣价"
@update:value="
(value) =>
emit(
'setProductDiscountPrice',
index,
parseNullableNumber(value),
)
"
/>
</div>
<div class="fs-field">
<label>折扣</label>
<span class="fs-auto-rate">
{{
resolveDiscountRateLabel(
item.originalPrice,
item.discountPrice,
)
}}
</span>
</div>
<div class="fs-field">
<label>限购/</label>
<InputNumber
:value="item.perUserLimit ?? undefined"
:min="1"
:precision="0"
placeholder="不限则留空"
@update:value="
(value) =>
emit(
'setProductPerUserLimit',
index,
parseNullableInteger(value),
)
"
/>
</div>
</div>
</div>
<Button size="small" @click="emit('openProductPicker')">
+ 添加商品
</Button>
</Form.Item>
<Form.Item label="适用渠道">
<div class="fs-pill-group">
<button
v-for="item in FLASH_SALE_CHANNEL_OPTIONS"
:key="item.value"
type="button"
class="fs-pill"
:class="{ checked: form.channels.includes(item.value) }"
@click="emit('toggleChannel', item.value)"
>
{{ item.label }}
</button>
</div>
</Form.Item>
<Form.Item label="每人限购">
<div class="fs-limit-row">
<InputNumber
:value="form.perUserLimit ?? undefined"
:min="1"
:precision="0"
placeholder="不限则留空"
@update:value="
(value) => emit('setPerUserLimit', parseNullableInteger(value))
"
/>
<span class="fs-unit"></span>
</div>
<div class="g-hint">
活动期间每人累计可购买的折扣商品总数留空不限
</div>
</Form.Item>
</Form>
</Spin>
<template #footer>
<div class="fs-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,173 @@
<script setup lang="ts">
import type {
FlashSalePickerCategoryItem,
FlashSalePickerProductItem,
} from '../types';
/**
* 文件职责:限时折扣商品选择弹窗。
*/
import { computed } from 'vue';
import { Button, Empty, Input, Modal, Select, Spin } from 'ant-design-vue';
import { FLASH_SALE_PRODUCT_STATUS_TEXT_MAP } from '../composables/flash-sale-page/constants';
import { formatCurrency } from '../composables/flash-sale-page/helpers';
const props = defineProps<{
categories: FlashSalePickerCategoryItem[];
categoryFilterId: string;
keyword: string;
loading: boolean;
open: boolean;
products: FlashSalePickerProductItem[];
selectedProductIds: string[];
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'search'): void;
(event: 'setCategoryFilterId', value: string): void;
(event: 'setKeyword', value: string): void;
(event: 'submit'): void;
(event: 'toggleAll', checked: boolean): void;
(event: 'toggleProduct', productId: string): void;
}>();
const categoryOptions = computed(() => [
{ label: '全部分类', value: '' },
...props.categories.map((item) => ({
label: item.name,
value: item.id,
})),
]);
const allChecked = computed(
() =>
props.products.length > 0 &&
props.products.every((item) => props.selectedProductIds.includes(item.id)),
);
const selectedCount = computed(() => props.selectedProductIds.length);
function setKeyword(value: string) {
emit('setKeyword', value);
}
function setCategoryFilterId(value: unknown) {
emit('setCategoryFilterId', String(value ?? ''));
}
function toggleAll() {
emit('toggleAll', !allChecked.value);
}
function toggleProduct(productId: string) {
emit('toggleProduct', productId);
}
function resolveStatusClass(status: FlashSalePickerProductItem['status']) {
if (status === 'on_sale') {
return 'pp-prod-status on';
}
if (status === 'sold_out') {
return 'pp-prod-status sold';
}
return 'pp-prod-status off';
}
</script>
<template>
<Modal
:open="open"
title="添加折扣商品"
width="900px"
:footer="null"
class="fs-product-picker-modal"
@cancel="emit('close')"
>
<div class="pp-toolbar">
<div class="pp-search">
<Input
:value="keyword"
placeholder="搜索商品名称或编码..."
allow-clear
@update:value="setKeyword"
@press-enter="emit('search')"
/>
</div>
<Select
:value="categoryFilterId"
:options="categoryOptions"
class="pp-cat-select"
@update:value="setCategoryFilterId"
/>
<Button @click="emit('search')">搜索</Button>
<span class="pp-selected-count">已选 {{ selectedCount }} </span>
</div>
<div class="pp-body">
<Spin :spinning="loading">
<div v-if="products.length === 0" class="pp-empty">
<Empty description="暂无可选商品" />
</div>
<table v-else class="pp-table">
<thead>
<tr>
<th class="pp-col-check">
<input
type="checkbox"
:checked="allChecked"
@change="toggleAll"
/>
</th>
<th>商品</th>
<th>分类</th>
<th>售价</th>
<th>库存</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in products"
:key="item.id"
:class="{ 'pp-checked': selectedProductIds.includes(item.id) }"
@click="toggleProduct(item.id)"
>
<td class="pp-col-check">
<input
type="checkbox"
:checked="selectedProductIds.includes(item.id)"
@change.stop="toggleProduct(item.id)"
/>
</td>
<td>
<div class="pp-prod-name">{{ item.name }}</div>
<div class="pp-prod-spu">{{ item.spuCode }}</div>
</td>
<td>{{ item.categoryName }}</td>
<td>{{ formatCurrency(item.price) }}</td>
<td>{{ item.stock }}</td>
<td>
<span :class="resolveStatusClass(item.status)">
{{ FLASH_SALE_PRODUCT_STATUS_TEXT_MAP[item.status] }}
</span>
</td>
</tr>
</tbody>
</table>
</Spin>
</div>
<div class="pp-footer">
<div class="pp-footer-info">
已选择 <strong>{{ selectedCount }}</strong> 个商品
</div>
<div class="pp-footer-btns">
<Button @click="emit('close')">取消</Button>
<Button type="primary" @click="emit('submit')">确认选择</Button>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* 文件职责:限时折扣统计条。
*/
import type { FlashSaleStatsViewModel } from '../types';
import { formatInteger } from '../composables/flash-sale-page/helpers';
defineProps<{
stats: FlashSaleStatsViewModel;
}>();
</script>
<template>
<div class="fs-stats">
<span>
活动总数
<strong>{{ formatInteger(stats.totalCount) }}</strong>
</span>
<span>
进行中
<strong>{{ formatInteger(stats.ongoingCount) }}</strong>
</span>
<span>
参与商品
<strong>{{ formatInteger(stats.participatingProductCount) }}</strong>
</span>
<span>
本月折扣销量
<strong>{{ formatInteger(stats.monthlyDiscountSalesCount) }}</strong>
</span>
</div>
</template>

View File

@@ -0,0 +1,98 @@
import type { FlashSaleCardViewModel } from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣卡片行操作。
*/
import { message, Modal } from 'ant-design-vue';
import {
changeMarketingFlashSaleStatusApi,
deleteMarketingFlashSaleApi,
} from '#/api/marketing';
interface CreateCardActionsOptions {
loadActivities: () => Promise<void>;
resolveOperationStoreId: (preferredStoreIds?: string[]) => string;
}
export function createCardActions(options: CreateCardActionsOptions) {
function toggleActivityStatus(item: FlashSaleCardViewModel) {
const operationStoreId = options.resolveOperationStoreId(item.storeIds);
if (!operationStoreId) {
return;
}
const nextStatus = item.status === 'completed' ? 'active' : 'completed';
const isEnabling = nextStatus === 'active';
const feedbackKey = `flash-sale-status-${item.id}`;
Modal.confirm({
title: isEnabling
? `确认启用活动「${item.name}」吗?`
: `确认停用活动「${item.name}」吗?`,
content: isEnabling
? '启用后活动将恢复为进行中状态。'
: '停用后活动将结束,可后续再次启用。',
okText: isEnabling ? '确认启用' : '确认停用',
cancelText: '取消',
async onOk() {
try {
message.loading({
key: feedbackKey,
duration: 0,
content: isEnabling ? '正在启用活动...' : '正在停用活动...',
});
await changeMarketingFlashSaleStatusApi({
storeId: operationStoreId,
activityId: item.id,
status: nextStatus,
});
message.success({
key: feedbackKey,
content: isEnabling ? '活动已启用' : '活动已停用',
});
await options.loadActivities();
} catch (error) {
console.error(error);
message.error({
key: feedbackKey,
content: isEnabling
? '启用失败,请稍后重试'
: '停用失败,请稍后重试',
});
}
},
});
}
function removeActivity(item: FlashSaleCardViewModel) {
const operationStoreId = options.resolveOperationStoreId(item.storeIds);
if (!operationStoreId) {
return;
}
Modal.confirm({
title: `确认删除活动「${item.name}」吗?`,
okText: '确认删除',
cancelText: '取消',
async onOk() {
try {
await deleteMarketingFlashSaleApi({
storeId: operationStoreId,
activityId: item.id,
});
message.success('活动已删除');
await options.loadActivities();
} catch (error) {
console.error(error);
message.error('删除失败,请稍后重试');
}
},
});
}
return {
removeActivity,
toggleActivityStatus,
};
}

View File

@@ -0,0 +1,160 @@
import type {
MarketingFlashSaleChannel,
MarketingFlashSaleCycleType,
MarketingFlashSaleDisplayStatus,
MarketingFlashSaleProductStatus,
MarketingFlashSaleRecurringDateMode,
} from '#/api/marketing';
import type {
FlashSaleEditorForm,
FlashSaleEditorProductForm,
FlashSaleFilterForm,
} from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣页面常量与默认表单。
*/
/** 状态筛选项。 */
export const FLASH_SALE_STATUS_FILTER_OPTIONS: Array<{
label: string;
value: '' | MarketingFlashSaleDisplayStatus;
}> = [
{ label: '全部状态', value: '' },
{ label: '进行中', value: 'ongoing' },
{ label: '已结束', value: 'ended' },
{ label: '未开始', value: 'upcoming' },
];
/** 活动周期选项。 */
export const FLASH_SALE_CYCLE_OPTIONS: Array<{
label: string;
value: MarketingFlashSaleCycleType;
}> = [
{ label: '一次性活动', value: 'once' },
{ label: '周期循环', value: 'recurring' },
];
/** 周期日期模式选项。 */
export const FLASH_SALE_RECURRING_DATE_MODE_OPTIONS: Array<{
label: string;
value: MarketingFlashSaleRecurringDateMode;
}> = [
{ label: '长期有效', value: 'long_term' },
{ label: '指定日期区间', value: 'fixed' },
];
/** 适用渠道选项。 */
export const FLASH_SALE_CHANNEL_OPTIONS: Array<{
label: string;
value: MarketingFlashSaleChannel;
}> = [
{ label: '外卖配送', value: 'delivery' },
{ label: '到店自取', value: 'pickup' },
{ label: '堂食点餐', value: 'dine_in' },
];
/** 周循环星期选项。 */
export const FLASH_SALE_WEEKDAY_OPTIONS: Array<{
label: string;
shortLabel: string;
value: number;
}> = [
{ label: '周一', shortLabel: '一', value: 1 },
{ label: '周二', shortLabel: '二', value: 2 },
{ label: '周三', shortLabel: '三', value: 3 },
{ label: '周四', shortLabel: '四', value: 4 },
{ label: '周五', shortLabel: '五', value: 5 },
{ label: '周六', shortLabel: '六', value: 6 },
{ label: '周日', shortLabel: '日', value: 7 },
];
/** 展示状态文案。 */
export const FLASH_SALE_STATUS_TEXT_MAP: Record<
MarketingFlashSaleDisplayStatus,
string
> = {
ongoing: '进行中',
upcoming: '未开始',
ended: '已结束',
};
/** 展示状态类。 */
export const FLASH_SALE_STATUS_CLASS_MAP: Record<
MarketingFlashSaleDisplayStatus,
string
> = {
ongoing: 'fs-tag-running',
upcoming: 'fs-tag-notstarted',
ended: 'fs-tag-ended',
};
/** 商品状态文案。 */
export const FLASH_SALE_PRODUCT_STATUS_TEXT_MAP: Record<
MarketingFlashSaleProductStatus,
string
> = {
on_sale: '在售',
off_shelf: '下架',
sold_out: '沽清',
};
/** 创建默认筛选表单。 */
export function createDefaultFlashSaleFilterForm(): FlashSaleFilterForm {
return {
status: '',
};
}
/** 创建空指标对象。 */
export function createEmptyFlashSaleMetrics() {
return {
activitySalesCount: 0,
discountTotalAmount: 0,
loopedWeeks: 0,
monthlyDiscountSalesCount: 0,
};
}
/** 创建默认商品表单。 */
export function createDefaultFlashSaleProductForm(
product?: Partial<FlashSaleEditorProductForm>,
): FlashSaleEditorProductForm {
return {
productId: product?.productId ?? '',
categoryId: product?.categoryId ?? '',
categoryName: product?.categoryName ?? '',
name: product?.name ?? '',
spuCode: product?.spuCode ?? '',
status: product?.status ?? 'off_shelf',
originalPrice: Number(product?.originalPrice ?? 0),
discountPrice:
product?.discountPrice === null || product?.discountPrice === undefined
? null
: Number(product.discountPrice),
perUserLimit:
product?.perUserLimit === null || product?.perUserLimit === undefined
? null
: Number(product.perUserLimit),
soldCount: Number(product?.soldCount ?? 0),
};
}
/** 创建默认编辑表单。 */
export function createDefaultFlashSaleEditorForm(): FlashSaleEditorForm {
return {
id: '',
name: '',
cycleType: 'once',
recurringDateMode: 'fixed',
validDateRange: null,
timeRange: null,
weekDays: [4],
channels: ['delivery', 'pickup'],
perUserLimit: null,
products: [],
metrics: createEmptyFlashSaleMetrics(),
status: 'active',
storeIds: [],
};
}

View File

@@ -0,0 +1,115 @@
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import type {
FlashSaleCardViewModel,
FlashSaleFilterForm,
FlashSaleStatsViewModel,
} from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣页面数据读取动作。
*/
import { message } from 'ant-design-vue';
import { getMarketingFlashSaleListApi } from '#/api/marketing';
import { getStoreListApi } from '#/api/store';
interface CreateDataActionsOptions {
filterForm: FlashSaleFilterForm;
isLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
keyword: Ref<string>;
page: Ref<number>;
pageSize: Ref<number>;
rows: Ref<FlashSaleCardViewModel[]>;
selectedStoreId: Ref<string>;
stats: Ref<FlashSaleStatsViewModel>;
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 getMarketingFlashSaleListApi({
storeId: options.selectedStoreId.value || undefined,
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(): FlashSaleStatsViewModel {
return {
totalCount: 0,
ongoingCount: 0,
participatingProductCount: 0,
monthlyDiscountSalesCount: 0,
};
}

View File

@@ -0,0 +1,421 @@
import type { Ref } from 'vue';
import type {
MarketingFlashSaleChannel,
MarketingFlashSaleCycleType,
MarketingFlashSaleRecurringDateMode,
} from '#/api/marketing';
import type { StoreListItemDto } from '#/api/store';
import type {
FlashSaleEditorForm,
FlashSaleEditorProductForm,
} from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣主编辑抽屉动作。
*/
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
getMarketingFlashSaleDetailApi,
saveMarketingFlashSaleApi,
} from '#/api/marketing';
import { createDefaultFlashSaleEditorForm } from './constants';
import {
buildSaveFlashSalePayload,
cloneProductForm,
mapDetailToEditorForm,
resolveDiscountRateLabel,
} from './helpers';
interface CreateDrawerActionsOptions {
form: FlashSaleEditorForm;
isDrawerLoading: Ref<boolean>;
isDrawerOpen: Ref<boolean>;
isDrawerSubmitting: Ref<boolean>;
loadActivities: () => Promise<void>;
openPicker: (
pickerOptions: {
selectedProductIds: string[];
selectedProducts: FlashSaleEditorProductForm[];
storeId: string;
},
onConfirm: (products: FlashSaleEditorProductForm[]) => 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: FlashSaleEditorForm) {
options.form.id = next.id;
options.form.name = next.name;
options.form.cycleType = next.cycleType;
options.form.recurringDateMode = next.recurringDateMode;
options.form.validDateRange = next.validDateRange;
options.form.timeRange = next.timeRange;
options.form.weekDays = [...next.weekDays];
options.form.channels = [...next.channels];
options.form.perUserLimit = next.perUserLimit;
options.form.products = next.products.map((item) => cloneProductForm(item));
options.form.metrics = { ...next.metrics };
options.form.status = next.status;
options.form.storeIds = [...next.storeIds];
}
function resetForm() {
applyForm(createDefaultFlashSaleEditorForm());
}
function setFormName(value: string) {
options.form.name = value;
}
function setFormCycleType(value: MarketingFlashSaleCycleType) {
options.form.cycleType = value;
if (value === 'once') {
options.form.recurringDateMode = 'fixed';
options.form.weekDays = [];
} else if (options.form.weekDays.length === 0) {
options.form.weekDays = [4];
}
}
function setFormRecurringDateMode(
value: MarketingFlashSaleRecurringDateMode,
) {
options.form.recurringDateMode = value;
if (value === 'long_term' && options.form.validDateRange) {
// 长期模式允许保留日期范围;仅在用户清空时不强制校验。
}
}
function setFormValidDateRange(value: FlashSaleEditorForm['validDateRange']) {
options.form.validDateRange = value;
}
function setFormTimeRange(value: FlashSaleEditorForm['timeRange']) {
options.form.timeRange = value;
}
function toggleWeekDay(day: number) {
if (options.form.weekDays.includes(day)) {
options.form.weekDays = options.form.weekDays.filter(
(item) => item !== day,
);
return;
}
options.form.weekDays = [...options.form.weekDays, day].toSorted(
(first, second) => first - second,
);
}
function quickSelectWeekDays(mode: 'all' | 'weekday' | 'weekend') {
if (mode === 'all') {
options.form.weekDays = [1, 2, 3, 4, 5, 6, 7];
return;
}
if (mode === 'weekday') {
options.form.weekDays = [1, 2, 3, 4, 5];
return;
}
options.form.weekDays = [6, 7];
}
function setFormChannels(value: MarketingFlashSaleChannel[]) {
options.form.channels = [...new Set(value)];
}
function toggleChannel(channel: MarketingFlashSaleChannel) {
if (options.form.channels.includes(channel)) {
setFormChannels(options.form.channels.filter((item) => item !== channel));
return;
}
setFormChannels([...options.form.channels, channel]);
}
function setFormPerUserLimit(value: null | number) {
options.form.perUserLimit = normalizeNullableInteger(value);
}
function setProductDiscountPrice(index: number, value: null | number) {
const row = options.form.products[index];
if (!row) {
return;
}
row.discountPrice = normalizeNullableNumber(value);
options.form.products = [...options.form.products];
}
function setProductPerUserLimit(index: number, value: null | number) {
const row = options.form.products[index];
if (!row) {
return;
}
row.perUserLimit = normalizeNullableInteger(value);
options.form.products = [...options.form.products];
}
function removeProduct(index: number) {
options.form.products = options.form.products.filter(
(_, rowIndex) => rowIndex !== index,
);
}
async function openProductPicker() {
const operationStoreId = resolveScopeStoreId();
if (!operationStoreId) {
message.warning('请选择具体门店后再添加商品');
return;
}
await options.openPicker(
{
storeId: operationStoreId,
selectedProducts: options.form.products.map((item) =>
cloneProductForm(item),
),
selectedProductIds: options.form.products.map((item) => item.productId),
},
(products) => {
const currentById = new Map(
options.form.products.map((item) => [item.productId, item]),
);
const merged = products.map((item) => {
const existing = currentById.get(item.productId);
return existing ? cloneProductForm(existing) : cloneProductForm(item);
});
options.form.products = merged;
},
);
}
async function openCreateDrawer() {
if (!options.selectedStoreId.value) {
message.warning('请选择具体门店后再创建活动');
return;
}
resetForm();
options.form.storeIds = [options.selectedStoreId.value];
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 getMarketingFlashSaleDetailApi({
storeId: operationStoreId,
activityId,
});
const mapped = mapDetailToEditorForm(detail);
if (mapped.storeIds.length === 0) {
mapped.storeIds = [operationStoreId];
}
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.storeIds,
);
if (!operationStoreId) {
message.warning('请选择可操作门店');
return;
}
if (!validateBeforeSubmit(operationStoreId)) {
return;
}
options.isDrawerSubmitting.value = true;
try {
await saveMarketingFlashSaleApi(
buildSaveFlashSalePayload(options.form, operationStoreId),
);
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) {
const normalizedName = options.form.name.trim();
if (!normalizedName) {
message.warning('请输入活动名称');
return false;
}
if (normalizedName.length > 64) {
message.warning('活动名称长度不能超过 64 个字符');
return false;
}
if (options.form.storeIds.length === 0) {
message.warning('活动门店不能为空');
return false;
}
if (!options.form.storeIds.includes(operationStoreId)) {
message.warning('活动门店必须包含当前操作门店');
return false;
}
if (options.form.cycleType === 'once' && !options.form.validDateRange) {
message.warning('请选择活动时间');
return false;
}
if (options.form.cycleType === 'recurring') {
if (options.form.weekDays.length === 0) {
message.warning('请选择循环日期');
return false;
}
if (
options.form.recurringDateMode === 'fixed' &&
!options.form.validDateRange
) {
message.warning('请选择周期活动日期区间');
return false;
}
}
if (options.form.channels.length === 0) {
message.warning('请至少选择一个适用渠道');
return false;
}
if (options.form.perUserLimit !== null && options.form.perUserLimit <= 0) {
message.warning('每人限购必须大于 0');
return false;
}
if (options.form.products.length === 0) {
message.warning('请至少添加一个折扣商品');
return false;
}
for (const row of options.form.products) {
if (!row.productId) {
message.warning('商品数据异常,请重新选择商品');
return false;
}
if (!row.discountPrice || row.discountPrice <= 0) {
message.warning(`商品「${row.name}」折扣价必须大于 0`);
return false;
}
if (row.discountPrice > row.originalPrice) {
message.warning(`商品「${row.name}」折扣价不能高于原价`);
return false;
}
if (row.perUserLimit !== null && row.perUserLimit <= 0) {
message.warning(`商品「${row.name}」限购必须大于 0`);
return false;
}
if (
options.form.perUserLimit &&
row.perUserLimit &&
row.perUserLimit > options.form.perUserLimit
) {
message.warning(`商品「${row.name}」限购不能大于活动每人限购`);
return false;
}
if (
resolveDiscountRateLabel(row.originalPrice, row.discountPrice) === '-'
) {
message.warning(`商品「${row.name}」折扣价配置不合法`);
return false;
}
}
return true;
}
function resolveScopeStoreId() {
if (options.selectedStoreId.value) {
return options.selectedStoreId.value;
}
if (options.form.storeIds.length > 0) {
return options.form.storeIds[0] ?? '';
}
return options.stores.value[0]?.id ?? '';
}
return {
drawerMode,
openCreateDrawer,
openEditDrawer,
openProductPicker,
quickSelectWeekDays,
removeProduct,
setDrawerOpen,
setFormChannels,
setFormCycleType,
setFormName,
setFormPerUserLimit,
setFormRecurringDateMode,
setFormTimeRange,
setFormValidDateRange,
setProductDiscountPrice,
setProductPerUserLimit,
submitDrawer,
toggleChannel,
toggleWeekDay,
};
}
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));
}

View File

@@ -0,0 +1,258 @@
import type { Dayjs } from 'dayjs';
import type {
MarketingFlashSaleDetailDto,
MarketingFlashSaleEditorStatus,
SaveMarketingFlashSaleDto,
} from '#/api/marketing';
import type {
FlashSaleCardViewModel,
FlashSaleEditorForm,
FlashSaleEditorProductForm,
} from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣页面纯函数工具。
*/
import dayjs from 'dayjs';
import {
createDefaultFlashSaleEditorForm,
createDefaultFlashSaleProductForm,
FLASH_SALE_WEEKDAY_OPTIONS,
} from './constants';
/** 格式化整数。 */
export function formatInteger(value: number) {
return Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 0,
}).format(value || 0);
}
/** 格式化金额。 */
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 resolveDiscountRateLabel(
originalPrice: number,
discountPrice: null | number,
) {
if (!discountPrice || discountPrice <= 0 || originalPrice <= 0) {
return '-';
}
if (discountPrice > originalPrice) {
return '-';
}
const rate = Number(((discountPrice / originalPrice) * 10).toFixed(1));
return `${trimDecimal(rate)}`;
}
/** 商品限购文案。 */
export function resolveProductLimitText(limit: null | number) {
if (!limit || limit <= 0) {
return '不限';
}
return `${formatInteger(limit)}件/人`;
}
/** 循环星期文案。 */
export function resolveWeekDaysText(weekDays: number[]) {
if (weekDays.length === 0) {
return '';
}
const sorted = [...new Set(weekDays)]
.filter((item) => item >= 1 && item <= 7)
.toSorted((first, second) => first - second);
if (sorted.length === 7) {
return '每天';
}
const weekday = [1, 2, 3, 4, 5];
const weekend = [6, 7];
if (weekday.every((item) => sorted.includes(item)) && sorted.length === 5) {
return '工作日';
}
if (weekend.every((item) => sorted.includes(item)) && sorted.length === 2) {
return '周末';
}
const labels = sorted
.map(
(item) =>
FLASH_SALE_WEEKDAY_OPTIONS.find((option) => option.value === item)
?.label ?? '',
)
.filter(Boolean);
return labels.join('、');
}
/** 卡片周期角标文案。 */
export function resolveRecurringBadgeText(item: FlashSaleCardViewModel) {
if (item.cycleType !== 'recurring') {
return '';
}
const weekText = resolveWeekDaysText(item.weekDays);
return weekText ? `${weekText}` : '周期循环';
}
/** 卡片日期范围文案。 */
export function resolveDateRangeText(item: FlashSaleCardViewModel) {
if (
item.cycleType === 'recurring' &&
item.recurringDateMode === 'long_term' &&
(!item.startDate || !item.endDate)
) {
return '长期有效';
}
if (item.startDate && item.endDate) {
return `${item.startDate} ~ ${item.endDate}`;
}
return '长期有效';
}
/** 卡片时段文案。 */
export function resolveTimeRangeText(item: FlashSaleCardViewModel) {
if (item.timeStart && item.timeEnd) {
return `${item.timeStart} - ${item.timeEnd}`;
}
return '全天有效';
}
/** 卡片汇总指标。 */
export function resolveCardSummary(item: FlashSaleCardViewModel) {
const summary = [
{
label: '活动销量',
value: `${formatInteger(item.metrics.activitySalesCount)}`,
},
{
label: '折扣总额',
value: formatCurrency(item.metrics.discountTotalAmount),
},
];
if (item.cycleType === 'recurring') {
summary.push({
label: '已循环',
value: `${formatInteger(item.metrics.loopedWeeks)}`,
});
}
return summary;
}
/** 详情映射编辑表单。 */
export function mapDetailToEditorForm(
detail: MarketingFlashSaleDetailDto,
): FlashSaleEditorForm {
const form = createDefaultFlashSaleEditorForm();
form.id = detail.id;
form.name = detail.name;
form.cycleType = detail.cycleType;
form.recurringDateMode = detail.recurringDateMode;
form.validDateRange =
detail.startDate && detail.endDate
? [dayjs(detail.startDate), dayjs(detail.endDate)]
: null;
form.timeRange =
detail.timeStart && detail.timeEnd
? [
dayjs(`2000-01-01 ${detail.timeStart}`),
dayjs(`2000-01-01 ${detail.timeEnd}`),
]
: null;
form.weekDays = [...detail.weekDays];
form.channels = [...detail.channels];
form.perUserLimit = detail.perUserLimit;
form.products = detail.products.map((item) =>
createDefaultFlashSaleProductForm({
productId: item.productId,
categoryId: item.categoryId,
categoryName: item.categoryName,
name: item.name,
spuCode: item.spuCode,
status: item.status,
originalPrice: item.originalPrice,
discountPrice: item.discountPrice,
perUserLimit: item.perUserLimit,
soldCount: item.soldCount,
}),
);
form.metrics = { ...detail.metrics };
form.status = detail.status;
form.storeIds = [...detail.storeIds];
return form;
}
/** 构建保存请求。 */
export function buildSaveFlashSalePayload(
form: FlashSaleEditorForm,
storeId: string,
): SaveMarketingFlashSaleDto {
const [startDate, endDate] = (form.validDateRange ?? []) as [Dayjs, Dayjs];
const [timeStart, timeEnd] = (form.timeRange ?? []) as [Dayjs, Dayjs];
return {
id: form.id || undefined,
storeId,
name: form.name.trim(),
cycleType: form.cycleType,
recurringDateMode: form.recurringDateMode,
startDate:
form.validDateRange && startDate
? startDate.format('YYYY-MM-DD')
: undefined,
endDate:
form.validDateRange && endDate ? endDate.format('YYYY-MM-DD') : undefined,
timeStart:
form.timeRange && timeStart ? timeStart.format('HH:mm') : undefined,
timeEnd: form.timeRange && timeEnd ? timeEnd.format('HH:mm') : undefined,
weekDays: form.cycleType === 'recurring' ? [...form.weekDays] : [],
channels: [...form.channels],
perUserLimit: form.perUserLimit,
storeIds: [...form.storeIds],
products: form.products.map((item) => ({
productId: item.productId,
discountPrice: Number(item.discountPrice || 0),
perUserLimit: item.perUserLimit,
})),
metrics: { ...form.metrics },
};
}
/** 深拷贝商品表单项。 */
export function cloneProductForm(
product: FlashSaleEditorProductForm,
): FlashSaleEditorProductForm {
return {
productId: product.productId,
categoryId: product.categoryId,
categoryName: product.categoryName,
name: product.name,
spuCode: product.spuCode,
status: product.status,
originalPrice: product.originalPrice,
discountPrice: product.discountPrice,
perUserLimit: product.perUserLimit,
soldCount: product.soldCount,
};
}
/** 是否已结束。 */
export function isEndedStatus(status: MarketingFlashSaleEditorStatus) {
return status === 'completed';
}

View File

@@ -0,0 +1,215 @@
import type { Ref } from 'vue';
import type {
FlashSaleEditorProductForm,
FlashSalePickerCategoryItem,
FlashSalePickerProductItem,
} from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣商品选择弹窗动作。
*/
import { message } from 'ant-design-vue';
import {
getMarketingFlashSalePickerCategoriesApi,
getMarketingFlashSalePickerProductsApi,
} from '#/api/marketing';
import { createDefaultFlashSaleProductForm } from './constants';
interface OpenPickerOptions {
selectedProducts: FlashSaleEditorProductForm[];
selectedProductIds: string[];
storeId: string;
}
interface CreatePickerActionsOptions {
isPickerLoading: Ref<boolean>;
isPickerOpen: Ref<boolean>;
pickerCategories: Ref<FlashSalePickerCategoryItem[]>;
pickerCategoryFilterId: Ref<string>;
pickerKeyword: Ref<string>;
pickerProducts: Ref<FlashSalePickerProductItem[]>;
pickerSelectedProductIds: Ref<string[]>;
}
export function createPickerActions(options: CreatePickerActionsOptions) {
let activeStoreId = '';
let selectedProductSnapshot = new Map<string, FlashSaleEditorProductForm>();
let onConfirmProducts:
| ((products: FlashSaleEditorProductForm[]) => void)
| null = null;
function setPickerOpen(value: boolean) {
options.isPickerOpen.value = value;
if (!value) {
onConfirmProducts = null;
}
}
function setPickerKeyword(value: string) {
options.pickerKeyword.value = value;
}
function setPickerCategoryFilterId(value: string) {
options.pickerCategoryFilterId.value = value;
}
function setPickerSelectedProductIds(value: string[]) {
options.pickerSelectedProductIds.value = [...new Set(value)];
}
function togglePickerProduct(productId: string) {
if (options.pickerSelectedProductIds.value.includes(productId)) {
options.pickerSelectedProductIds.value =
options.pickerSelectedProductIds.value.filter(
(item) => item !== productId,
);
return;
}
options.pickerSelectedProductIds.value = [
...options.pickerSelectedProductIds.value,
productId,
];
}
function toggleAllProducts(checked: boolean) {
if (!checked) {
const visibleIds = new Set(
options.pickerProducts.value.map((item) => item.id),
);
options.pickerSelectedProductIds.value =
options.pickerSelectedProductIds.value.filter(
(item) => !visibleIds.has(item),
);
return;
}
const merged = new Set(options.pickerSelectedProductIds.value);
for (const item of options.pickerProducts.value) {
merged.add(item.id);
}
options.pickerSelectedProductIds.value = [...merged];
}
async function loadPickerCategories() {
if (!activeStoreId) {
options.pickerCategories.value = [];
return;
}
options.pickerCategories.value =
await getMarketingFlashSalePickerCategoriesApi({
storeId: activeStoreId,
});
}
async function loadPickerProducts() {
if (!activeStoreId) {
options.pickerProducts.value = [];
return;
}
options.pickerProducts.value = await getMarketingFlashSalePickerProductsApi(
{
storeId: activeStoreId,
categoryId: options.pickerCategoryFilterId.value || undefined,
keyword: options.pickerKeyword.value.trim() || undefined,
limit: 500,
},
);
}
async function reloadPickerList() {
if (!options.isPickerOpen.value) {
return;
}
options.isPickerLoading.value = true;
try {
await Promise.all([loadPickerCategories(), loadPickerProducts()]);
} catch (error) {
console.error(error);
options.pickerCategories.value = [];
options.pickerProducts.value = [];
message.error('加载可选商品失败');
} finally {
options.isPickerLoading.value = false;
}
}
async function openPicker(
pickerOptions: OpenPickerOptions,
onConfirm: (products: FlashSaleEditorProductForm[]) => void,
) {
if (!pickerOptions.storeId) {
message.warning('请先选择具体门店后再添加商品');
return;
}
activeStoreId = pickerOptions.storeId;
onConfirmProducts = onConfirm;
selectedProductSnapshot = new Map(
pickerOptions.selectedProducts.map((item) => [
item.productId,
createDefaultFlashSaleProductForm(item),
]),
);
options.pickerKeyword.value = '';
options.pickerCategoryFilterId.value = '';
options.pickerSelectedProductIds.value = [
...pickerOptions.selectedProductIds,
];
options.isPickerOpen.value = true;
await reloadPickerList();
}
function submitPicker() {
const selectedIds = new Set(options.pickerSelectedProductIds.value);
const currentProductMap = new Map(
options.pickerProducts.value.map((item) => [
item.id,
createDefaultFlashSaleProductForm({
productId: item.id,
categoryId: item.categoryId,
categoryName: item.categoryName,
name: item.name,
spuCode: item.spuCode,
status: item.status,
originalPrice: item.price,
discountPrice: item.price,
perUserLimit: null,
soldCount: 0,
}),
]),
);
const selectedProducts = [...selectedIds]
.map(
(productId) =>
currentProductMap.get(productId) ??
selectedProductSnapshot.get(productId),
)
.filter(Boolean) as FlashSaleEditorProductForm[];
if (selectedProducts.length === 0) {
message.warning('请至少选择一个商品');
return;
}
onConfirmProducts?.(selectedProducts);
setPickerOpen(false);
}
return {
openPicker,
reloadPickerList,
setPickerCategoryFilterId,
setPickerKeyword,
setPickerOpen,
setPickerSelectedProductIds,
submitPicker,
toggleAllProducts,
togglePickerProduct,
};
}

View File

@@ -0,0 +1,280 @@
import type { StoreListItemDto } from '#/api/store';
import type {
FlashSaleCardViewModel,
FlashSalePickerCategoryItem,
FlashSalePickerProductItem,
FlashSaleStatsViewModel,
} from '#/views/marketing/flash-sale/types';
/**
* 文件职责:限时折扣页面状态与行为编排。
*/
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { createCardActions } from './flash-sale-page/card-actions';
import {
createDefaultFlashSaleEditorForm,
createDefaultFlashSaleFilterForm,
FLASH_SALE_STATUS_FILTER_OPTIONS,
} from './flash-sale-page/constants';
import {
createDataActions,
createEmptyStats,
} from './flash-sale-page/data-actions';
import { createDrawerActions } from './flash-sale-page/drawer-actions';
import { createPickerActions } from './flash-sale-page/picker-actions';
export function useMarketingFlashSalePage() {
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const filterForm = reactive(createDefaultFlashSaleFilterForm());
const keyword = ref('');
const rows = ref<FlashSaleCardViewModel[]>([]);
const stats = ref<FlashSaleStatsViewModel>(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(createDefaultFlashSaleEditorForm());
const isPickerOpen = ref(false);
const isPickerLoading = ref(false);
const pickerKeyword = ref('');
const pickerCategoryFilterId = ref('');
const pickerSelectedProductIds = ref<string[]>([]);
const pickerCategories = ref<FlashSalePickerCategoryItem[]>([]);
const pickerProducts = ref<FlashSalePickerProductItem[]>([]);
const storeOptions = computed(() => [
{ label: '全部门店', value: '' },
...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,
setPickerCategoryFilterId,
setPickerKeyword,
setPickerOpen,
setPickerSelectedProductIds,
submitPicker,
toggleAllProducts,
togglePickerProduct,
} = createPickerActions({
isPickerLoading,
isPickerOpen,
pickerCategories,
pickerCategoryFilterId,
pickerKeyword,
pickerProducts,
pickerSelectedProductIds,
});
const {
drawerMode,
openCreateDrawer,
openEditDrawer,
openProductPicker,
quickSelectWeekDays,
removeProduct,
setDrawerOpen,
setFormChannels,
setFormCycleType,
setFormName,
setFormPerUserLimit,
setFormRecurringDateMode,
setFormTimeRange,
setFormValidDateRange,
setProductDiscountPrice,
setProductPerUserLimit,
submitDrawer,
toggleChannel,
toggleWeekDay,
} = createDrawerActions({
form,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
loadActivities,
openPicker,
resolveOperationStoreId,
selectedStoreId,
stores,
});
const { removeActivity, toggleActivityStatus } = 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: '' | FlashSaleCardViewModel['displayStatus'],
) {
filterForm.status = value;
}
async function applyFilters() {
page.value = 1;
await loadActivities();
}
async function resetFilters() {
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 handlePickerSearch() {
await reloadPickerList();
}
async function handlePickerCategoryFilterChange(value: string) {
setPickerCategoryFilterId(value);
await reloadPickerList();
}
watch(selectedStoreId, () => {
page.value = 1;
filterForm.status = '';
keyword.value = '';
void loadActivities();
});
onMounted(async () => {
await loadStores();
await loadActivities();
});
return {
applyFilters,
drawerSubmitText,
drawerTitle,
filterForm,
form,
FLASH_SALE_STATUS_FILTER_OPTIONS,
handlePageChange,
handlePickerCategoryFilterChange,
handlePickerSearch,
hasStore,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
isLoading,
isPickerLoading,
isPickerOpen,
isStoreLoading,
keyword,
openCreateDrawer,
openEditDrawer,
openProductPicker,
page,
pageSize,
pickerCategories,
pickerCategoryFilterId,
pickerKeyword,
pickerProducts,
pickerSelectedProductIds,
quickSelectWeekDays,
reloadPickerList,
removeActivity,
removeProduct,
resetFilters,
rows,
selectedStoreId,
setDrawerOpen,
setFormChannels,
setFormCycleType,
setFormName,
setFormPerUserLimit,
setFormRecurringDateMode,
setFormTimeRange,
setFormValidDateRange,
setKeyword: setKeywordValue,
setPickerKeyword,
setPickerOpen,
setPickerSelectedProductIds,
setProductDiscountPrice,
setProductPerUserLimit,
setSelectedStoreId,
setStatusFilter,
stats,
storeNameMap,
storeOptions,
submitDrawer,
submitPicker,
toggleActivityStatus,
toggleAllProducts,
toggleChannel,
togglePickerProduct,
toggleWeekDay,
total,
};
}

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
/**
* 文件职责:营销中心-限时折扣页面。
*/
import type { MarketingFlashSaleDisplayStatus } from '#/api/marketing';
import { Page } from '@vben/common-ui';
import { Button, Empty, Input, Pagination, Select, Spin } from 'ant-design-vue';
import FlashSaleActivityCard from './components/FlashSaleActivityCard.vue';
import FlashSaleEditorDrawer from './components/FlashSaleEditorDrawer.vue';
import FlashSaleProductPickerModal from './components/FlashSaleProductPickerModal.vue';
import FlashSaleStatsCards from './components/FlashSaleStatsCards.vue';
import { useMarketingFlashSalePage } from './composables/useMarketingFlashSalePage';
const {
applyFilters,
drawerSubmitText,
drawerTitle,
filterForm,
form,
FLASH_SALE_STATUS_FILTER_OPTIONS,
handlePageChange,
handlePickerCategoryFilterChange,
handlePickerSearch,
hasStore,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
isLoading,
isPickerLoading,
isPickerOpen,
isStoreLoading,
keyword,
openCreateDrawer,
openEditDrawer,
openProductPicker,
page,
pageSize,
pickerCategories,
pickerCategoryFilterId,
pickerKeyword,
pickerProducts,
pickerSelectedProductIds,
quickSelectWeekDays,
removeActivity,
removeProduct,
resetFilters,
rows,
selectedStoreId,
setDrawerOpen,
setFormCycleType,
setFormName,
setFormPerUserLimit,
setFormRecurringDateMode,
setFormTimeRange,
setFormValidDateRange,
setKeyword,
setPickerKeyword,
setPickerOpen,
setSelectedStoreId,
setStatusFilter,
setProductDiscountPrice,
setProductPerUserLimit,
stats,
storeOptions,
submitDrawer,
submitPicker,
toggleActivityStatus,
toggleAllProducts,
toggleChannel,
togglePickerProduct,
toggleWeekDay,
total,
} = useMarketingFlashSalePage();
function onStatusFilterChange(value: unknown) {
const next =
typeof value === 'string' && value
? (value as MarketingFlashSaleDisplayStatus)
: '';
setStatusFilter(next);
void applyFilters();
}
</script>
<template>
<Page title="限时折扣" content-class="page-marketing-flash-sale">
<div class="fs-page">
<div class="fs-toolbar">
<Select
class="fs-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
placeholder="全部门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<Select
class="fs-filter-select"
:value="filterForm.status"
:options="FLASH_SALE_STATUS_FILTER_OPTIONS"
placeholder="全部状态"
@update:value="onStatusFilterChange"
/>
<Input
class="fs-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="fs-spacer"></span>
<Button type="primary" @click="openCreateDrawer">创建限时折扣</Button>
</div>
<FlashSaleStatsCards v-if="hasStore" :stats="stats" />
<div v-if="!hasStore" class="fs-empty">暂无门店请先创建门店</div>
<Spin v-else :spinning="isLoading">
<div v-if="rows.length > 0" class="fs-list">
<FlashSaleActivityCard
v-for="item in rows"
:key="item.id"
:item="item"
@edit="(row) => openEditDrawer(row.id, row.storeIds)"
@toggle-status="toggleActivityStatus"
@remove="removeActivity"
/>
</div>
<div v-else class="fs-empty">
<Empty description="暂无限时折扣活动" />
</div>
<div v-if="rows.length > 0" class="fs-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>
<FlashSaleEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
:submit-text="drawerSubmitText"
:submitting="isDrawerSubmitting"
:loading="isDrawerLoading"
:form="form"
@close="setDrawerOpen(false)"
@set-name="setFormName"
@set-cycle-type="setFormCycleType"
@set-recurring-date-mode="setFormRecurringDateMode"
@set-valid-date-range="setFormValidDateRange"
@set-time-range="setFormTimeRange"
@toggle-week-day="toggleWeekDay"
@quick-select-week-days="quickSelectWeekDays"
@toggle-channel="toggleChannel"
@set-per-user-limit="setFormPerUserLimit"
@open-product-picker="openProductPicker"
@remove-product="removeProduct"
@set-product-discount-price="setProductDiscountPrice"
@set-product-per-user-limit="setProductPerUserLimit"
@submit="submitDrawer"
/>
<FlashSaleProductPickerModal
:open="isPickerOpen"
:loading="isPickerLoading"
:keyword="pickerKeyword"
:category-filter-id="pickerCategoryFilterId"
:categories="pickerCategories"
:products="pickerProducts"
:selected-product-ids="pickerSelectedProductIds"
@close="setPickerOpen(false)"
@set-keyword="setPickerKeyword"
@set-category-filter-id="handlePickerCategoryFilterChange"
@toggle-product="togglePickerProduct"
@toggle-all="toggleAllProducts"
@search="handlePickerSearch"
@submit="submitPicker"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,29 @@
/**
* 文件职责:限时折扣页面基础样式变量。
*/
.page-marketing-flash-sale {
--fs-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1);
--fs-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
--fs-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%);
--fs-border: #e7eaf0;
--fs-text: #1f2937;
--fs-subtext: #6b7280;
--fs-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,163 @@
/**
* 文件职责:限时折扣活动卡片样式。
*/
.page-marketing-flash-sale {
.fs-card {
padding: 20px;
margin-bottom: 2px;
background: #fff;
border: 1px solid var(--fs-border);
border-radius: 10px;
box-shadow: var(--fs-shadow-sm);
transition: box-shadow var(--fs-transition);
}
.fs-card:hover {
box-shadow: var(--fs-shadow-md);
}
.fs-card.ended {
opacity: 0.56;
}
.fs-card-hd {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.fs-card-name {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
}
.fs-card-time {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: var(--fs-muted);
}
.fs-card-time .iconify,
.fs-recur-badge .iconify {
width: 12px;
height: 12px;
}
.fs-tag-running {
font-weight: 600;
color: #22c55e;
background: #dcfce7;
border: 1px solid #bbf7d0;
border-radius: 6px;
}
.fs-tag-ended {
font-weight: 600;
color: #9ca3af;
background: #f8f9fb;
border: 1px solid #e5e7eb;
border-radius: 6px;
}
.fs-tag-notstarted {
font-weight: 600;
color: #1677ff;
background: #f0f5ff;
border: 1px solid #adc6ff;
border-radius: 6px;
}
.fs-recur-badge {
display: inline-flex;
gap: 3px;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #1677ff;
background: #f0f5ff;
border: 1px solid #adc6ff;
border-radius: 4px;
}
.fs-prod-table {
width: 100%;
margin-bottom: 14px;
font-size: 13px;
border-collapse: collapse;
}
.fs-prod-table th {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-align: left;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.fs-prod-table td {
padding: 8px 12px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.fs-prod-table tr:last-child td {
border-bottom: none;
}
.fs-prod-table tr:hover td {
background: color-mix(in srgb, #1677ff 3%, #fff);
}
.fs-orig-price {
font-size: 12px;
color: var(--fs-muted);
text-decoration: line-through;
}
.fs-disc-price {
font-weight: 600;
color: #ef4444;
}
.fs-disc-rate {
display: inline-block;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #ef4444;
background: #fff1f0;
border-radius: 4px;
}
.fs-card-summary {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 10px 12px;
margin-bottom: 12px;
font-size: 12px;
color: #4b5563;
background: #f8f9fb;
border-radius: 8px;
}
.fs-card-summary strong {
font-weight: 600;
color: #1a1a2e;
}
.fs-card-ft {
display: flex;
gap: 16px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
}

View File

@@ -0,0 +1,256 @@
/**
* 文件职责:限时折扣主编辑抽屉样式。
*/
.fs-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;
}
.fs-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 {
height: 32px;
}
.ant-picker {
height: 34px;
padding: 0 10px;
}
.ant-input-number:focus-within,
.ant-picker-focused,
.ant-input:focus {
border-color: #1677ff !important;
box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important;
}
.fs-pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fs-pill {
height: 32px;
padding: 0 14px;
font-size: 13px;
line-height: 30px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s;
}
.fs-pill:hover {
color: #1677ff;
border-color: #91caff;
}
.fs-pill.checked {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.fs-range-picker {
width: 100%;
max-width: 360px;
}
.fs-day-sel {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.fs-day {
min-width: 46px;
height: 28px;
padding: 0 10px;
font-size: 12px;
color: #9ca3af;
cursor: pointer;
background: #f8f9fb;
border: 1px solid #e5e7eb;
border-radius: 14px;
transition: all 0.2s;
}
.fs-day.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.fs-day-quick {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.fs-product-empty {
padding: 14px 12px;
margin-bottom: 10px;
font-size: 13px;
color: #9ca3af;
text-align: center;
background: #f8f9fb;
border: 1px dashed #e5e7eb;
border-radius: 8px;
}
.fs-drawer-prod {
margin-bottom: 12px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.fs-drawer-prod-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.fs-drawer-prod-hd span {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.fs-drawer-prod-remove {
width: 24px;
height: 24px;
font-size: 14px;
color: #9ca3af;
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
transition: all 0.2s;
}
.fs-drawer-prod-remove:hover {
color: #ef4444;
background: #fef2f2;
}
.fs-drawer-prod-bd {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 10px 12px;
}
.fs-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fs-field label {
font-size: 11px;
color: #6b7280;
}
.fs-field .ant-input,
.fs-field .ant-input-number {
width: 120px;
}
.fs-auto-rate {
min-width: 46px;
font-size: 12px;
font-weight: 600;
line-height: 34px;
color: #ef4444;
}
.fs-limit-row {
display: flex;
gap: 8px;
align-items: center;
}
.fs-limit-row .ant-input-number {
width: 100px;
}
.fs-unit {
font-size: 13px;
color: #6b7280;
}
.fs-drawer-footer {
display: flex;
gap: 8px;
justify-content: flex-start;
}
.fs-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,91 @@
/**
* 文件职责:限时折扣页面布局样式。
*/
.page-marketing-flash-sale {
.fs-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.fs-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 12px 14px;
background: #fff;
border: 1px solid var(--fs-border);
border-radius: 10px;
box-shadow: var(--fs-shadow-sm);
}
.fs-store-select {
width: 220px;
}
.fs-filter-select {
width: 130px;
}
.fs-search {
width: 220px;
}
.fs-store-select .ant-select-selector,
.fs-filter-select .ant-select-selector {
border-radius: 8px !important;
}
.fs-spacer {
flex: 1;
}
.fs-stats {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 10px 16px;
font-size: 13px;
color: #4b5563;
background: #fff;
border: 1px solid var(--fs-border);
border-radius: 10px;
box-shadow: var(--fs-shadow-sm);
}
.fs-stats span {
display: inline-flex;
gap: 6px;
align-items: center;
}
.fs-stats strong {
font-weight: 600;
color: #1a1a2e;
}
.fs-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.fs-empty {
padding: 28px 14px;
font-size: 13px;
color: #9ca3af;
text-align: center;
background: #fff;
border: 1px solid var(--fs-border);
border-radius: 10px;
box-shadow: var(--fs-shadow-sm);
}
.fs-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 4px 2px;
margin-top: 12px;
}
}

View File

@@ -0,0 +1,146 @@
/**
* 文件职责:限时折扣商品选择弹窗样式。
*/
.fs-product-picker-modal {
.ant-modal-body {
padding: 0;
}
.ant-modal-content {
overflow: hidden;
border-radius: 10px;
}
.pp-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.pp-search {
flex: 1;
min-width: 220px;
max-width: 360px;
}
.pp-cat-select {
width: 150px;
}
.pp-selected-count {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #1677ff;
}
.pp-body {
max-height: 56vh;
overflow: auto;
}
.pp-empty {
padding: 32px 0;
}
.pp-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.pp-table th {
position: sticky;
top: 0;
z-index: 2;
padding: 9px 14px;
font-size: 12px;
font-weight: 500;
color: #6b7280;
text-align: left;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.pp-table td {
padding: 9px 14px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.pp-table tbody tr {
cursor: pointer;
}
.pp-table tbody tr:hover td {
background: color-mix(in srgb, #1677ff 3%, #fff);
}
.pp-table tbody tr.pp-checked td {
background: color-mix(in srgb, #1677ff 8%, #fff);
}
.pp-col-check {
width: 44px;
}
.pp-prod-name {
font-weight: 500;
}
.pp-prod-spu {
margin-top: 2px;
font-size: 11px;
color: #9ca3af;
}
.pp-prod-status {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
font-size: 11px;
border-radius: 999px;
}
.pp-prod-status.on {
color: #166534;
background: #dcfce7;
}
.pp-prod-status.sold {
color: #92400e;
background: #fef3c7;
}
.pp-prod-status.off {
color: #475569;
background: #e2e8f0;
}
.pp-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-top: 1px solid #f0f0f0;
}
.pp-footer-info {
font-size: 12px;
color: #6b7280;
}
.pp-footer-info strong {
color: #1677ff;
}
.pp-footer-btns {
display: inline-flex;
gap: 8px;
align-items: center;
}
}

View File

@@ -0,0 +1,37 @@
/**
* 文件职责:限时折扣页面响应式样式。
*/
.page-marketing-flash-sale {
@media (width <= 1200px) {
.fs-toolbar {
flex-wrap: wrap;
}
.fs-spacer {
display: none;
}
}
@media (width <= 768px) {
.fs-card {
padding: 14px;
}
.fs-card-hd {
gap: 8px;
}
.fs-card-summary {
gap: 10px 16px;
}
.fs-prod-table {
font-size: 12px;
}
.fs-prod-table th,
.fs-prod-table td {
padding: 7px 8px;
}
}
}

View File

@@ -0,0 +1,68 @@
import type { Dayjs } from 'dayjs';
import type {
MarketingFlashSaleChannel,
MarketingFlashSaleCycleType,
MarketingFlashSaleDisplayStatus,
MarketingFlashSaleEditorStatus,
MarketingFlashSaleListItemDto,
MarketingFlashSaleMetricsDto,
MarketingFlashSalePickerCategoryItemDto,
MarketingFlashSalePickerProductItemDto,
MarketingFlashSaleProductStatus,
MarketingFlashSaleRecurringDateMode,
MarketingFlashSaleStatsDto,
} from '#/api/marketing';
/**
* 文件职责:限时折扣页面类型定义。
*/
/** 列表筛选表单。 */
export interface FlashSaleFilterForm {
status: '' | MarketingFlashSaleDisplayStatus;
}
/** 抽屉商品表单项。 */
export interface FlashSaleEditorProductForm {
categoryId: string;
categoryName: string;
discountPrice: null | number;
name: string;
originalPrice: number;
perUserLimit: null | number;
productId: string;
soldCount: number;
spuCode: string;
status: MarketingFlashSaleProductStatus;
}
/** 主编辑抽屉表单。 */
export interface FlashSaleEditorForm {
channels: MarketingFlashSaleChannel[];
cycleType: MarketingFlashSaleCycleType;
id: string;
metrics: MarketingFlashSaleMetricsDto;
name: string;
perUserLimit: null | number;
products: FlashSaleEditorProductForm[];
recurringDateMode: MarketingFlashSaleRecurringDateMode;
status: MarketingFlashSaleEditorStatus;
storeIds: string[];
timeRange: [Dayjs, Dayjs] | null;
validDateRange: [Dayjs, Dayjs] | null;
weekDays: number[];
}
/** 列表卡片视图模型。 */
export type FlashSaleCardViewModel = MarketingFlashSaleListItemDto;
/** 统计视图模型。 */
export type FlashSaleStatsViewModel = MarketingFlashSaleStatsDto;
/** 选品分类项。 */
export type FlashSalePickerCategoryItem =
MarketingFlashSalePickerCategoryItemDto;
/** 选品商品项。 */
export type FlashSalePickerProductItem = MarketingFlashSalePickerProductItemDto;