feat(project): implement tenant seckill page and drawers
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s

This commit is contained in:
2026-03-02 13:10:18 +08:00
parent d81b670148
commit 9920f2e32c
23 changed files with 3700 additions and 4 deletions

View File

@@ -185,3 +185,4 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) {
export * from './flash-sale';
export * from './full-reduction';
export * from './seckill';

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,91 @@
/**
* 文件职责:秒杀活动页面布局样式。
*/
.page-marketing-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;
}
}

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

View File

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

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