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 './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;
|
||||
}>();
|
||||
|
||||
function onDateRangeChange(value: [Dayjs, Dayjs] | null) {
|
||||
emit('setValidDateRange', value);
|
||||
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 onTimeRangeChange(value: [Dayjs, Dayjs] | null) {
|
||||
emit('setTimeRange', value);
|
||||
function onDateRangeChange(value: RangePickerValue) {
|
||||
emit('setValidDateRange', normalizeDayjsRange(value));
|
||||
}
|
||||
|
||||
function onTimeRangeChange(value: RangePickerValue) {
|
||||
emit('setTimeRange', normalizeDayjsRange(value));
|
||||
}
|
||||
|
||||
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