feat(project): implement tenant seckill page and drawers
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
This commit is contained in:
@@ -185,3 +185,4 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) {
|
|||||||
|
|
||||||
export * from './flash-sale';
|
export * from './flash-sale';
|
||||||
export * from './full-reduction';
|
export * from './full-reduction';
|
||||||
|
export * from './seckill';
|
||||||
|
|||||||
271
apps/web-antd/src/api/marketing/seckill.ts
Normal file
271
apps/web-antd/src/api/marketing/seckill.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:营销中心秒杀活动 API 与 DTO 定义。
|
||||||
|
* 1. 维护秒杀活动列表、详情、保存、状态切换、删除与选品契约。
|
||||||
|
*/
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/** 活动展示状态。 */
|
||||||
|
export type MarketingSeckillDisplayStatus = 'ended' | 'ongoing' | 'upcoming';
|
||||||
|
|
||||||
|
/** 活动编辑状态。 */
|
||||||
|
export type MarketingSeckillEditorStatus = 'active' | 'completed';
|
||||||
|
|
||||||
|
/** 秒杀活动类型。 */
|
||||||
|
export type MarketingSeckillActivityType = 'hourly' | 'timed';
|
||||||
|
|
||||||
|
/** 适用渠道。 */
|
||||||
|
export type MarketingSeckillChannel = 'delivery' | 'dine_in' | 'pickup';
|
||||||
|
|
||||||
|
/** 商品状态。 */
|
||||||
|
export type MarketingSeckillProductStatus =
|
||||||
|
| 'off_shelf'
|
||||||
|
| 'on_sale'
|
||||||
|
| 'sold_out';
|
||||||
|
|
||||||
|
/** 场次。 */
|
||||||
|
export interface MarketingSeckillSessionDto {
|
||||||
|
durationMinutes: number;
|
||||||
|
startTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 秒杀商品。 */
|
||||||
|
export interface MarketingSeckillProductDto {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
name: string;
|
||||||
|
originalPrice: number;
|
||||||
|
perUserLimit: null | number;
|
||||||
|
productId: string;
|
||||||
|
seckillPrice: number;
|
||||||
|
soldCount: number;
|
||||||
|
spuCode: string;
|
||||||
|
status: MarketingSeckillProductStatus;
|
||||||
|
stockLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活动指标。 */
|
||||||
|
export interface MarketingSeckillMetricsDto {
|
||||||
|
conversionRate: number;
|
||||||
|
dealCount: number;
|
||||||
|
monthlySeckillSalesCount: number;
|
||||||
|
participantCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表查询参数。 */
|
||||||
|
export interface MarketingSeckillListQuery {
|
||||||
|
keyword?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status?: '' | MarketingSeckillDisplayStatus;
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情查询参数。 */
|
||||||
|
export interface MarketingSeckillDetailQuery {
|
||||||
|
activityId: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存请求。 */
|
||||||
|
export interface SaveMarketingSeckillDto {
|
||||||
|
activityType: MarketingSeckillActivityType;
|
||||||
|
channels: MarketingSeckillChannel[];
|
||||||
|
endDate?: string;
|
||||||
|
id?: string;
|
||||||
|
metrics?: MarketingSeckillMetricsDto;
|
||||||
|
name: string;
|
||||||
|
perUserLimit: null | number;
|
||||||
|
preheatEnabled: boolean;
|
||||||
|
preheatHours: null | number;
|
||||||
|
products: Array<{
|
||||||
|
perUserLimit: null | number;
|
||||||
|
productId: string;
|
||||||
|
seckillPrice: number;
|
||||||
|
stockLimit: number;
|
||||||
|
}>;
|
||||||
|
sessions: MarketingSeckillSessionDto[];
|
||||||
|
startDate?: string;
|
||||||
|
storeId: string;
|
||||||
|
timeEnd?: string;
|
||||||
|
timeStart?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态修改请求。 */
|
||||||
|
export interface ChangeMarketingSeckillStatusDto {
|
||||||
|
activityId: string;
|
||||||
|
status: MarketingSeckillEditorStatus;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除请求。 */
|
||||||
|
export interface DeleteMarketingSeckillDto {
|
||||||
|
activityId: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表统计。 */
|
||||||
|
export interface MarketingSeckillStatsDto {
|
||||||
|
conversionRate: number;
|
||||||
|
monthlySeckillSalesCount: number;
|
||||||
|
ongoingCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表项。 */
|
||||||
|
export interface MarketingSeckillListItemDto {
|
||||||
|
activityType: MarketingSeckillActivityType;
|
||||||
|
channels: MarketingSeckillChannel[];
|
||||||
|
displayStatus: MarketingSeckillDisplayStatus;
|
||||||
|
endDate?: string;
|
||||||
|
id: string;
|
||||||
|
isDimmed: boolean;
|
||||||
|
metrics: MarketingSeckillMetricsDto;
|
||||||
|
name: string;
|
||||||
|
perUserLimit: null | number;
|
||||||
|
preheatEnabled: boolean;
|
||||||
|
preheatHours: null | number;
|
||||||
|
products: MarketingSeckillProductDto[];
|
||||||
|
sessions: MarketingSeckillSessionDto[];
|
||||||
|
startDate?: string;
|
||||||
|
status: MarketingSeckillEditorStatus;
|
||||||
|
storeIds: string[];
|
||||||
|
timeEnd?: string;
|
||||||
|
timeStart?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表结果。 */
|
||||||
|
export interface MarketingSeckillListResultDto {
|
||||||
|
items: MarketingSeckillListItemDto[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
stats: MarketingSeckillStatsDto;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情数据。 */
|
||||||
|
export interface MarketingSeckillDetailDto {
|
||||||
|
activityType: MarketingSeckillActivityType;
|
||||||
|
channels: MarketingSeckillChannel[];
|
||||||
|
displayStatus: MarketingSeckillDisplayStatus;
|
||||||
|
endDate?: string;
|
||||||
|
id: string;
|
||||||
|
metrics: MarketingSeckillMetricsDto;
|
||||||
|
name: string;
|
||||||
|
perUserLimit: null | number;
|
||||||
|
preheatEnabled: boolean;
|
||||||
|
preheatHours: null | number;
|
||||||
|
products: MarketingSeckillProductDto[];
|
||||||
|
sessions: MarketingSeckillSessionDto[];
|
||||||
|
startDate?: string;
|
||||||
|
status: MarketingSeckillEditorStatus;
|
||||||
|
storeIds: string[];
|
||||||
|
timeEnd?: string;
|
||||||
|
timeStart?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选品分类查询参数。 */
|
||||||
|
export interface MarketingSeckillPickerCategoryQuery {
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选品分类项。 */
|
||||||
|
export interface MarketingSeckillPickerCategoryItemDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
productCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选品商品查询参数。 */
|
||||||
|
export interface MarketingSeckillPickerProductQuery {
|
||||||
|
categoryId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
limit?: number;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选品商品项。 */
|
||||||
|
export interface MarketingSeckillPickerProductItemDto {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
spuCode: string;
|
||||||
|
status: MarketingSeckillProductStatus;
|
||||||
|
stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取列表。 */
|
||||||
|
export async function getMarketingSeckillListApi(
|
||||||
|
params: MarketingSeckillListQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MarketingSeckillListResultDto>(
|
||||||
|
'/marketing/seckill/list',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取详情。 */
|
||||||
|
export async function getMarketingSeckillDetailApi(
|
||||||
|
params: MarketingSeckillDetailQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MarketingSeckillDetailDto>(
|
||||||
|
'/marketing/seckill/detail',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存活动。 */
|
||||||
|
export async function saveMarketingSeckillApi(data: SaveMarketingSeckillDto) {
|
||||||
|
return requestClient.post<MarketingSeckillDetailDto>(
|
||||||
|
'/marketing/seckill/save',
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改状态。 */
|
||||||
|
export async function changeMarketingSeckillStatusApi(
|
||||||
|
data: ChangeMarketingSeckillStatusDto,
|
||||||
|
) {
|
||||||
|
return requestClient.post<MarketingSeckillDetailDto>(
|
||||||
|
'/marketing/seckill/status',
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除活动。 */
|
||||||
|
export async function deleteMarketingSeckillApi(
|
||||||
|
data: DeleteMarketingSeckillDto,
|
||||||
|
) {
|
||||||
|
return requestClient.post('/marketing/seckill/delete', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取选品分类。 */
|
||||||
|
export async function getMarketingSeckillPickerCategoriesApi(
|
||||||
|
params: MarketingSeckillPickerCategoryQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MarketingSeckillPickerCategoryItemDto[]>(
|
||||||
|
'/marketing/seckill/picker/categories',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取选品商品。 */
|
||||||
|
export async function getMarketingSeckillPickerProductsApi(
|
||||||
|
params: MarketingSeckillPickerProductQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<MarketingSeckillPickerProductItemDto[]>(
|
||||||
|
'/marketing/seckill/picker/products',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,12 +64,27 @@ const emit = defineEmits<{
|
|||||||
(event: 'toggleWeekDay', day: number): void;
|
(event: 'toggleWeekDay', day: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function onDateRangeChange(value: [Dayjs, Dayjs] | null) {
|
type RangePickerValue = [Dayjs, Dayjs] | [string, string] | null;
|
||||||
emit('setValidDateRange', value);
|
|
||||||
|
function normalizeDayjsRange(value: RangePickerValue): [Dayjs, Dayjs] | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start, end] = value;
|
||||||
|
if (typeof start === 'string' || typeof end === 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [start, end];
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTimeRangeChange(value: [Dayjs, Dayjs] | null) {
|
function onDateRangeChange(value: RangePickerValue) {
|
||||||
emit('setTimeRange', value);
|
emit('setValidDateRange', normalizeDayjsRange(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimeRangeChange(value: RangePickerValue) {
|
||||||
|
emit('setTimeRange', normalizeDayjsRange(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNullableNumber(value: null | number | string) {
|
function parseNullableNumber(value: null | number | string) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动卡片。
|
||||||
|
*/
|
||||||
|
import type { SeckillCardViewModel } from '../types';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SECKILL_STATUS_CLASS_MAP,
|
||||||
|
SECKILL_STATUS_TEXT_MAP,
|
||||||
|
} from '../composables/seckill-page/constants';
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
resolveCardSummary,
|
||||||
|
resolveCardTimeText,
|
||||||
|
resolveProgressClass,
|
||||||
|
resolveProgressPercent,
|
||||||
|
} from '../composables/seckill-page/helpers';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: SeckillCardViewModel;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [item: SeckillCardViewModel];
|
||||||
|
remove: [item: SeckillCardViewModel];
|
||||||
|
toggleStatus: [item: SeckillCardViewModel];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const summaryItems = computed(() => resolveCardSummary(props.item));
|
||||||
|
const statusActionText = computed(() =>
|
||||||
|
props.item.status === 'completed' ? '启用' : '停用',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sk-card" :class="{ ended: item.isDimmed }">
|
||||||
|
<div class="sk-card-hd">
|
||||||
|
<span class="sk-card-name">{{ item.name }}</span>
|
||||||
|
<span class="g-tag" :class="SECKILL_STATUS_CLASS_MAP[item.displayStatus]">
|
||||||
|
{{ SECKILL_STATUS_TEXT_MAP[item.displayStatus] }}
|
||||||
|
</span>
|
||||||
|
<span class="sk-card-time">
|
||||||
|
<IconifyIcon
|
||||||
|
:icon="
|
||||||
|
item.displayStatus === 'upcoming' || item.displayStatus === 'ended'
|
||||||
|
? 'lucide:calendar-days'
|
||||||
|
: 'lucide:clock-3'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{{ resolveCardTimeText(item) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sk-prod-list">
|
||||||
|
<div
|
||||||
|
v-for="product in item.products"
|
||||||
|
:key="product.productId"
|
||||||
|
class="sk-prod-row"
|
||||||
|
>
|
||||||
|
<span class="sk-prod-name">{{ product.name }}</span>
|
||||||
|
<span class="sk-prod-prices">
|
||||||
|
<span class="sk-orig-price">{{
|
||||||
|
formatCurrency(product.originalPrice)
|
||||||
|
}}</span>
|
||||||
|
<span class="sk-seckill-price">{{
|
||||||
|
formatCurrency(product.seckillPrice)
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="sk-progress-wrap">
|
||||||
|
<div class="sk-progress">
|
||||||
|
<div
|
||||||
|
class="sk-progress-fill"
|
||||||
|
:class="
|
||||||
|
resolveProgressClass(
|
||||||
|
resolveProgressPercent(product.soldCount, product.stockLimit),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:style="{
|
||||||
|
width: `${resolveProgressPercent(product.soldCount, product.stockLimit)}%`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<span class="sk-progress-text">
|
||||||
|
已抢 {{ product.soldCount }}/{{ product.stockLimit }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="product.soldCount >= product.stockLimit"
|
||||||
|
class="sk-sold-out"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:flame" />
|
||||||
|
已抢光
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sk-card-summary">
|
||||||
|
<span v-for="summary in summaryItems" :key="summary.label">
|
||||||
|
{{ summary.label }}
|
||||||
|
<strong>{{ summary.value }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sk-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,357 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import type { SeckillEditorForm } from '../types';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MarketingSeckillActivityType,
|
||||||
|
MarketingSeckillChannel,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
TimePicker,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动主编辑抽屉。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
SECKILL_ACTIVITY_TYPE_OPTIONS,
|
||||||
|
SECKILL_CHANNEL_OPTIONS,
|
||||||
|
} from '../composables/seckill-page/constants';
|
||||||
|
import { formatCurrency } from '../composables/seckill-page/helpers';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
form: SeckillEditorForm;
|
||||||
|
loading: boolean;
|
||||||
|
open: boolean;
|
||||||
|
submitText: string;
|
||||||
|
submitting: boolean;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'addSession'): void;
|
||||||
|
(event: 'close'): void;
|
||||||
|
(event: 'openProductPicker'): void;
|
||||||
|
(event: 'removeProduct', index: number): void;
|
||||||
|
(event: 'removeSession', index: number): void;
|
||||||
|
(event: 'setActivityType', value: MarketingSeckillActivityType): void;
|
||||||
|
(event: 'setName', value: string): void;
|
||||||
|
(event: 'setPerUserLimit', value: null | number): void;
|
||||||
|
(event: 'setPreheatEnabled', value: boolean): void;
|
||||||
|
(event: 'setPreheatHours', value: null | number): void;
|
||||||
|
(event: 'setProductPerUserLimit', index: number, value: null | number): void;
|
||||||
|
(event: 'setProductSeckillPrice', index: number, value: null | number): void;
|
||||||
|
(event: 'setProductStockLimit', index: number, value: null | number): void;
|
||||||
|
(event: 'setSessionDuration', index: number, value: null | number): void;
|
||||||
|
(event: 'setSessionStartTime', index: number, value: string): void;
|
||||||
|
(event: 'setTimeRange', value: [Dayjs, Dayjs] | null): void;
|
||||||
|
(event: 'setValidDateRange', value: [Dayjs, Dayjs] | null): void;
|
||||||
|
(event: 'submit'): void;
|
||||||
|
(event: 'toggleChannel', channel: MarketingSeckillChannel): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type RangePickerValue = [Dayjs, Dayjs] | [string, string] | null;
|
||||||
|
|
||||||
|
function normalizeDayjsRange(value: RangePickerValue): [Dayjs, Dayjs] | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start, end] = value;
|
||||||
|
if (typeof start === 'string' || typeof end === 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRangeChange(value: RangePickerValue) {
|
||||||
|
emit('setValidDateRange', normalizeDayjsRange(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimeRangeChange(value: RangePickerValue) {
|
||||||
|
emit('setTimeRange', normalizeDayjsRange(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="560"
|
||||||
|
:destroy-on-close="false"
|
||||||
|
class="sk-editor-drawer"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Form layout="vertical" class="sk-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="sk-pill-group">
|
||||||
|
<button
|
||||||
|
v-for="item in SECKILL_ACTIVITY_TYPE_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
class="sk-pill"
|
||||||
|
:class="{ checked: form.activityType === item.value }"
|
||||||
|
@click="emit('setActivityType', item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
v-if="form.activityType === 'timed'"
|
||||||
|
label="活动时间"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
:value="form.validDateRange ?? undefined"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
class="sk-range-picker"
|
||||||
|
@update:value="onDateRangeChange"
|
||||||
|
/>
|
||||||
|
<TimePicker.RangePicker
|
||||||
|
:value="form.timeRange ?? undefined"
|
||||||
|
format="HH:mm"
|
||||||
|
class="sk-range-picker"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
@update:value="onTimeRangeChange"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item v-else label="场次设置" required>
|
||||||
|
<div class="sk-session-list">
|
||||||
|
<div
|
||||||
|
v-for="(session, index) in form.sessions"
|
||||||
|
:key="session.key"
|
||||||
|
class="sk-session-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
:value="session.startTime"
|
||||||
|
type="time"
|
||||||
|
class="sk-session-time"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
emit('setSessionStartTime', index, String(value ?? ''))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="sk-session-label">持续</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="session.durationMinutes ?? undefined"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="sk-session-duration"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
emit(
|
||||||
|
'setSessionDuration',
|
||||||
|
index,
|
||||||
|
parseNullableInteger(value),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="sk-session-label">分钟</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sk-session-remove"
|
||||||
|
@click="emit('removeSession', index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="small" @click="emit('addSession')">+ 添加场次</Button>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="秒杀商品" required>
|
||||||
|
<div v-if="form.products.length === 0" class="sk-product-empty">
|
||||||
|
暂未添加秒杀商品
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in form.products"
|
||||||
|
:key="item.productId"
|
||||||
|
class="sk-drawer-prod"
|
||||||
|
>
|
||||||
|
<div class="sk-drawer-prod-hd">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sk-drawer-prod-remove"
|
||||||
|
@click="emit('removeProduct', index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sk-drawer-prod-bd">
|
||||||
|
<div class="sk-field">
|
||||||
|
<label>原价</label>
|
||||||
|
<Input :value="formatCurrency(item.originalPrice)" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="sk-field">
|
||||||
|
<label>秒杀价</label>
|
||||||
|
<InputNumber
|
||||||
|
:value="item.seckillPrice ?? undefined"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="请输入秒杀价"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
emit(
|
||||||
|
'setProductSeckillPrice',
|
||||||
|
index,
|
||||||
|
parseNullableNumber(value),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sk-field">
|
||||||
|
<label>限量(份)</label>
|
||||||
|
<InputNumber
|
||||||
|
:value="item.stockLimit ?? undefined"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
placeholder="如:50"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
emit(
|
||||||
|
'setProductStockLimit',
|
||||||
|
index,
|
||||||
|
parseNullableInteger(value),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sk-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="sk-pill-group">
|
||||||
|
<button
|
||||||
|
v-for="item in SECKILL_CHANNEL_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
class="sk-pill"
|
||||||
|
:class="{ checked: form.channels.includes(item.value) }"
|
||||||
|
@click="emit('toggleChannel', item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="每人限购">
|
||||||
|
<div class="sk-limit-row">
|
||||||
|
<InputNumber
|
||||||
|
:value="form.perUserLimit ?? undefined"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
placeholder="不限则留空"
|
||||||
|
@update:value="
|
||||||
|
(value) => emit('setPerUserLimit', parseNullableInteger(value))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="sk-unit">件</span>
|
||||||
|
</div>
|
||||||
|
<div class="g-hint">活动期间每人累计可秒杀的商品总数,留空不限</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="预热设置">
|
||||||
|
<div class="sk-preheat-row">
|
||||||
|
<Switch
|
||||||
|
:checked="form.preheatEnabled"
|
||||||
|
@update:checked="
|
||||||
|
(value) => emit('setPreheatEnabled', Boolean(value))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="sk-session-label">开启预热</span>
|
||||||
|
<template v-if="form.preheatEnabled">
|
||||||
|
<span class="sk-session-label">提前</span>
|
||||||
|
<InputNumber
|
||||||
|
:value="form.preheatHours ?? undefined"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
@update:value="
|
||||||
|
(value) =>
|
||||||
|
emit('setPreheatHours', parseNullableInteger(value))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="sk-session-label">小时</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="g-hint">在商品页展示秒杀预告,吸引用户提前关注</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="sk-drawer-footer">
|
||||||
|
<Button @click="emit('close')">取消</Button>
|
||||||
|
<Button type="primary" :loading="submitting" @click="emit('submit')">
|
||||||
|
{{ submitText }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
SeckillPickerCategoryItem,
|
||||||
|
SeckillPickerProductItem,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动商品选择二级抽屉。
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Button, Drawer, Empty, Input, Select, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { SECKILL_PRODUCT_STATUS_TEXT_MAP } from '../composables/seckill-page/constants';
|
||||||
|
import { formatCurrency } from '../composables/seckill-page/helpers';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
categories: SeckillPickerCategoryItem[];
|
||||||
|
categoryFilterId: string;
|
||||||
|
keyword: string;
|
||||||
|
loading: boolean;
|
||||||
|
open: boolean;
|
||||||
|
products: SeckillPickerProductItem[];
|
||||||
|
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: SeckillPickerProductItem['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>
|
||||||
|
<Drawer
|
||||||
|
:open="open"
|
||||||
|
title="添加秒杀商品"
|
||||||
|
width="760"
|
||||||
|
class="sk-product-picker-drawer"
|
||||||
|
@close="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)">
|
||||||
|
{{ SECKILL_PRODUCT_STATUS_TEXT_MAP[item.status] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动统计条。
|
||||||
|
*/
|
||||||
|
import type { SeckillStatsViewModel } from '../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatInteger,
|
||||||
|
trimDecimal,
|
||||||
|
} from '../composables/seckill-page/helpers';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
stats: SeckillStatsViewModel;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sk-stats">
|
||||||
|
<span>
|
||||||
|
秒杀活动
|
||||||
|
<strong>{{ formatInteger(stats.totalCount) }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
进行中
|
||||||
|
<strong>{{ formatInteger(stats.ongoingCount) }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
本月秒杀销量
|
||||||
|
<strong>{{ formatInteger(stats.monthlySeckillSalesCount) }}</strong>
|
||||||
|
单
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
秒杀转化率
|
||||||
|
<strong>{{ trimDecimal(stats.conversionRate) }}%</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { SeckillCardViewModel } from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动卡片行操作。
|
||||||
|
*/
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
changeMarketingSeckillStatusApi,
|
||||||
|
deleteMarketingSeckillApi,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
|
||||||
|
interface CreateCardActionsOptions {
|
||||||
|
loadActivities: () => Promise<void>;
|
||||||
|
resolveOperationStoreId: (preferredStoreIds?: string[]) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCardActions(options: CreateCardActionsOptions) {
|
||||||
|
function toggleActivityStatus(item: SeckillCardViewModel) {
|
||||||
|
const operationStoreId = options.resolveOperationStoreId(item.storeIds);
|
||||||
|
if (!operationStoreId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = item.status === 'completed' ? 'active' : 'completed';
|
||||||
|
const isEnabling = nextStatus === 'active';
|
||||||
|
const feedbackKey = `seckill-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 changeMarketingSeckillStatusApi({
|
||||||
|
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: SeckillCardViewModel) {
|
||||||
|
const operationStoreId = options.resolveOperationStoreId(item.storeIds);
|
||||||
|
if (!operationStoreId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认删除活动「${item.name}」吗?`,
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await deleteMarketingSeckillApi({
|
||||||
|
storeId: operationStoreId,
|
||||||
|
activityId: item.id,
|
||||||
|
});
|
||||||
|
message.success('活动已删除');
|
||||||
|
await options.loadActivities();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error('删除失败,请稍后重试');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeActivity,
|
||||||
|
toggleActivityStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type {
|
||||||
|
MarketingSeckillActivityType,
|
||||||
|
MarketingSeckillChannel,
|
||||||
|
MarketingSeckillDisplayStatus,
|
||||||
|
MarketingSeckillProductStatus,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
import type {
|
||||||
|
SeckillEditorForm,
|
||||||
|
SeckillEditorProductForm,
|
||||||
|
SeckillEditorSessionForm,
|
||||||
|
SeckillFilterForm,
|
||||||
|
} from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面常量与默认表单。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 状态筛选项。 */
|
||||||
|
export const SECKILL_STATUS_FILTER_OPTIONS: Array<{
|
||||||
|
label: string;
|
||||||
|
value: '' | MarketingSeckillDisplayStatus;
|
||||||
|
}> = [
|
||||||
|
{ label: '全部状态', value: '' },
|
||||||
|
{ label: '进行中', value: 'ongoing' },
|
||||||
|
{ label: '未开始', value: 'upcoming' },
|
||||||
|
{ label: '已结束', value: 'ended' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 活动类型选项。 */
|
||||||
|
export const SECKILL_ACTIVITY_TYPE_OPTIONS: Array<{
|
||||||
|
label: string;
|
||||||
|
value: MarketingSeckillActivityType;
|
||||||
|
}> = [
|
||||||
|
{ label: '限时秒杀', value: 'timed' },
|
||||||
|
{ label: '整点秒杀', value: 'hourly' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 适用渠道选项。 */
|
||||||
|
export const SECKILL_CHANNEL_OPTIONS: Array<{
|
||||||
|
label: string;
|
||||||
|
value: MarketingSeckillChannel;
|
||||||
|
}> = [
|
||||||
|
{ label: '外卖', value: 'delivery' },
|
||||||
|
{ label: '自提', value: 'pickup' },
|
||||||
|
{ label: '堂食', value: 'dine_in' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 展示状态文案。 */
|
||||||
|
export const SECKILL_STATUS_TEXT_MAP: Record<
|
||||||
|
MarketingSeckillDisplayStatus,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
ongoing: '进行中',
|
||||||
|
upcoming: '未开始',
|
||||||
|
ended: '已结束',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 展示状态类。 */
|
||||||
|
export const SECKILL_STATUS_CLASS_MAP: Record<
|
||||||
|
MarketingSeckillDisplayStatus,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
ongoing: 'sk-tag-running',
|
||||||
|
upcoming: 'sk-tag-notstarted',
|
||||||
|
ended: 'sk-tag-ended',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 商品状态文案。 */
|
||||||
|
export const SECKILL_PRODUCT_STATUS_TEXT_MAP: Record<
|
||||||
|
MarketingSeckillProductStatus,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
on_sale: '在售',
|
||||||
|
off_shelf: '下架',
|
||||||
|
sold_out: '沽清',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 创建默认筛选表单。 */
|
||||||
|
export function createDefaultSeckillFilterForm(): SeckillFilterForm {
|
||||||
|
return {
|
||||||
|
status: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建空指标对象。 */
|
||||||
|
export function createEmptySeckillMetrics() {
|
||||||
|
return {
|
||||||
|
participantCount: 0,
|
||||||
|
dealCount: 0,
|
||||||
|
conversionRate: 0,
|
||||||
|
monthlySeckillSalesCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建默认场次表单项。 */
|
||||||
|
export function createDefaultSeckillSessionForm(
|
||||||
|
session?: Partial<SeckillEditorSessionForm>,
|
||||||
|
): SeckillEditorSessionForm {
|
||||||
|
return {
|
||||||
|
key: session?.key ?? `${Date.now()}-${Math.random()}`,
|
||||||
|
startTime: session?.startTime ?? '',
|
||||||
|
durationMinutes:
|
||||||
|
session?.durationMinutes === null ||
|
||||||
|
session?.durationMinutes === undefined
|
||||||
|
? 60
|
||||||
|
: Number(session.durationMinutes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建默认商品表单。 */
|
||||||
|
export function createDefaultSeckillProductForm(
|
||||||
|
product?: Partial<SeckillEditorProductForm>,
|
||||||
|
): SeckillEditorProductForm {
|
||||||
|
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),
|
||||||
|
seckillPrice:
|
||||||
|
product?.seckillPrice === null || product?.seckillPrice === undefined
|
||||||
|
? null
|
||||||
|
: Number(product.seckillPrice),
|
||||||
|
stockLimit:
|
||||||
|
product?.stockLimit === null || product?.stockLimit === undefined
|
||||||
|
? null
|
||||||
|
: Number(product.stockLimit),
|
||||||
|
perUserLimit:
|
||||||
|
product?.perUserLimit === null || product?.perUserLimit === undefined
|
||||||
|
? null
|
||||||
|
: Number(product.perUserLimit),
|
||||||
|
soldCount: Number(product?.soldCount ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建默认编辑表单。 */
|
||||||
|
export function createDefaultSeckillEditorForm(): SeckillEditorForm {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
activityType: 'timed',
|
||||||
|
validDateRange: null,
|
||||||
|
timeRange: null,
|
||||||
|
sessions: [
|
||||||
|
createDefaultSeckillSessionForm({
|
||||||
|
startTime: '10:00',
|
||||||
|
durationMinutes: 60,
|
||||||
|
}),
|
||||||
|
createDefaultSeckillSessionForm({
|
||||||
|
startTime: '14:00',
|
||||||
|
durationMinutes: 60,
|
||||||
|
}),
|
||||||
|
createDefaultSeckillSessionForm({
|
||||||
|
startTime: '18:00',
|
||||||
|
durationMinutes: 60,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
channels: ['delivery', 'pickup', 'dine_in'],
|
||||||
|
perUserLimit: null,
|
||||||
|
preheatEnabled: true,
|
||||||
|
preheatHours: 2,
|
||||||
|
products: [],
|
||||||
|
metrics: createEmptySeckillMetrics(),
|
||||||
|
status: 'active',
|
||||||
|
storeIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
import type {
|
||||||
|
SeckillCardViewModel,
|
||||||
|
SeckillFilterForm,
|
||||||
|
SeckillStatsViewModel,
|
||||||
|
} from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面数据读取动作。
|
||||||
|
*/
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getMarketingSeckillListApi } from '#/api/marketing';
|
||||||
|
import { getStoreListApi } from '#/api/store';
|
||||||
|
|
||||||
|
interface CreateDataActionsOptions {
|
||||||
|
filterForm: SeckillFilterForm;
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isStoreLoading: Ref<boolean>;
|
||||||
|
keyword: Ref<string>;
|
||||||
|
page: Ref<number>;
|
||||||
|
pageSize: Ref<number>;
|
||||||
|
rows: Ref<SeckillCardViewModel[]>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
stats: Ref<SeckillStatsViewModel>;
|
||||||
|
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 getMarketingSeckillListApi({
|
||||||
|
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(): SeckillStatsViewModel {
|
||||||
|
return {
|
||||||
|
totalCount: 0,
|
||||||
|
ongoingCount: 0,
|
||||||
|
monthlySeckillSalesCount: 0,
|
||||||
|
conversionRate: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MarketingSeckillActivityType,
|
||||||
|
MarketingSeckillChannel,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
import type {
|
||||||
|
SeckillEditorForm,
|
||||||
|
SeckillEditorProductForm,
|
||||||
|
} from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动主编辑抽屉动作。
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMarketingSeckillDetailApi,
|
||||||
|
saveMarketingSeckillApi,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDefaultSeckillEditorForm,
|
||||||
|
createDefaultSeckillSessionForm,
|
||||||
|
} from './constants';
|
||||||
|
import {
|
||||||
|
buildSaveSeckillPayload,
|
||||||
|
cloneProductForm,
|
||||||
|
cloneSessionForm,
|
||||||
|
mapDetailToEditorForm,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
interface CreateDrawerActionsOptions {
|
||||||
|
form: SeckillEditorForm;
|
||||||
|
isDrawerLoading: Ref<boolean>;
|
||||||
|
isDrawerOpen: Ref<boolean>;
|
||||||
|
isDrawerSubmitting: Ref<boolean>;
|
||||||
|
loadActivities: () => Promise<void>;
|
||||||
|
openPicker: (
|
||||||
|
pickerOptions: {
|
||||||
|
selectedProductIds: string[];
|
||||||
|
selectedProducts: SeckillEditorProductForm[];
|
||||||
|
storeId: string;
|
||||||
|
},
|
||||||
|
onConfirm: (products: SeckillEditorProductForm[]) => 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: SeckillEditorForm) {
|
||||||
|
options.form.id = next.id;
|
||||||
|
options.form.name = next.name;
|
||||||
|
options.form.activityType = next.activityType;
|
||||||
|
options.form.validDateRange = next.validDateRange;
|
||||||
|
options.form.timeRange = next.timeRange;
|
||||||
|
options.form.sessions = next.sessions.map((item) => cloneSessionForm(item));
|
||||||
|
options.form.channels = [...next.channels];
|
||||||
|
options.form.perUserLimit = next.perUserLimit;
|
||||||
|
options.form.preheatEnabled = next.preheatEnabled;
|
||||||
|
options.form.preheatHours = next.preheatHours;
|
||||||
|
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(createDefaultSeckillEditorForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormName(value: string) {
|
||||||
|
options.form.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivityType(value: MarketingSeckillActivityType) {
|
||||||
|
options.form.activityType = value;
|
||||||
|
if (value === 'hourly' && options.form.sessions.length === 0) {
|
||||||
|
options.form.sessions = [createDefaultSeckillSessionForm()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormValidDateRange(value: SeckillEditorForm['validDateRange']) {
|
||||||
|
options.form.validDateRange = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormTimeRange(value: SeckillEditorForm['timeRange']) {
|
||||||
|
options.form.timeRange = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSession() {
|
||||||
|
options.form.sessions = [
|
||||||
|
...options.form.sessions,
|
||||||
|
createDefaultSeckillSessionForm(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSession(index: number) {
|
||||||
|
options.form.sessions = options.form.sessions.filter(
|
||||||
|
(_, rowIndex) => rowIndex !== index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionStartTime(index: number, value: string) {
|
||||||
|
const row = options.form.sessions[index];
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.startTime = value;
|
||||||
|
options.form.sessions = [...options.form.sessions];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionDuration(index: number, value: null | number) {
|
||||||
|
const row = options.form.sessions[index];
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.durationMinutes = normalizeNullableInteger(value);
|
||||||
|
options.form.sessions = [...options.form.sessions];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormChannels(value: MarketingSeckillChannel[]) {
|
||||||
|
options.form.channels = [...new Set(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChannel(channel: MarketingSeckillChannel) {
|
||||||
|
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 setPreheatEnabled(value: boolean) {
|
||||||
|
options.form.preheatEnabled = value;
|
||||||
|
if (!value) {
|
||||||
|
options.form.preheatHours = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.form.preheatHours || options.form.preheatHours <= 0) {
|
||||||
|
options.form.preheatHours = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreheatHours(value: null | number) {
|
||||||
|
options.form.preheatHours = normalizeNullableInteger(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProductSeckillPrice(index: number, value: null | number) {
|
||||||
|
const row = options.form.products[index];
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.seckillPrice = normalizeNullableNumber(value);
|
||||||
|
options.form.products = [...options.form.products];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProductStockLimit(index: number, value: null | number) {
|
||||||
|
const row = options.form.products[index];
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.stockLimit = normalizeNullableInteger(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 getMarketingSeckillDetailApi({
|
||||||
|
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 saveMarketingSeckillApi(
|
||||||
|
buildSaveSeckillPayload(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.activityType === 'timed' &&
|
||||||
|
(!options.form.validDateRange || !options.form.timeRange)
|
||||||
|
) {
|
||||||
|
message.warning('请完整设置活动时间');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.form.activityType === 'hourly') {
|
||||||
|
if (options.form.sessions.length === 0) {
|
||||||
|
message.warning('请至少配置一个场次');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionTimeSet = new Set<string>();
|
||||||
|
for (const session of options.form.sessions) {
|
||||||
|
if (!isTimeText(session.startTime)) {
|
||||||
|
message.warning('场次时间格式必须为 HH:mm');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.durationMinutes || session.durationMinutes <= 0) {
|
||||||
|
message.warning('场次持续时长必须大于 0');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionTimeSet.has(session.startTime)) {
|
||||||
|
message.warning('场次时间不能重复');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sessionTimeSet.add(session.startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.preheatEnabled &&
|
||||||
|
(!options.form.preheatHours || options.form.preheatHours <= 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.seckillPrice || row.seckillPrice <= 0) {
|
||||||
|
message.warning(`商品「${row.name}」秒杀价必须大于 0`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.seckillPrice > row.originalPrice) {
|
||||||
|
message.warning(`商品「${row.name}」秒杀价不能高于原价`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!row.stockLimit || row.stockLimit <= 0) {
|
||||||
|
message.warning(`商品「${row.name}」限量必须大于 0`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.stockLimit < row.soldCount) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
addSession,
|
||||||
|
drawerMode,
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
openProductPicker,
|
||||||
|
removeProduct,
|
||||||
|
removeSession,
|
||||||
|
setActivityType,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormChannels,
|
||||||
|
setFormName,
|
||||||
|
setFormPerUserLimit,
|
||||||
|
setFormTimeRange,
|
||||||
|
setFormValidDateRange,
|
||||||
|
setPreheatEnabled,
|
||||||
|
setPreheatHours,
|
||||||
|
setProductPerUserLimit,
|
||||||
|
setProductSeckillPrice,
|
||||||
|
setProductStockLimit,
|
||||||
|
setSessionDuration,
|
||||||
|
setSessionStartTime,
|
||||||
|
submitDrawer,
|
||||||
|
toggleChannel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimeText(value: string) {
|
||||||
|
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test((value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
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.floor(numeric);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MarketingSeckillDetailDto,
|
||||||
|
MarketingSeckillEditorStatus,
|
||||||
|
SaveMarketingSeckillDto,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
import type {
|
||||||
|
SeckillCardViewModel,
|
||||||
|
SeckillEditorForm,
|
||||||
|
SeckillEditorProductForm,
|
||||||
|
SeckillEditorSessionForm,
|
||||||
|
} from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面纯函数工具。
|
||||||
|
*/
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDefaultSeckillEditorForm,
|
||||||
|
createDefaultSeckillProductForm,
|
||||||
|
createDefaultSeckillSessionForm,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留小数并去除尾零。 */
|
||||||
|
export function trimDecimal(value: number) {
|
||||||
|
return Number(value || 0)
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算进度百分比。 */
|
||||||
|
export function resolveProgressPercent(soldCount: number, stockLimit: number) {
|
||||||
|
if (stockLimit <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const percent = (soldCount / stockLimit) * 100;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(percent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据进度计算条颜色。 */
|
||||||
|
export function resolveProgressClass(percent: number) {
|
||||||
|
if (percent >= 100) {
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
if (percent >= 70) {
|
||||||
|
return 'orange';
|
||||||
|
}
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品限购文案。 */
|
||||||
|
export function resolveProductLimitText(limit: null | number) {
|
||||||
|
if (!limit || limit <= 0) {
|
||||||
|
return '不限';
|
||||||
|
}
|
||||||
|
return `${formatInteger(limit)}件/人`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活动时间文案。 */
|
||||||
|
export function resolveCardTimeText(item: SeckillCardViewModel) {
|
||||||
|
if (item.activityType === 'hourly') {
|
||||||
|
if (item.sessions.length === 0) {
|
||||||
|
return '场次未配置';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionText = item.sessions
|
||||||
|
.map((session) => `${session.startTime} 场`)
|
||||||
|
.join(' / ');
|
||||||
|
return `每天 ${sessionText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.displayStatus === 'upcoming' && item.startDate && item.timeStart) {
|
||||||
|
return `${item.startDate} ${item.timeStart} 开始`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.displayStatus === 'ended' && item.startDate && item.endDate) {
|
||||||
|
return `${item.startDate} ~ ${item.endDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.timeStart && item.timeEnd) {
|
||||||
|
return `每天 ${item.timeStart} - ${item.timeEnd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '时间待设置';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活动汇总数据。 */
|
||||||
|
export function resolveCardSummary(item: SeckillCardViewModel) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '参与人数',
|
||||||
|
value: `${formatInteger(item.metrics.participantCount)}人`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '成交',
|
||||||
|
value: `${formatInteger(item.metrics.dealCount)}单`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '转化率',
|
||||||
|
value: `${trimDecimal(item.metrics.conversionRate)}%`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 映射详情到编辑表单。 */
|
||||||
|
export function mapDetailToEditorForm(
|
||||||
|
detail: MarketingSeckillDetailDto,
|
||||||
|
): SeckillEditorForm {
|
||||||
|
const form = createDefaultSeckillEditorForm();
|
||||||
|
form.id = detail.id;
|
||||||
|
form.name = detail.name;
|
||||||
|
form.activityType = detail.activityType;
|
||||||
|
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.sessions = detail.sessions.map((item) =>
|
||||||
|
createDefaultSeckillSessionForm({
|
||||||
|
startTime: item.startTime,
|
||||||
|
durationMinutes: item.durationMinutes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form.channels = [...detail.channels];
|
||||||
|
form.perUserLimit = detail.perUserLimit;
|
||||||
|
form.preheatEnabled = detail.preheatEnabled;
|
||||||
|
form.preheatHours = detail.preheatHours;
|
||||||
|
form.products = detail.products.map((item) =>
|
||||||
|
createDefaultSeckillProductForm({
|
||||||
|
productId: item.productId,
|
||||||
|
categoryId: item.categoryId,
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
name: item.name,
|
||||||
|
spuCode: item.spuCode,
|
||||||
|
status: item.status,
|
||||||
|
originalPrice: item.originalPrice,
|
||||||
|
seckillPrice: item.seckillPrice,
|
||||||
|
stockLimit: item.stockLimit,
|
||||||
|
perUserLimit: item.perUserLimit,
|
||||||
|
soldCount: item.soldCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form.metrics = { ...detail.metrics };
|
||||||
|
form.status = detail.status;
|
||||||
|
form.storeIds = [...detail.storeIds];
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建保存请求。 */
|
||||||
|
export function buildSaveSeckillPayload(
|
||||||
|
form: SeckillEditorForm,
|
||||||
|
storeId: string,
|
||||||
|
): SaveMarketingSeckillDto {
|
||||||
|
const [startDate, endDate] = (form.validDateRange ?? []) as [Dayjs, Dayjs];
|
||||||
|
const [timeStart, timeEnd] = (form.timeRange ?? []) as [Dayjs, Dayjs];
|
||||||
|
const isTimedActivity = form.activityType === 'timed';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: form.id || undefined,
|
||||||
|
storeId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
activityType: form.activityType,
|
||||||
|
startDate:
|
||||||
|
isTimedActivity && form.validDateRange && startDate
|
||||||
|
? startDate.format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
endDate:
|
||||||
|
isTimedActivity && form.validDateRange && endDate
|
||||||
|
? endDate.format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
timeStart:
|
||||||
|
isTimedActivity && form.timeRange && timeStart
|
||||||
|
? timeStart.format('HH:mm')
|
||||||
|
: undefined,
|
||||||
|
timeEnd:
|
||||||
|
isTimedActivity && form.timeRange && timeEnd
|
||||||
|
? timeEnd.format('HH:mm')
|
||||||
|
: undefined,
|
||||||
|
sessions:
|
||||||
|
form.activityType === 'hourly'
|
||||||
|
? form.sessions
|
||||||
|
.filter((item) => item.startTime.trim())
|
||||||
|
.map((item) => ({
|
||||||
|
startTime: item.startTime,
|
||||||
|
durationMinutes: Number(item.durationMinutes ?? 0),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
channels: [...form.channels],
|
||||||
|
perUserLimit: form.perUserLimit,
|
||||||
|
preheatEnabled: form.preheatEnabled,
|
||||||
|
preheatHours: form.preheatEnabled ? form.preheatHours : null,
|
||||||
|
products: form.products.map((item) => ({
|
||||||
|
productId: item.productId,
|
||||||
|
seckillPrice: Number(item.seckillPrice || 0),
|
||||||
|
stockLimit: Number(item.stockLimit || 0),
|
||||||
|
perUserLimit: item.perUserLimit,
|
||||||
|
})),
|
||||||
|
metrics: { ...form.metrics },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深拷贝商品表单项。 */
|
||||||
|
export function cloneProductForm(
|
||||||
|
product: SeckillEditorProductForm,
|
||||||
|
): SeckillEditorProductForm {
|
||||||
|
return {
|
||||||
|
productId: product.productId,
|
||||||
|
categoryId: product.categoryId,
|
||||||
|
categoryName: product.categoryName,
|
||||||
|
name: product.name,
|
||||||
|
spuCode: product.spuCode,
|
||||||
|
status: product.status,
|
||||||
|
originalPrice: product.originalPrice,
|
||||||
|
seckillPrice: product.seckillPrice,
|
||||||
|
stockLimit: product.stockLimit,
|
||||||
|
perUserLimit: product.perUserLimit,
|
||||||
|
soldCount: product.soldCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深拷贝场次表单项。 */
|
||||||
|
export function cloneSessionForm(
|
||||||
|
session: SeckillEditorSessionForm,
|
||||||
|
): SeckillEditorSessionForm {
|
||||||
|
return {
|
||||||
|
key: session.key,
|
||||||
|
startTime: session.startTime,
|
||||||
|
durationMinutes: session.durationMinutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已结束。 */
|
||||||
|
export function isEndedStatus(status: MarketingSeckillEditorStatus) {
|
||||||
|
return status === 'completed';
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SeckillEditorProductForm,
|
||||||
|
SeckillPickerCategoryItem,
|
||||||
|
SeckillPickerProductItem,
|
||||||
|
} from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动商品选择二级抽屉动作。
|
||||||
|
*/
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMarketingSeckillPickerCategoriesApi,
|
||||||
|
getMarketingSeckillPickerProductsApi,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
|
||||||
|
import { createDefaultSeckillProductForm } from './constants';
|
||||||
|
|
||||||
|
interface OpenPickerOptions {
|
||||||
|
selectedProducts: SeckillEditorProductForm[];
|
||||||
|
selectedProductIds: string[];
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePickerActionsOptions {
|
||||||
|
isPickerLoading: Ref<boolean>;
|
||||||
|
isPickerOpen: Ref<boolean>;
|
||||||
|
pickerCategories: Ref<SeckillPickerCategoryItem[]>;
|
||||||
|
pickerCategoryFilterId: Ref<string>;
|
||||||
|
pickerKeyword: Ref<string>;
|
||||||
|
pickerProducts: Ref<SeckillPickerProductItem[]>;
|
||||||
|
pickerSelectedProductIds: Ref<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPickerActions(options: CreatePickerActionsOptions) {
|
||||||
|
let activeStoreId = '';
|
||||||
|
let selectedProductSnapshot = new Map<string, SeckillEditorProductForm>();
|
||||||
|
let onConfirmProducts:
|
||||||
|
| ((products: SeckillEditorProductForm[]) => 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 getMarketingSeckillPickerCategoriesApi({
|
||||||
|
storeId: activeStoreId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPickerProducts() {
|
||||||
|
if (!activeStoreId) {
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.pickerProducts.value = await getMarketingSeckillPickerProductsApi({
|
||||||
|
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: SeckillEditorProductForm[]) => void,
|
||||||
|
) {
|
||||||
|
if (!pickerOptions.storeId) {
|
||||||
|
message.warning('请先选择具体门店后再添加商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStoreId = pickerOptions.storeId;
|
||||||
|
onConfirmProducts = onConfirm;
|
||||||
|
selectedProductSnapshot = new Map(
|
||||||
|
pickerOptions.selectedProducts.map((item) => [
|
||||||
|
item.productId,
|
||||||
|
createDefaultSeckillProductForm(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,
|
||||||
|
createDefaultSeckillProductForm({
|
||||||
|
productId: item.id,
|
||||||
|
categoryId: item.categoryId,
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
name: item.name,
|
||||||
|
spuCode: item.spuCode,
|
||||||
|
status: item.status,
|
||||||
|
originalPrice: item.price,
|
||||||
|
seckillPrice: item.price,
|
||||||
|
stockLimit: null,
|
||||||
|
perUserLimit: null,
|
||||||
|
soldCount: 0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedProducts = [...selectedIds]
|
||||||
|
.map(
|
||||||
|
(productId) =>
|
||||||
|
currentProductMap.get(productId) ??
|
||||||
|
selectedProductSnapshot.get(productId),
|
||||||
|
)
|
||||||
|
.filter(Boolean) as SeckillEditorProductForm[];
|
||||||
|
|
||||||
|
if (selectedProducts.length === 0) {
|
||||||
|
message.warning('请至少选择一个商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirmProducts?.(selectedProducts);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openPicker,
|
||||||
|
reloadPickerList,
|
||||||
|
setPickerCategoryFilterId,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
setPickerSelectedProductIds,
|
||||||
|
submitPicker,
|
||||||
|
toggleAllProducts,
|
||||||
|
togglePickerProduct,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
import type {
|
||||||
|
SeckillCardViewModel,
|
||||||
|
SeckillPickerCategoryItem,
|
||||||
|
SeckillPickerProductItem,
|
||||||
|
SeckillStatsViewModel,
|
||||||
|
} from '#/views/marketing/seckill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面状态与行为编排。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { createCardActions } from './seckill-page/card-actions';
|
||||||
|
import {
|
||||||
|
createDefaultSeckillEditorForm,
|
||||||
|
createDefaultSeckillFilterForm,
|
||||||
|
SECKILL_STATUS_FILTER_OPTIONS,
|
||||||
|
} from './seckill-page/constants';
|
||||||
|
import {
|
||||||
|
createDataActions,
|
||||||
|
createEmptyStats,
|
||||||
|
} from './seckill-page/data-actions';
|
||||||
|
import { createDrawerActions } from './seckill-page/drawer-actions';
|
||||||
|
import { createPickerActions } from './seckill-page/picker-actions';
|
||||||
|
|
||||||
|
export function useMarketingSeckillPage() {
|
||||||
|
const stores = ref<StoreListItemDto[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const isStoreLoading = ref(false);
|
||||||
|
|
||||||
|
const filterForm = reactive(createDefaultSeckillFilterForm());
|
||||||
|
const keyword = ref('');
|
||||||
|
|
||||||
|
const rows = ref<SeckillCardViewModel[]>([]);
|
||||||
|
const stats = ref<SeckillStatsViewModel>(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(createDefaultSeckillEditorForm());
|
||||||
|
|
||||||
|
const isPickerOpen = ref(false);
|
||||||
|
const isPickerLoading = ref(false);
|
||||||
|
const pickerKeyword = ref('');
|
||||||
|
const pickerCategoryFilterId = ref('');
|
||||||
|
const pickerSelectedProductIds = ref<string[]>([]);
|
||||||
|
const pickerCategories = ref<SeckillPickerCategoryItem[]>([]);
|
||||||
|
const pickerProducts = ref<SeckillPickerProductItem[]>([]);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
addSession,
|
||||||
|
drawerMode,
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
openProductPicker,
|
||||||
|
removeProduct,
|
||||||
|
removeSession,
|
||||||
|
setActivityType,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormChannels,
|
||||||
|
setFormName,
|
||||||
|
setFormPerUserLimit,
|
||||||
|
setFormTimeRange,
|
||||||
|
setFormValidDateRange,
|
||||||
|
setPreheatEnabled,
|
||||||
|
setPreheatHours,
|
||||||
|
setProductPerUserLimit,
|
||||||
|
setProductSeckillPrice,
|
||||||
|
setProductStockLimit,
|
||||||
|
setSessionDuration,
|
||||||
|
setSessionStartTime,
|
||||||
|
submitDrawer,
|
||||||
|
toggleChannel,
|
||||||
|
} = 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: '' | SeckillCardViewModel['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 {
|
||||||
|
addSession,
|
||||||
|
applyFilters,
|
||||||
|
drawerSubmitText,
|
||||||
|
drawerTitle,
|
||||||
|
filterForm,
|
||||||
|
form,
|
||||||
|
SECKILL_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,
|
||||||
|
reloadPickerList,
|
||||||
|
removeActivity,
|
||||||
|
removeProduct,
|
||||||
|
removeSession,
|
||||||
|
resetFilters,
|
||||||
|
rows,
|
||||||
|
selectedStoreId,
|
||||||
|
setActivityType,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormChannels,
|
||||||
|
setFormName,
|
||||||
|
setFormPerUserLimit,
|
||||||
|
setFormTimeRange,
|
||||||
|
setFormValidDateRange,
|
||||||
|
setKeyword: setKeywordValue,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
setPickerSelectedProductIds,
|
||||||
|
setPreheatEnabled,
|
||||||
|
setPreheatHours,
|
||||||
|
setProductPerUserLimit,
|
||||||
|
setProductSeckillPrice,
|
||||||
|
setProductStockLimit,
|
||||||
|
setSelectedStoreId,
|
||||||
|
setSessionDuration,
|
||||||
|
setSessionStartTime,
|
||||||
|
setStatusFilter,
|
||||||
|
stats,
|
||||||
|
storeNameMap,
|
||||||
|
storeOptions,
|
||||||
|
submitDrawer,
|
||||||
|
submitPicker,
|
||||||
|
toggleActivityStatus,
|
||||||
|
toggleAllProducts,
|
||||||
|
toggleChannel,
|
||||||
|
togglePickerProduct,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
212
apps/web-antd/src/views/marketing/seckill/index.vue
Normal file
212
apps/web-antd/src/views/marketing/seckill/index.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:营销中心-秒杀活动页面。
|
||||||
|
*/
|
||||||
|
import type { MarketingSeckillDisplayStatus } from '#/api/marketing';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Empty, Input, Pagination, Select, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import SeckillActivityCard from './components/SeckillActivityCard.vue';
|
||||||
|
import SeckillEditorDrawer from './components/SeckillEditorDrawer.vue';
|
||||||
|
import SeckillProductPickerDrawer from './components/SeckillProductPickerDrawer.vue';
|
||||||
|
import SeckillStatsCards from './components/SeckillStatsCards.vue';
|
||||||
|
import { useMarketingSeckillPage } from './composables/useMarketingSeckillPage';
|
||||||
|
|
||||||
|
const {
|
||||||
|
addSession,
|
||||||
|
applyFilters,
|
||||||
|
drawerSubmitText,
|
||||||
|
drawerTitle,
|
||||||
|
filterForm,
|
||||||
|
form,
|
||||||
|
SECKILL_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,
|
||||||
|
removeActivity,
|
||||||
|
removeProduct,
|
||||||
|
removeSession,
|
||||||
|
resetFilters,
|
||||||
|
rows,
|
||||||
|
selectedStoreId,
|
||||||
|
setActivityType,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormName,
|
||||||
|
setFormPerUserLimit,
|
||||||
|
setFormTimeRange,
|
||||||
|
setFormValidDateRange,
|
||||||
|
setKeyword,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
setSelectedStoreId,
|
||||||
|
setStatusFilter,
|
||||||
|
setPreheatEnabled,
|
||||||
|
setPreheatHours,
|
||||||
|
setProductPerUserLimit,
|
||||||
|
setProductSeckillPrice,
|
||||||
|
setProductStockLimit,
|
||||||
|
setSessionDuration,
|
||||||
|
setSessionStartTime,
|
||||||
|
stats,
|
||||||
|
storeOptions,
|
||||||
|
submitDrawer,
|
||||||
|
submitPicker,
|
||||||
|
toggleActivityStatus,
|
||||||
|
toggleAllProducts,
|
||||||
|
toggleChannel,
|
||||||
|
togglePickerProduct,
|
||||||
|
total,
|
||||||
|
} = useMarketingSeckillPage();
|
||||||
|
|
||||||
|
function onStatusFilterChange(value: unknown) {
|
||||||
|
const next =
|
||||||
|
typeof value === 'string' && value
|
||||||
|
? (value as MarketingSeckillDisplayStatus)
|
||||||
|
: '';
|
||||||
|
setStatusFilter(next);
|
||||||
|
void applyFilters();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page title="秒杀活动" content-class="page-marketing-seckill">
|
||||||
|
<div class="sk-page">
|
||||||
|
<div class="sk-toolbar">
|
||||||
|
<Select
|
||||||
|
class="sk-store-select"
|
||||||
|
:value="selectedStoreId"
|
||||||
|
:options="storeOptions"
|
||||||
|
:loading="isStoreLoading"
|
||||||
|
placeholder="全部门店"
|
||||||
|
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
class="sk-filter-select"
|
||||||
|
:value="filterForm.status"
|
||||||
|
:options="SECKILL_STATUS_FILTER_OPTIONS"
|
||||||
|
placeholder="全部状态"
|
||||||
|
@update:value="onStatusFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
class="sk-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="sk-spacer"></span>
|
||||||
|
<Button type="primary" @click="openCreateDrawer">创建秒杀</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SeckillStatsCards v-if="hasStore" :stats="stats" />
|
||||||
|
|
||||||
|
<div v-if="!hasStore" class="sk-empty">暂无门店,请先创建门店</div>
|
||||||
|
|
||||||
|
<Spin v-else :spinning="isLoading">
|
||||||
|
<div v-if="rows.length > 0" class="sk-list">
|
||||||
|
<SeckillActivityCard
|
||||||
|
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="sk-empty">
|
||||||
|
<Empty description="暂无秒杀活动" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rows.length > 0" class="sk-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>
|
||||||
|
|
||||||
|
<SeckillEditorDrawer
|
||||||
|
:open="isDrawerOpen"
|
||||||
|
:title="drawerTitle"
|
||||||
|
:submit-text="drawerSubmitText"
|
||||||
|
:submitting="isDrawerSubmitting"
|
||||||
|
:loading="isDrawerLoading"
|
||||||
|
:form="form"
|
||||||
|
@close="setDrawerOpen(false)"
|
||||||
|
@set-name="setFormName"
|
||||||
|
@set-activity-type="setActivityType"
|
||||||
|
@set-valid-date-range="setFormValidDateRange"
|
||||||
|
@set-time-range="setFormTimeRange"
|
||||||
|
@add-session="addSession"
|
||||||
|
@remove-session="removeSession"
|
||||||
|
@set-session-start-time="setSessionStartTime"
|
||||||
|
@set-session-duration="setSessionDuration"
|
||||||
|
@toggle-channel="toggleChannel"
|
||||||
|
@set-per-user-limit="setFormPerUserLimit"
|
||||||
|
@set-preheat-enabled="setPreheatEnabled"
|
||||||
|
@set-preheat-hours="setPreheatHours"
|
||||||
|
@open-product-picker="openProductPicker"
|
||||||
|
@remove-product="removeProduct"
|
||||||
|
@set-product-seckill-price="setProductSeckillPrice"
|
||||||
|
@set-product-stock-limit="setProductStockLimit"
|
||||||
|
@set-product-per-user-limit="setProductPerUserLimit"
|
||||||
|
@submit="submitDrawer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SeckillProductPickerDrawer
|
||||||
|
: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>
|
||||||
29
apps/web-antd/src/views/marketing/seckill/styles/base.less
Normal file
29
apps/web-antd/src/views/marketing/seckill/styles/base.less
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面基础样式变量。
|
||||||
|
*/
|
||||||
|
.page-marketing-seckill {
|
||||||
|
--sk-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--sk-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
|
||||||
|
--sk-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%);
|
||||||
|
--sk-border: #e7eaf0;
|
||||||
|
--sk-text: #1f2937;
|
||||||
|
--sk-subtext: #6b7280;
|
||||||
|
--sk-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
214
apps/web-antd/src/views/marketing/seckill/styles/card.less
Normal file
214
apps/web-antd/src/views/marketing/seckill/styles/card.less
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动卡片样式。
|
||||||
|
*/
|
||||||
|
.page-marketing-seckill {
|
||||||
|
.sk-card {
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sk-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--sk-shadow-sm);
|
||||||
|
transition: box-shadow var(--sk-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card:hover {
|
||||||
|
box-shadow: var(--sk-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card.ended {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-hd {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-time {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sk-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-time .iconify {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-tag-running {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22c55e;
|
||||||
|
background: #dcfce7;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-tag-ended {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-tag-notstarted {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #f0f5ff;
|
||||||
|
border: 1px solid #adc6ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-list {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-row:hover {
|
||||||
|
background: color-mix(in srgb, #1677ff 3%, #fff);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 140px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-prices {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-orig-price {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sk-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-seckill-price {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-fill.green {
|
||||||
|
background: linear-gradient(90deg, #52c41a, #73d13d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-fill.orange {
|
||||||
|
background: linear-gradient(90deg, #fa8c16, #ffa940);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-fill.red {
|
||||||
|
background: linear-gradient(90deg, #f5222d, #ff4d4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-text {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 2px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-sold-out {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 3px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f5222d;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #fff1f0;
|
||||||
|
border: 1px solid #ffa39e;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-sold-out .iconify {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-summary strong {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-ft {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
apps/web-antd/src/views/marketing/seckill/styles/drawer.less
Normal file
274
apps/web-antd/src/views/marketing/seckill/styles/drawer.less
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动主编辑抽屉样式。
|
||||||
|
*/
|
||||||
|
.sk-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-pill-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-pill:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #91caff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-pill.checked {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-range-picker {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-time {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-duration {
|
||||||
|
width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-remove {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-session-remove:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-drawer-prod {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-drawer-prod-hd {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-drawer-prod-hd span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-drawer-prod-remove:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-drawer-prod-bd {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-field label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-field .ant-input,
|
||||||
|
.sk-field .ant-input-number {
|
||||||
|
width: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-limit-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-limit-row .ant-input-number {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-unit {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-preheat-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-preheat-row .ant-input-number {
|
||||||
|
width: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-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';
|
||||||
91
apps/web-antd/src/views/marketing/seckill/styles/layout.less
Normal file
91
apps/web-antd/src/views/marketing/seckill/styles/layout.less
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面布局样式。
|
||||||
|
*/
|
||||||
|
.page-marketing-seckill {
|
||||||
|
.sk-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sk-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--sk-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-store-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-filter-select {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-store-select .ant-select-selector,
|
||||||
|
.sk-filter-select .ant-select-selector {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sk-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--sk-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-stats span {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-stats strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-empty {
|
||||||
|
padding: 28px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sk-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--sk-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 4px 2px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/web-antd/src/views/marketing/seckill/styles/picker.less
Normal file
145
apps/web-antd/src/views/marketing/seckill/styles/picker.less
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动商品选择二级抽屉样式。
|
||||||
|
*/
|
||||||
|
.sk-product-picker-drawer {
|
||||||
|
.ant-drawer-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-footer {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,48 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面响应式样式。
|
||||||
|
*/
|
||||||
|
.page-marketing-seckill {
|
||||||
|
@media (width <= 1200px) {
|
||||||
|
.sk-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.sk-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-hd {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-prod-name,
|
||||||
|
.sk-prod-prices {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-progress-wrap {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-card-summary {
|
||||||
|
gap: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-stats {
|
||||||
|
gap: 12px 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/web-antd/src/views/marketing/seckill/types.ts
Normal file
79
apps/web-antd/src/views/marketing/seckill/types.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MarketingSeckillActivityType,
|
||||||
|
MarketingSeckillChannel,
|
||||||
|
MarketingSeckillDisplayStatus,
|
||||||
|
MarketingSeckillEditorStatus,
|
||||||
|
MarketingSeckillListItemDto,
|
||||||
|
MarketingSeckillMetricsDto,
|
||||||
|
MarketingSeckillPickerCategoryItemDto,
|
||||||
|
MarketingSeckillPickerProductItemDto,
|
||||||
|
MarketingSeckillProductStatus,
|
||||||
|
MarketingSeckillSessionDto,
|
||||||
|
MarketingSeckillStatsDto,
|
||||||
|
} from '#/api/marketing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:秒杀活动页面类型定义。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 列表筛选表单。 */
|
||||||
|
export interface SeckillFilterForm {
|
||||||
|
status: '' | MarketingSeckillDisplayStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽屉场次表单项。 */
|
||||||
|
export interface SeckillEditorSessionForm {
|
||||||
|
durationMinutes: null | number;
|
||||||
|
key: string;
|
||||||
|
startTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽屉商品表单项。 */
|
||||||
|
export interface SeckillEditorProductForm {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
name: string;
|
||||||
|
originalPrice: number;
|
||||||
|
perUserLimit: null | number;
|
||||||
|
productId: string;
|
||||||
|
seckillPrice: null | number;
|
||||||
|
soldCount: number;
|
||||||
|
spuCode: string;
|
||||||
|
status: MarketingSeckillProductStatus;
|
||||||
|
stockLimit: null | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主编辑抽屉表单。 */
|
||||||
|
export interface SeckillEditorForm {
|
||||||
|
activityType: MarketingSeckillActivityType;
|
||||||
|
channels: MarketingSeckillChannel[];
|
||||||
|
id: string;
|
||||||
|
metrics: MarketingSeckillMetricsDto;
|
||||||
|
name: string;
|
||||||
|
perUserLimit: null | number;
|
||||||
|
preheatEnabled: boolean;
|
||||||
|
preheatHours: null | number;
|
||||||
|
products: SeckillEditorProductForm[];
|
||||||
|
sessions: SeckillEditorSessionForm[];
|
||||||
|
status: MarketingSeckillEditorStatus;
|
||||||
|
storeIds: string[];
|
||||||
|
timeRange: [Dayjs, Dayjs] | null;
|
||||||
|
validDateRange: [Dayjs, Dayjs] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表卡片视图模型。 */
|
||||||
|
export type SeckillCardViewModel = MarketingSeckillListItemDto;
|
||||||
|
|
||||||
|
/** 统计视图模型。 */
|
||||||
|
export type SeckillStatsViewModel = MarketingSeckillStatsDto;
|
||||||
|
|
||||||
|
/** 选品分类项。 */
|
||||||
|
export type SeckillPickerCategoryItem = MarketingSeckillPickerCategoryItemDto;
|
||||||
|
|
||||||
|
/** 选品商品项。 */
|
||||||
|
export type SeckillPickerProductItem = MarketingSeckillPickerProductItemDto;
|
||||||
|
|
||||||
|
/** 场次规则项。 */
|
||||||
|
export type SeckillSessionItem = MarketingSeckillSessionDto;
|
||||||
Reference in New Issue
Block a user