feat(@vben/web-antd): implement marketing flash sale page
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 2s
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 2s
This commit is contained in:
266
apps/web-antd/src/api/marketing/flash-sale.ts
Normal file
266
apps/web-antd/src/api/marketing/flash-sale.ts
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
204
apps/web-antd/src/views/marketing/flash-sale/index.vue
Normal file
204
apps/web-antd/src/views/marketing/flash-sale/index.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
apps/web-antd/src/views/marketing/flash-sale/styles/card.less
Normal file
163
apps/web-antd/src/views/marketing/flash-sale/styles/card.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
apps/web-antd/src/views/marketing/flash-sale/styles/drawer.less
Normal file
256
apps/web-antd/src/views/marketing/flash-sale/styles/drawer.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@import './base.less';
|
||||||
|
@import './layout.less';
|
||||||
|
@import './card.less';
|
||||||
|
@import './drawer.less';
|
||||||
|
@import './picker.less';
|
||||||
|
@import './responsive.less';
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apps/web-antd/src/views/marketing/flash-sale/styles/picker.less
Normal file
146
apps/web-antd/src/views/marketing/flash-sale/styles/picker.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/web-antd/src/views/marketing/flash-sale/types.ts
Normal file
68
apps/web-antd/src/views/marketing/flash-sale/types.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user