Compare commits

..

2 Commits

Author SHA1 Message Date
be0a8e6914 feat: implement marketing punch card management page
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
2026-03-02 21:43:47 +08:00
0e043ddd79 feat(@vben/web-antd): implement new customer gift page and drawers 2026-03-02 15:58:53 +08:00
45 changed files with 7124 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,213 @@
/**
* 文件职责:营销中心新客有礼 API 与 DTO 定义。
* 1. 维护新客有礼详情、配置保存、邀请记录分页与写入契约。
*/
import { requestClient } from '#/api/request';
/** 礼包类型。 */
export type MarketingNewCustomerGiftType = 'coupon' | 'direct';
/** 券场景。 */
export type MarketingNewCustomerCouponScene = 'invitee' | 'inviter' | 'welcome';
/** 券类型。 */
export type MarketingNewCustomerCouponType =
| 'amount_off'
| 'discount'
| 'free_shipping';
/** 分享渠道。 */
export type MarketingNewCustomerShareChannel =
| 'moments'
| 'sms'
| 'wechat_friend';
/** 邀请订单状态。 */
export type MarketingNewCustomerInviteOrderStatus = 'ordered' | 'pending_order';
/** 邀请奖励状态。 */
export type MarketingNewCustomerInviteRewardStatus = 'issued' | 'pending';
/** 优惠券规则。 */
export interface MarketingNewCustomerCouponRuleDto {
couponType: MarketingNewCustomerCouponType;
id: string;
minimumSpend: null | number;
scene: MarketingNewCustomerCouponScene;
sortOrder: number;
validDays: number;
value: null | number;
}
/** 新客有礼设置。 */
export interface MarketingNewCustomerSettingsDto {
directMinimumSpend: null | number;
directReduceAmount: null | number;
giftEnabled: boolean;
giftType: MarketingNewCustomerGiftType;
inviteEnabled: boolean;
inviteeCoupons: MarketingNewCustomerCouponRuleDto[];
inviterCoupons: MarketingNewCustomerCouponRuleDto[];
shareChannels: MarketingNewCustomerShareChannel[];
storeId: string;
updatedAt: string;
welcomeCoupons: MarketingNewCustomerCouponRuleDto[];
}
/** 统计数据。 */
export interface MarketingNewCustomerStatsDto {
firstOrderConversionRate: number;
firstOrderedCount: number;
giftClaimRate: number;
giftClaimedCount: number;
monthlyGrowthCount: number;
monthlyGrowthRatePercent: number;
monthlyNewCustomers: number;
}
/** 邀请记录。 */
export interface MarketingNewCustomerInviteRecordDto {
id: string;
inviteeName: string;
inviteTime: string;
inviterName: string;
orderStatus: MarketingNewCustomerInviteOrderStatus;
rewardIssuedAt: null | string;
rewardStatus: MarketingNewCustomerInviteRewardStatus;
sourceChannel?: string;
}
/** 邀请记录分页。 */
export interface MarketingNewCustomerInviteRecordListResultDto {
items: MarketingNewCustomerInviteRecordDto[];
page: number;
pageSize: number;
totalCount: number;
}
/** 新客有礼详情。 */
export interface MarketingNewCustomerDetailDto {
inviteRecords: MarketingNewCustomerInviteRecordListResultDto;
settings: MarketingNewCustomerSettingsDto;
stats: MarketingNewCustomerStatsDto;
}
/** 新客有礼详情查询。 */
export interface MarketingNewCustomerDetailQuery {
recordPage?: number;
recordPageSize?: number;
storeId: string;
}
/** 邀请记录查询。 */
export interface MarketingNewCustomerInviteRecordListQuery {
page: number;
pageSize: number;
storeId: string;
}
/** 保存券规则项。 */
export interface SaveMarketingNewCustomerCouponRuleDto {
couponType: MarketingNewCustomerCouponType;
minimumSpend: null | number;
validDays: number;
value: null | number;
}
/** 保存新客有礼配置请求。 */
export interface SaveMarketingNewCustomerSettingsDto {
directMinimumSpend: null | number;
directReduceAmount: null | number;
giftEnabled: boolean;
giftType: MarketingNewCustomerGiftType;
inviteEnabled: boolean;
inviteeCoupons: SaveMarketingNewCustomerCouponRuleDto[];
inviterCoupons: SaveMarketingNewCustomerCouponRuleDto[];
shareChannels: MarketingNewCustomerShareChannel[];
storeId: string;
welcomeCoupons: SaveMarketingNewCustomerCouponRuleDto[];
}
/** 写入邀请记录请求。 */
export interface WriteMarketingNewCustomerInviteRecordDto {
inviteeName: string;
inviteTime: string;
inviterName: string;
orderStatus: MarketingNewCustomerInviteOrderStatus;
rewardIssuedAt: null | string;
rewardStatus: MarketingNewCustomerInviteRewardStatus;
sourceChannel?: string;
storeId: string;
}
/** 新客成长记录。 */
export interface MarketingNewCustomerGrowthRecordDto {
customerKey: string;
customerName?: string;
firstOrderAt: null | string;
giftClaimedAt: null | string;
id: string;
registeredAt: string;
sourceChannel?: string;
}
/** 写入成长记录请求。 */
export interface WriteMarketingNewCustomerGrowthRecordDto {
customerKey: string;
customerName?: string;
firstOrderAt: null | string;
giftClaimedAt: null | string;
registeredAt: string;
sourceChannel?: string;
storeId: string;
}
/** 获取新客有礼详情。 */
export async function getMarketingNewCustomerDetailApi(
params: MarketingNewCustomerDetailQuery,
) {
return requestClient.get<MarketingNewCustomerDetailDto>(
'/marketing/new-customer/detail',
{ params },
);
}
/** 保存新客有礼配置。 */
export async function saveMarketingNewCustomerSettingsApi(
data: SaveMarketingNewCustomerSettingsDto,
) {
return requestClient.post<MarketingNewCustomerSettingsDto>(
'/marketing/new-customer/save',
data,
);
}
/** 获取邀请记录分页。 */
export async function getMarketingNewCustomerInviteRecordListApi(
params: MarketingNewCustomerInviteRecordListQuery,
) {
return requestClient.get<MarketingNewCustomerInviteRecordListResultDto>(
'/marketing/new-customer/invite-record/list',
{ params },
);
}
/** 写入邀请记录。 */
export async function writeMarketingNewCustomerInviteRecordApi(
data: WriteMarketingNewCustomerInviteRecordDto,
) {
return requestClient.post<MarketingNewCustomerInviteRecordDto>(
'/marketing/new-customer/invite-record/write',
data,
);
}
/** 写入成长记录。 */
export async function writeMarketingNewCustomerGrowthRecordApi(
data: WriteMarketingNewCustomerGrowthRecordDto,
) {
return requestClient.post<MarketingNewCustomerGrowthRecordDto>(
'/marketing/new-customer/growth-record/write',
data,
);
}

View File

@@ -0,0 +1,334 @@
/**
* 文件职责:营销中心次卡管理 API 与 DTO 定义。
* 1. 维护次卡列表、详情、保存、状态、删除、使用记录及导出契约。
*/
import { requestClient } from '#/api/request';
/** 次卡状态。 */
export type MarketingPunchCardStatus = 'disabled' | 'enabled';
/** 有效期类型。 */
export type MarketingPunchCardValidityType = 'days' | 'range';
/** 适用范围类型。 */
export type MarketingPunchCardScopeType =
| 'all'
| 'category'
| 'product'
| 'tag';
/** 使用模式。 */
export type MarketingPunchCardUsageMode = 'cap' | 'free';
/** 过期策略。 */
export type MarketingPunchCardExpireStrategy = 'invalidate' | 'refund';
/** 使用记录展示状态。 */
export type MarketingPunchCardUsageDisplayStatus =
| 'almost_used_up'
| 'expired'
| 'normal'
| 'used_up';
/** 使用记录筛选状态。 */
export type MarketingPunchCardUsageFilterStatus =
| 'expired'
| 'normal'
| 'used_up';
/** 次卡范围。 */
export interface MarketingPunchCardScopeDto {
categoryIds: string[];
productIds: string[];
scopeType: MarketingPunchCardScopeType;
tagIds: string[];
}
/** 次卡模板统计。 */
export interface MarketingPunchCardStatsDto {
activeInUseCount: number;
onSaleCount: number;
totalRevenueAmount: number;
totalSoldCount: number;
}
/** 次卡列表项。 */
export interface MarketingPunchCardListItemDto {
activeCount: number;
coverImageUrl?: string;
dailyLimit: null | number;
id: string;
isDimmed: boolean;
name: string;
originalPrice: null | number;
revenueAmount: number;
salePrice: number;
scopeType: MarketingPunchCardScopeType;
soldCount: number;
status: MarketingPunchCardStatus;
totalTimes: number;
updatedAt: string;
usageCapAmount: null | number;
usageMode: MarketingPunchCardUsageMode;
validitySummary: string;
}
/** 次卡列表结果。 */
export interface MarketingPunchCardListResultDto {
items: MarketingPunchCardListItemDto[];
page: number;
pageSize: number;
stats: MarketingPunchCardStatsDto;
totalCount: number;
}
/** 次卡详情。 */
export interface MarketingPunchCardDetailDto {
activeCount: number;
allowTransfer: boolean;
coverImageUrl?: string;
dailyLimit: null | number;
description?: string;
expireStrategy: MarketingPunchCardExpireStrategy;
id: string;
name: string;
notifyChannels: string[];
originalPrice: null | number;
perOrderLimit: null | number;
perUserPurchaseLimit: null | number;
revenueAmount: number;
salePrice: number;
scope: MarketingPunchCardScopeDto;
soldCount: number;
status: MarketingPunchCardStatus;
storeId: string;
totalTimes: number;
updatedAt: string;
usageCapAmount: null | number;
usageMode: MarketingPunchCardUsageMode;
validityDays: null | number;
validityType: MarketingPunchCardValidityType;
validFrom: null | string;
validTo: null | string;
}
/** 保存次卡请求。 */
export interface SaveMarketingPunchCardDto {
allowTransfer: boolean;
coverImageUrl?: string;
dailyLimit: null | number;
description?: string;
expireStrategy: MarketingPunchCardExpireStrategy;
id?: string;
name: string;
notifyChannels: string[];
originalPrice: null | number;
perOrderLimit: null | number;
perUserPurchaseLimit: null | number;
salePrice: number;
scopeCategoryIds: string[];
scopeProductIds: string[];
scopeTagIds: string[];
scopeType: MarketingPunchCardScopeType;
storeId: string;
totalTimes: number;
usageCapAmount: null | number;
usageMode: MarketingPunchCardUsageMode;
validityDays: null | number;
validityType: MarketingPunchCardValidityType;
validFrom: null | string;
validTo: null | string;
}
/** 修改状态请求。 */
export interface ChangeMarketingPunchCardStatusDto {
punchCardId: string;
status: MarketingPunchCardStatus;
storeId: string;
}
/** 删除次卡请求。 */
export interface DeleteMarketingPunchCardDto {
punchCardId: string;
storeId: string;
}
/** 次卡列表查询。 */
export interface MarketingPunchCardListQuery {
keyword?: string;
page: number;
pageSize: number;
status?: '' | MarketingPunchCardStatus;
storeId: string;
}
/** 次卡详情查询。 */
export interface MarketingPunchCardDetailQuery {
punchCardId: string;
storeId: string;
}
/** 使用记录统计。 */
export interface MarketingPunchCardUsageStatsDto {
expiringSoonCount: number;
monthUsedCount: number;
todayUsedCount: number;
}
/** 次卡选项。 */
export interface MarketingPunchCardTemplateOptionDto {
name: string;
templateId: string;
}
/** 使用记录项。 */
export interface MarketingPunchCardUsageRecordDto {
displayStatus: MarketingPunchCardUsageDisplayStatus;
extraPayAmount: null | number;
id: string;
memberName: string;
memberPhoneMasked: string;
productName: string;
punchCardId: string;
punchCardInstanceId: string;
punchCardName: string;
recordNo: string;
remainingTimesAfterUse: number;
totalTimes: number;
usedAt: string;
usedTimes: number;
}
/** 使用记录列表结果。 */
export interface MarketingPunchCardUsageRecordListResultDto {
items: MarketingPunchCardUsageRecordDto[];
page: number;
pageSize: number;
stats: MarketingPunchCardUsageStatsDto;
templateOptions: MarketingPunchCardTemplateOptionDto[];
totalCount: number;
}
/** 使用记录查询参数。 */
export interface MarketingPunchCardUsageRecordListQuery {
keyword?: string;
page: number;
pageSize: number;
punchCardId?: string;
status?: '' | MarketingPunchCardUsageFilterStatus;
storeId: string;
}
/** 使用记录导出参数。 */
export interface ExportMarketingPunchCardUsageRecordQuery {
keyword?: string;
punchCardId?: string;
status?: '' | MarketingPunchCardUsageFilterStatus;
storeId: string;
}
/** 使用记录导出回执。 */
export interface MarketingPunchCardUsageRecordExportDto {
fileContentBase64: string;
fileName: string;
totalCount: number;
}
/** 写入使用记录请求。 */
export interface WriteMarketingPunchCardUsageRecordDto {
extraPayAmount: null | number;
memberName?: string;
memberPhoneMasked?: string;
productName: string;
punchCardId: string;
punchCardInstanceId?: string;
punchCardInstanceNo?: string;
storeId: string;
usedAt?: string;
usedTimes: number;
}
/** 查询次卡列表。 */
export async function getMarketingPunchCardListApi(
params: MarketingPunchCardListQuery,
) {
return requestClient.get<MarketingPunchCardListResultDto>(
'/marketing/punch-card/list',
{
params,
},
);
}
/** 查询次卡详情。 */
export async function getMarketingPunchCardDetailApi(
params: MarketingPunchCardDetailQuery,
) {
return requestClient.get<MarketingPunchCardDetailDto>(
'/marketing/punch-card/detail',
{
params,
},
);
}
/** 保存次卡。 */
export async function saveMarketingPunchCardApi(
data: SaveMarketingPunchCardDto,
) {
return requestClient.post<MarketingPunchCardDetailDto>(
'/marketing/punch-card/save',
data,
);
}
/** 修改次卡状态。 */
export async function changeMarketingPunchCardStatusApi(
data: ChangeMarketingPunchCardStatusDto,
) {
return requestClient.post<MarketingPunchCardDetailDto>(
'/marketing/punch-card/status',
data,
);
}
/** 删除次卡。 */
export async function deleteMarketingPunchCardApi(
data: DeleteMarketingPunchCardDto,
) {
return requestClient.post('/marketing/punch-card/delete', data);
}
/** 查询使用记录。 */
export async function getMarketingPunchCardUsageRecordListApi(
params: MarketingPunchCardUsageRecordListQuery,
) {
return requestClient.get<MarketingPunchCardUsageRecordListResultDto>(
'/marketing/punch-card/usage-record/list',
{
params,
},
);
}
/** 导出使用记录。 */
export async function exportMarketingPunchCardUsageRecordApi(
params: ExportMarketingPunchCardUsageRecordQuery,
) {
return requestClient.get<MarketingPunchCardUsageRecordExportDto>(
'/marketing/punch-card/usage-record/export',
{
params,
},
);
}
/** 写入使用记录。 */
export async function writeMarketingPunchCardUsageRecordApi(
data: WriteMarketingPunchCardUsageRecordDto,
) {
return requestClient.post<MarketingPunchCardUsageRecordDto>(
'/marketing/punch-card/usage-record/write',
data,
);
}

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
/**
* 文件职责:新客有礼券添加抽屉。
* 1. 提供券类型、门槛、有效期编辑能力。
*/
import type { NewCustomerCouponDrawerForm } from '../types';
import type {
MarketingNewCustomerCouponScene,
MarketingNewCustomerCouponType,
} from '#/api/marketing';
import { IconifyIcon } from '@vben/icons';
import { Button, Drawer, Form, InputNumber, Radio } from 'ant-design-vue';
import {
NEW_CUSTOMER_COUPON_SCENE_TITLE_MAP,
NEW_CUSTOMER_COUPON_TYPE_OPTIONS,
} from '../composables/new-customer-page/constants';
defineProps<{
form: NewCustomerCouponDrawerForm;
open: boolean;
scene: MarketingNewCustomerCouponScene;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'setCouponType', value: MarketingNewCustomerCouponType): void;
(event: 'setMinimumSpend', value: null | number): void;
(event: 'setValidDays', value: null | number): void;
(event: 'setValue', value: null | number): void;
(event: 'submit'): void;
}>();
function parseNullableNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function onCouponTypeChange(value: unknown) {
if (
value === 'amount_off' ||
value === 'discount' ||
value === 'free_shipping'
) {
emit('setCouponType', value);
}
}
</script>
<template>
<Drawer
:open="open"
:title="`添加优惠券 · ${NEW_CUSTOMER_COUPON_SCENE_TITLE_MAP[scene]}`"
width="480"
class="mnc-coupon-drawer"
@close="emit('close')"
>
<Form layout="vertical" class="mnc-coupon-drawer-form">
<Form.Item label="券类型" required>
<Radio.Group
:value="form.couponType"
button-style="solid"
@update:value="onCouponTypeChange"
>
<Radio.Button
v-for="item in NEW_CUSTOMER_COUPON_TYPE_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
v-if="form.couponType === 'amount_off'"
label="减免金额"
required
>
<div class="mnc-inline-fields">
<span>¥</span>
<InputNumber
:value="form.value ?? undefined"
:min="0.01"
:precision="2"
placeholder="如5"
@update:value="
(value) => emit('setValue', parseNullableNumber(value))
"
/>
</div>
</Form.Item>
<Form.Item v-if="form.couponType === 'discount'" label="折扣" required>
<div class="mnc-inline-fields">
<InputNumber
:value="form.value ?? undefined"
:min="0.1"
:max="9.99"
:precision="2"
placeholder="如8"
@update:value="
(value) => emit('setValue', parseNullableNumber(value))
"
/>
<span></span>
</div>
</Form.Item>
<Form.Item v-if="form.couponType === 'free_shipping'" label="券说明">
<div class="mnc-shipping-tip">
<IconifyIcon icon="lucide:truck" />
免配送费券无需设置面额
</div>
</Form.Item>
<Form.Item label="使用门槛">
<div class="mnc-inline-fields">
<span>订单满</span>
<InputNumber
:value="form.minimumSpend ?? undefined"
:min="0"
:precision="2"
placeholder="如30"
@update:value="
(value) => emit('setMinimumSpend', parseNullableNumber(value))
"
/>
<span>元可用</span>
</div>
<div class="mnc-field-hint">留空或填 0 表示无门槛</div>
</Form.Item>
<Form.Item label="有效期" required>
<div class="mnc-inline-fields">
<span>领取后</span>
<InputNumber
:value="form.validDays"
:min="1"
:max="365"
:precision="0"
placeholder="如7"
@update:value="
(value) => emit('setValidDays', parseNullableNumber(value))
"
/>
<span></span>
</div>
</Form.Item>
</Form>
<template #footer>
<div class="mnc-coupon-drawer-footer">
<Button @click="emit('close')">取消</Button>
<Button type="primary" @click="emit('submit')">添加</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
/**
* 文件职责:新客有礼券组渲染组件。
* 1. 展示券列表、删除与添加入口。
*/
import type { NewCustomerCouponFormItem } from '../types';
import { IconifyIcon } from '@vben/icons';
import { Button, Empty } from 'ant-design-vue';
import {
formatCouponDescription,
formatCouponName,
} from '../composables/new-customer-page/helpers';
defineProps<{
addText: string;
canManage: boolean;
coupons: NewCustomerCouponFormItem[];
description?: string;
title: string;
}>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'remove', index: number): void;
}>();
</script>
<template>
<div class="mnc-coupon-group">
<div class="mnc-coupon-group-title">
{{ title }}
<span v-if="description" class="mnc-coupon-group-desc">{{
description
}}</span>
</div>
<div v-if="coupons.length > 0" class="mnc-coupon-list">
<div
v-for="(item, index) in coupons"
:key="`${item.id}-${index}`"
class="mnc-coupon-item"
>
<div class="mnc-coupon-info">
<div class="mnc-coupon-name">{{ formatCouponName(item) }}</div>
<div class="mnc-coupon-desc">{{ formatCouponDescription(item) }}</div>
</div>
<div class="mnc-coupon-validity">
<IconifyIcon icon="lucide:clock-3" />
领取后{{ item.validDays }}
</div>
<Button
v-if="canManage"
type="text"
danger
class="mnc-coupon-remove"
@click="emit('remove', index)"
>
<IconifyIcon icon="lucide:trash-2" />
</Button>
</div>
</div>
<Empty v-else class="mnc-coupon-empty" description="暂未添加优惠券" />
<button
v-if="canManage"
type="button"
class="mnc-add-coupon-btn"
@click="emit('add')"
>
<IconifyIcon icon="lucide:plus" />
{{ addText }}
</button>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* 文件职责:新客有礼邀请记录表格。
* 1. 渲染邀请记录状态与分页。
*/
import type { NewCustomerInviteRecordViewModel } from '../types';
import { Table } from 'ant-design-vue';
import {
NEW_CUSTOMER_ORDER_STATUS_TEXT_MAP,
NEW_CUSTOMER_REWARD_STATUS_TEXT_MAP,
} from '../composables/new-customer-page/constants';
defineProps<{
loading: boolean;
page: number;
pageSize: number;
records: NewCustomerInviteRecordViewModel[];
total: number;
}>();
const emit = defineEmits<{
(event: 'pageChange', page: number, pageSize: number): void;
}>();
const columns = [
{
title: '邀请人',
dataIndex: 'inviterName',
key: 'inviterName',
},
{
title: '被邀请人',
dataIndex: 'inviteeName',
key: 'inviteeName',
},
{
title: '邀请时间',
dataIndex: 'inviteTime',
key: 'inviteTime',
},
{
title: '状态',
dataIndex: 'orderStatus',
key: 'orderStatus',
},
{
title: '奖励发放',
dataIndex: 'rewardStatus',
key: 'rewardStatus',
},
];
function handleTableChange(pagination: {
current?: number;
pageSize?: number;
}) {
emit('pageChange', pagination.current ?? 1, pagination.pageSize ?? 10);
}
function resolveOrderStatusText(status: unknown) {
if (status === 'ordered' || status === 'pending_order') {
return NEW_CUSTOMER_ORDER_STATUS_TEXT_MAP[status];
}
return NEW_CUSTOMER_ORDER_STATUS_TEXT_MAP.pending_order;
}
function resolveRewardStatusText(status: unknown) {
if (status === 'issued' || status === 'pending') {
return NEW_CUSTOMER_REWARD_STATUS_TEXT_MAP[status];
}
return NEW_CUSTOMER_REWARD_STATUS_TEXT_MAP.pending;
}
</script>
<template>
<Table
:loading="loading"
:columns="columns"
:data-source="records"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (value) => `共 ${value} 条`,
}"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'orderStatus'">
<span
class="mnc-status-tag"
:class="record.orderStatus === 'ordered' ? 'is-green' : 'is-orange'"
>
{{ resolveOrderStatusText(record.orderStatus) }}
</span>
</template>
<template v-else-if="column.key === 'rewardStatus'">
<span
class="mnc-status-tag"
:class="record.rewardStatus === 'issued' ? 'is-green' : 'is-gray'"
>
{{ resolveRewardStatusText(record.rewardStatus) }}
</span>
</template>
</template>
</Table>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
/**
* 文件职责:新客有礼统计卡片。
* 1. 展示本月新客、礼包领取率、新客转化率三项指标。
*/
import type { NewCustomerStatsViewModel } from '../types';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
formatInteger,
formatPercent,
} from '../composables/new-customer-page/helpers';
const props = defineProps<{
stats: NewCustomerStatsViewModel;
}>();
const growthIsUp = computed(() => props.stats.monthlyGrowthCount >= 0);
</script>
<template>
<div class="mnc-stats">
<div class="mnc-stat-card">
<div class="mnc-stat-label">
<IconifyIcon icon="lucide:user-plus" />
本月新客
</div>
<div class="mnc-stat-value">
{{ formatInteger(stats.monthlyNewCustomers) }}
<span class="mnc-stat-unit"></span>
<span
class="mnc-stat-trend"
:class="{ down: !growthIsUp, up: growthIsUp }"
>
<IconifyIcon
:icon="growthIsUp ? 'lucide:trending-up' : 'lucide:trending-down'"
/>
{{ growthIsUp ? '+' : ''
}}{{ formatPercent(stats.monthlyGrowthRatePercent) }}%
</span>
</div>
<div class="mnc-stat-sub">
较上月{{ stats.monthlyGrowthCount >= 0 ? '增长' : '减少' }}
{{ formatInteger(Math.abs(stats.monthlyGrowthCount)) }}
</div>
</div>
<div class="mnc-stat-card">
<div class="mnc-stat-label">
<IconifyIcon icon="lucide:ticket" />
礼包领取率
</div>
<div class="mnc-stat-value">
{{ formatPercent(stats.giftClaimRate) }}
<span class="mnc-stat-unit">%</span>
</div>
<div class="mnc-stat-sub">
{{ formatInteger(stats.monthlyNewCustomers) }} 人中
{{ formatInteger(stats.giftClaimedCount) }} 人已领取
</div>
</div>
<div class="mnc-stat-card">
<div class="mnc-stat-label">
<IconifyIcon icon="lucide:shopping-bag" />
新客转化率
</div>
<div class="mnc-stat-value">
{{ formatPercent(stats.firstOrderConversionRate) }}
<span class="mnc-stat-unit">%</span>
</div>
<div class="mnc-stat-sub">
首单完成率{{ formatInteger(stats.firstOrderedCount) }} 人已下单
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,131 @@
import type {
MarketingNewCustomerCouponScene,
MarketingNewCustomerCouponType,
MarketingNewCustomerGiftType,
MarketingNewCustomerInviteOrderStatus,
MarketingNewCustomerInviteRewardStatus,
MarketingNewCustomerShareChannel,
} from '#/api/marketing';
import type {
NewCustomerCouponDrawerForm,
NewCustomerInviteRecordPager,
NewCustomerPageForm,
NewCustomerStatsViewModel,
} from '#/views/marketing/new-customer/types';
/**
* 文件职责:新客有礼页面常量与默认值。
*/
/** 查看权限码。 */
export const NEW_CUSTOMER_VIEW_PERMISSION =
'tenant:marketing:new-customer:view';
/** 管理权限码。 */
export const NEW_CUSTOMER_MANAGE_PERMISSION =
'tenant:marketing:new-customer:manage';
/** 礼包类型选项。 */
export const NEW_CUSTOMER_GIFT_TYPE_OPTIONS: Array<{
label: string;
value: MarketingNewCustomerGiftType;
}> = [
{ label: '优惠券包', value: 'coupon' },
{ label: '首单直减', value: 'direct' },
];
/** 分享渠道选项。 */
export const NEW_CUSTOMER_SHARE_CHANNEL_OPTIONS: Array<{
label: string;
value: MarketingNewCustomerShareChannel;
}> = [
{ label: '微信好友', value: 'wechat_friend' },
{ label: '朋友圈', value: 'moments' },
{ label: '短信', value: 'sms' },
];
/** 券类型选项。 */
export const NEW_CUSTOMER_COUPON_TYPE_OPTIONS: Array<{
label: string;
value: MarketingNewCustomerCouponType;
}> = [
{ label: '满减券', value: 'amount_off' },
{ label: '折扣券', value: 'discount' },
{ label: '免配送费', value: 'free_shipping' },
];
/** 券场景标题。 */
export const NEW_CUSTOMER_COUPON_SCENE_TITLE_MAP: Record<
MarketingNewCustomerCouponScene,
string
> = {
welcome: '新客礼包券',
inviter: '邀请人奖励券',
invitee: '被邀请人奖励券',
};
/** 邀请记录订单状态文案。 */
export const NEW_CUSTOMER_ORDER_STATUS_TEXT_MAP: Record<
MarketingNewCustomerInviteOrderStatus,
string
> = {
pending_order: '待下单',
ordered: '已下单',
};
/** 邀请记录奖励状态文案。 */
export const NEW_CUSTOMER_REWARD_STATUS_TEXT_MAP: Record<
MarketingNewCustomerInviteRewardStatus,
string
> = {
pending: '待触发',
issued: '已发放',
};
/** 创建默认页面表单。 */
export function createDefaultNewCustomerPageForm(): NewCustomerPageForm {
return {
giftEnabled: true,
giftType: 'coupon',
directReduceAmount: 10,
directMinimumSpend: 30,
inviteEnabled: true,
shareChannels: ['wechat_friend', 'moments'],
welcomeCoupons: [],
inviterCoupons: [],
inviteeCoupons: [],
};
}
/** 创建默认统计。 */
export function createDefaultNewCustomerStats(): NewCustomerStatsViewModel {
return {
monthlyNewCustomers: 0,
monthlyGrowthCount: 0,
monthlyGrowthRatePercent: 0,
giftClaimRate: 0,
giftClaimedCount: 0,
firstOrderConversionRate: 0,
firstOrderedCount: 0,
};
}
/** 创建默认邀请记录分页。 */
export function createDefaultNewCustomerInvitePager(): NewCustomerInviteRecordPager {
return {
items: [],
page: 1,
pageSize: 10,
totalCount: 0,
};
}
/** 创建默认券抽屉表单。 */
export function createDefaultNewCustomerCouponDrawerForm(): NewCustomerCouponDrawerForm {
return {
couponType: 'amount_off',
value: null,
minimumSpend: null,
validDays: 7,
};
}

View File

@@ -0,0 +1,158 @@
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import type {
NewCustomerInviteRecordPager,
NewCustomerPageForm,
NewCustomerStatsViewModel,
} from '#/views/marketing/new-customer/types';
/**
* 文件职责:新客有礼页面数据加载动作。
* 1. 加载门店、详情与邀请记录分页。
*/
import { message } from 'ant-design-vue';
import {
getMarketingNewCustomerDetailApi,
getMarketingNewCustomerInviteRecordListApi,
} from '#/api/marketing';
import { getStoreListApi } from '#/api/store';
import {
createDefaultNewCustomerInvitePager,
createDefaultNewCustomerStats,
} from './constants';
import { mapSettingsToPageForm } from './helpers';
interface CreateDataActionsOptions {
form: NewCustomerPageForm;
invitePager: Ref<NewCustomerInviteRecordPager>;
isDetailLoading: Ref<boolean>;
isInviteLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
selectedStoreId: Ref<string>;
stats: Ref<NewCustomerStatsViewModel>;
stores: Ref<StoreListItemDto[]>;
updatedAt: Ref<string>;
}
export function createDataActions(options: CreateDataActionsOptions) {
function applyForm(next: NewCustomerPageForm) {
options.form.giftEnabled = next.giftEnabled;
options.form.giftType = next.giftType;
options.form.directReduceAmount = next.directReduceAmount;
options.form.directMinimumSpend = next.directMinimumSpend;
options.form.inviteEnabled = next.inviteEnabled;
options.form.shareChannels = [...next.shareChannels];
options.form.welcomeCoupons = [...next.welcomeCoupons];
options.form.inviterCoupons = [...next.inviterCoupons];
options.form.inviteeCoupons = [...next.inviteeCoupons];
}
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 = '';
return;
}
const exists = options.stores.value.some(
(item) => item.id === options.selectedStoreId.value,
);
if (!exists) {
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
options.isStoreLoading.value = false;
}
}
async function loadDetail(resetForm: boolean) {
if (!options.selectedStoreId.value) {
options.stats.value = createDefaultNewCustomerStats();
options.invitePager.value = createDefaultNewCustomerInvitePager();
options.updatedAt.value = '';
return;
}
options.isDetailLoading.value = true;
try {
const detail = await getMarketingNewCustomerDetailApi({
storeId: options.selectedStoreId.value,
recordPage: options.invitePager.value.page,
recordPageSize: options.invitePager.value.pageSize,
});
options.stats.value = detail.stats;
options.updatedAt.value = detail.settings.updatedAt;
if (resetForm) {
applyForm(mapSettingsToPageForm(detail.settings));
}
options.invitePager.value = {
items: detail.inviteRecords.items ?? [],
page: detail.inviteRecords.page,
pageSize: detail.inviteRecords.pageSize,
totalCount: detail.inviteRecords.totalCount,
};
} catch (error) {
console.error(error);
options.stats.value = createDefaultNewCustomerStats();
options.invitePager.value = createDefaultNewCustomerInvitePager();
message.error('加载新客有礼失败');
} finally {
options.isDetailLoading.value = false;
}
}
async function loadInviteRecords() {
if (!options.selectedStoreId.value) {
options.invitePager.value = createDefaultNewCustomerInvitePager();
return;
}
options.isInviteLoading.value = true;
try {
const result = await getMarketingNewCustomerInviteRecordListApi({
storeId: options.selectedStoreId.value,
page: options.invitePager.value.page,
pageSize: options.invitePager.value.pageSize,
});
options.invitePager.value = {
items: result.items ?? [],
page: result.page,
pageSize: result.pageSize,
totalCount: result.totalCount,
};
} catch (error) {
console.error(error);
options.invitePager.value = {
...options.invitePager.value,
items: [],
totalCount: 0,
};
message.error('加载邀请记录失败');
} finally {
options.isInviteLoading.value = false;
}
}
return {
loadDetail,
loadInviteRecords,
loadStores,
};
}

View File

@@ -0,0 +1,172 @@
import type { ComputedRef } from 'vue';
import type { MarketingNewCustomerCouponScene } from '#/api/marketing';
import type {
NewCustomerCouponDrawerForm,
NewCustomerPageForm,
} from '#/views/marketing/new-customer/types';
/**
* 文件职责:新客有礼券抽屉动作。
* 1. 管理抽屉开关、字段更新与添加券行为。
*/
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { createDefaultNewCustomerCouponDrawerForm } from './constants';
import { normalizeCouponSort, toNullableNumber } from './helpers';
interface CreateDrawerActionsOptions {
canManage: ComputedRef<boolean>;
form: NewCustomerPageForm;
}
export function createDrawerActions(options: CreateDrawerActionsOptions) {
const isCouponDrawerOpen = ref(false);
const couponDrawerScene = ref<MarketingNewCustomerCouponScene>('welcome');
const couponDrawerForm = reactive<NewCustomerCouponDrawerForm>(
createDefaultNewCustomerCouponDrawerForm(),
);
function setCouponDrawerOpen(value: boolean) {
isCouponDrawerOpen.value = value;
}
function setCouponDrawerType(
value: NewCustomerCouponDrawerForm['couponType'],
) {
couponDrawerForm.couponType = value;
if (value === 'free_shipping') {
couponDrawerForm.value = null;
}
}
function setCouponDrawerValue(value: null | number) {
couponDrawerForm.value = value;
}
function setCouponDrawerMinimumSpend(value: null | number) {
couponDrawerForm.minimumSpend = value;
}
function setCouponDrawerValidDays(value: null | number) {
couponDrawerForm.validDays = Math.max(1, Math.min(365, Number(value || 1)));
}
function openCouponDrawer(scene: MarketingNewCustomerCouponScene) {
if (!options.canManage.value) {
return;
}
resetCouponDrawerForm();
couponDrawerScene.value = scene;
isCouponDrawerOpen.value = true;
}
function closeCouponDrawer() {
isCouponDrawerOpen.value = false;
}
function submitCouponDrawer() {
if (!validateBeforeSubmit()) {
return;
}
const next = {
id: `draft-${Date.now()}-${Math.round(Math.random() * 1000)}`,
scene: couponDrawerScene.value,
couponType: couponDrawerForm.couponType,
value:
couponDrawerForm.couponType === 'free_shipping'
? null
: toNullableNumber(couponDrawerForm.value),
minimumSpend: toNullableNumber(couponDrawerForm.minimumSpend),
validDays: Math.max(
1,
Math.min(365, Math.floor(couponDrawerForm.validDays)),
),
sortOrder: 0,
};
if (couponDrawerScene.value === 'welcome') {
options.form.welcomeCoupons = normalizeCouponSort('welcome', [
...options.form.welcomeCoupons,
next,
]);
} else if (couponDrawerScene.value === 'inviter') {
options.form.inviterCoupons = normalizeCouponSort('inviter', [
...options.form.inviterCoupons,
next,
]);
} else {
options.form.inviteeCoupons = normalizeCouponSort('invitee', [
...options.form.inviteeCoupons,
next,
]);
}
isCouponDrawerOpen.value = false;
}
function validateBeforeSubmit() {
const minimumSpend = toNullableNumber(couponDrawerForm.minimumSpend) ?? 0;
if (minimumSpend < 0) {
message.warning('使用门槛不能小于 0');
return false;
}
if (couponDrawerForm.validDays < 1 || couponDrawerForm.validDays > 365) {
message.warning('有效期必须在 1-365 天之间');
return false;
}
if (couponDrawerForm.couponType === 'free_shipping') {
return true;
}
const value = toNullableNumber(couponDrawerForm.value);
if (value === null || value <= 0) {
message.warning('请填写正确的券面额');
return false;
}
if (couponDrawerForm.couponType === 'discount' && value >= 10) {
message.warning('折扣券面额必须小于 10');
return false;
}
if (
couponDrawerForm.couponType === 'amount_off' &&
minimumSpend > 0 &&
value >= minimumSpend
) {
message.warning('满减券减免金额必须小于使用门槛');
return false;
}
return true;
}
function resetCouponDrawerForm() {
const next = createDefaultNewCustomerCouponDrawerForm();
couponDrawerForm.couponType = next.couponType;
couponDrawerForm.value = next.value;
couponDrawerForm.minimumSpend = next.minimumSpend;
couponDrawerForm.validDays = next.validDays;
}
return {
closeCouponDrawer,
couponDrawerForm,
couponDrawerScene,
isCouponDrawerOpen,
openCouponDrawer,
setCouponDrawerMinimumSpend,
setCouponDrawerOpen,
setCouponDrawerType,
setCouponDrawerValidDays,
setCouponDrawerValue,
submitCouponDrawer,
};
}

View File

@@ -0,0 +1,176 @@
import type { ComputedRef, Ref } from 'vue';
import type { MarketingNewCustomerCouponScene } from '#/api/marketing';
import type { NewCustomerPageForm } from '#/views/marketing/new-customer/types';
/**
* 文件职责:新客有礼页面编辑与保存动作。
* 1. 管理主表单字段更新。
* 2. 负责保存与重置。
*/
import { message } from 'ant-design-vue';
import { saveMarketingNewCustomerSettingsApi } from '#/api/marketing';
import { buildSaveSettingsPayload, normalizeCouponSort } from './helpers';
interface CreateEditorActionsOptions {
canManage: ComputedRef<boolean>;
form: NewCustomerPageForm;
isSaving: Ref<boolean>;
loadDetail: (resetForm: boolean) => Promise<void>;
selectedStoreId: Ref<string>;
}
export function createEditorActions(options: CreateEditorActionsOptions) {
function setGiftEnabled(value: boolean) {
options.form.giftEnabled = value;
}
function setGiftType(value: NewCustomerPageForm['giftType']) {
options.form.giftType = value;
}
function setDirectReduceAmount(value: null | number) {
options.form.directReduceAmount = value;
}
function setDirectMinimumSpend(value: null | number) {
options.form.directMinimumSpend = value;
}
function setInviteEnabled(value: boolean) {
options.form.inviteEnabled = value;
}
function toggleShareChannel(
channel: NewCustomerPageForm['shareChannels'][number],
) {
if (options.form.shareChannels.includes(channel)) {
options.form.shareChannels = options.form.shareChannels.filter(
(item) => item !== channel,
);
return;
}
options.form.shareChannels = [...options.form.shareChannels, channel];
}
function removeCoupon(scene: MarketingNewCustomerCouponScene, index: number) {
const list = resolveCouponList(scene).filter((_, idx) => idx !== index);
applyCouponList(scene, list);
}
async function resetSettings() {
await options.loadDetail(true);
}
async function saveSettings() {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
return;
}
if (!validateBeforeSubmit()) {
return;
}
options.isSaving.value = true;
try {
await saveMarketingNewCustomerSettingsApi(
buildSaveSettingsPayload(options.form, options.selectedStoreId.value),
);
message.success('新客有礼设置已保存');
await options.loadDetail(true);
} catch (error) {
console.error(error);
} finally {
options.isSaving.value = false;
}
}
function validateBeforeSubmit() {
if (options.form.shareChannels.length === 0) {
message.warning('请至少选择一个分享渠道');
return false;
}
if (
options.form.giftType === 'coupon' &&
options.form.welcomeCoupons.length === 0
) {
message.warning('新客礼包至少需要添加一张优惠券');
return false;
}
if (options.form.giftType === 'direct') {
if (
options.form.directReduceAmount === null ||
options.form.directReduceAmount <= 0
) {
message.warning('首单直减金额必须大于 0');
return false;
}
if (
options.form.directMinimumSpend === null ||
options.form.directMinimumSpend < 0
) {
message.warning('首单直减使用门槛不能小于 0');
return false;
}
}
if (
options.form.inviteEnabled &&
(options.form.inviterCoupons.length === 0 ||
options.form.inviteeCoupons.length === 0)
) {
message.warning('开启老带新后必须配置邀请人和被邀请人奖励券');
return false;
}
return true;
}
function resolveCouponList(scene: MarketingNewCustomerCouponScene) {
if (scene === 'welcome') {
return options.form.welcomeCoupons;
}
if (scene === 'inviter') {
return options.form.inviterCoupons;
}
return options.form.inviteeCoupons;
}
function applyCouponList(
scene: MarketingNewCustomerCouponScene,
list: NewCustomerPageForm['welcomeCoupons'],
) {
const normalized = normalizeCouponSort(scene, list);
if (scene === 'welcome') {
options.form.welcomeCoupons = normalized;
return;
}
if (scene === 'inviter') {
options.form.inviterCoupons = normalized;
return;
}
options.form.inviteeCoupons = normalized;
}
return {
removeCoupon,
resetSettings,
saveSettings,
setDirectMinimumSpend,
setDirectReduceAmount,
setGiftEnabled,
setGiftType,
setInviteEnabled,
toggleShareChannel,
};
}

View File

@@ -0,0 +1,156 @@
import type {
MarketingNewCustomerCouponScene,
MarketingNewCustomerSettingsDto,
SaveMarketingNewCustomerCouponRuleDto,
SaveMarketingNewCustomerSettingsDto,
} from '#/api/marketing';
import type {
NewCustomerCouponFormItem,
NewCustomerPageForm,
} from '#/views/marketing/new-customer/types';
import { toCouponFormItem } from '#/views/marketing/new-customer/types';
/**
* 文件职责:新客有礼页面纯函数工具。
*/
/** 格式化百分比。 */
export function formatPercent(value: number) {
return Number(value || 0).toFixed(1);
}
/** 格式化整数。 */
export function formatInteger(value: number) {
return Math.trunc(Number(value || 0)).toLocaleString('zh-CN');
}
/** 格式化金额。 */
export function formatMoney(value: null | number) {
if (value === null || value === undefined) {
return '0';
}
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/** 生成券标题。 */
export function formatCouponName(item: NewCustomerCouponFormItem) {
if (item.couponType === 'free_shipping') {
return '免配送费';
}
if (item.couponType === 'discount') {
return `${formatMoney(item.minimumSpend)}${formatMoney(item.value)}`;
}
return `${formatMoney(item.minimumSpend)}${formatMoney(item.value)}`;
}
/** 生成券描述。 */
export function formatCouponDescription(item: NewCustomerCouponFormItem) {
if (item.couponType === 'free_shipping') {
return '无门槛,减免配送费';
}
if (!item.minimumSpend || item.minimumSpend <= 0) {
return '无门槛可用';
}
return `订单满 ${formatMoney(item.minimumSpend)} 元可用`;
}
/** 重建排序。 */
export function normalizeCouponSort(
scene: MarketingNewCustomerCouponScene,
items: NewCustomerCouponFormItem[],
) {
return items.map((item, index) => ({
...item,
scene,
sortOrder: index + 1,
}));
}
/** 拷贝券列表。 */
export function cloneCouponItems(items: NewCustomerCouponFormItem[]) {
return items.map((item) => ({ ...item }));
}
/** 应用后端设置到页面表单。 */
export function mapSettingsToPageForm(
settings: MarketingNewCustomerSettingsDto,
): NewCustomerPageForm {
return {
giftEnabled: settings.giftEnabled,
giftType: settings.giftType,
directReduceAmount: settings.directReduceAmount,
directMinimumSpend: settings.directMinimumSpend,
inviteEnabled: settings.inviteEnabled,
shareChannels: [...settings.shareChannels],
welcomeCoupons: normalizeCouponSort(
'welcome',
settings.welcomeCoupons.map((item) => toCouponFormItem(item)),
),
inviterCoupons: normalizeCouponSort(
'inviter',
settings.inviterCoupons.map((item) => toCouponFormItem(item)),
),
inviteeCoupons: normalizeCouponSort(
'invitee',
settings.inviteeCoupons.map((item) => toCouponFormItem(item)),
),
};
}
/** 构建保存请求。 */
export function buildSaveSettingsPayload(
form: NewCustomerPageForm,
storeId: string,
): SaveMarketingNewCustomerSettingsDto {
return {
storeId,
giftEnabled: form.giftEnabled,
giftType: form.giftType,
directReduceAmount:
form.giftType === 'direct'
? toNullableNumber(form.directReduceAmount)
: null,
directMinimumSpend:
form.giftType === 'direct'
? toNullableNumber(form.directMinimumSpend)
: null,
inviteEnabled: form.inviteEnabled,
shareChannels: [...form.shareChannels],
welcomeCoupons: buildSaveCouponRules(form.welcomeCoupons),
inviterCoupons: buildSaveCouponRules(form.inviterCoupons),
inviteeCoupons: buildSaveCouponRules(form.inviteeCoupons),
};
}
/** 构建保存券列表。 */
export function buildSaveCouponRules(
items: NewCustomerCouponFormItem[],
): SaveMarketingNewCustomerCouponRuleDto[] {
return items.map((item) => ({
couponType: item.couponType,
value:
item.couponType === 'free_shipping' ? null : toNullableNumber(item.value),
minimumSpend: toNullableNumber(item.minimumSpend),
validDays: Math.max(1, Math.min(365, Math.floor(item.validDays))),
}));
}
/** 解析可空数字。 */
export function toNullableNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return null;
}
return parsed;
}

View File

@@ -0,0 +1,170 @@
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:新客有礼页面状态与行为编排。
* 1. 管理门店、统计、配置表单与邀请记录分页状态。
* 2. 编排保存动作与优惠券抽屉动作。
*/
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useAccessStore } from '@vben/stores';
import {
createDefaultNewCustomerInvitePager,
createDefaultNewCustomerPageForm,
createDefaultNewCustomerStats,
NEW_CUSTOMER_MANAGE_PERMISSION,
} from './new-customer-page/constants';
import { createDataActions } from './new-customer-page/data-actions';
import { createDrawerActions } from './new-customer-page/drawer-actions';
import { createEditorActions } from './new-customer-page/editor-actions';
export function useMarketingNewCustomerPage() {
const accessStore = useAccessStore();
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const isDetailLoading = ref(false);
const isInviteLoading = ref(false);
const isSaving = ref(false);
const form = reactive(createDefaultNewCustomerPageForm());
const stats = ref(createDefaultNewCustomerStats());
const invitePager = ref(createDefaultNewCustomerInvitePager());
const updatedAt = ref('');
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const hasStore = computed(() => stores.value.length > 0);
const accessCodeSet = computed(
() => new Set((accessStore.accessCodes ?? []).map(String)),
);
const canManage = computed(() =>
accessCodeSet.value.has(NEW_CUSTOMER_MANAGE_PERMISSION),
);
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setInvitePagerPage(page: number) {
invitePager.value = {
...invitePager.value,
page,
};
}
function setInvitePagerPageSize(pageSize: number) {
invitePager.value = {
...invitePager.value,
pageSize,
};
}
const { loadStores, loadDetail, loadInviteRecords } = createDataActions({
stores,
selectedStoreId,
isStoreLoading,
isDetailLoading,
isInviteLoading,
form,
stats,
invitePager,
updatedAt,
});
const {
removeCoupon,
resetSettings,
saveSettings,
setDirectMinimumSpend,
setDirectReduceAmount,
setGiftEnabled,
setGiftType,
setInviteEnabled,
toggleShareChannel,
} = createEditorActions({
canManage,
form,
isSaving,
loadDetail,
selectedStoreId,
});
const {
closeCouponDrawer,
couponDrawerForm,
couponDrawerScene,
isCouponDrawerOpen,
openCouponDrawer,
setCouponDrawerMinimumSpend,
setCouponDrawerType,
setCouponDrawerValidDays,
setCouponDrawerValue,
submitCouponDrawer,
} = createDrawerActions({
canManage,
form,
});
async function handleInvitePageChange(page: number, pageSize: number) {
setInvitePagerPage(page);
setInvitePagerPageSize(pageSize);
await loadInviteRecords();
}
watch(selectedStoreId, async () => {
invitePager.value = {
...invitePager.value,
page: 1,
};
await loadDetail(true);
});
onMounted(async () => {
await loadStores();
});
return {
canManage,
closeCouponDrawer,
couponDrawerForm,
couponDrawerScene,
form,
handleInvitePageChange,
hasStore,
invitePager,
isCouponDrawerOpen,
isDetailLoading,
isInviteLoading,
isSaving,
isStoreLoading,
openCouponDrawer,
removeCoupon,
resetSettings,
saveSettings,
selectedStoreId,
setCouponDrawerMinimumSpend,
setCouponDrawerType,
setCouponDrawerValidDays,
setCouponDrawerValue,
setDirectMinimumSpend,
setDirectReduceAmount,
setGiftEnabled,
setGiftType,
setInviteEnabled,
setSelectedStoreId,
stats,
storeOptions,
submitCouponDrawer,
toggleShareChannel,
updatedAt,
};
}

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
/**
* 文件职责:营销中心-新客有礼页面主视图。
* 1. 还原原型布局(工具栏、横幅、统计、配置区、邀请区、底部保存栏)。
* 2. 编排新客券二级抽屉与页面保存行为。
*/
import type { MarketingNewCustomerCouponScene } from '#/api/marketing';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
InputNumber,
Select,
Spin,
Switch,
} from 'ant-design-vue';
import NewCustomerCouponDrawer from './components/NewCustomerCouponDrawer.vue';
import NewCustomerCouponGroup from './components/NewCustomerCouponGroup.vue';
import NewCustomerInviteRecordTable from './components/NewCustomerInviteRecordTable.vue';
import NewCustomerStatsCards from './components/NewCustomerStatsCards.vue';
import {
NEW_CUSTOMER_GIFT_TYPE_OPTIONS,
NEW_CUSTOMER_SHARE_CHANNEL_OPTIONS,
} from './composables/new-customer-page/constants';
import { useMarketingNewCustomerPage } from './composables/useMarketingNewCustomerPage';
const {
canManage,
closeCouponDrawer,
couponDrawerForm,
couponDrawerScene,
form,
handleInvitePageChange,
hasStore,
invitePager,
isCouponDrawerOpen,
isDetailLoading,
isInviteLoading,
isSaving,
isStoreLoading,
openCouponDrawer,
removeCoupon,
resetSettings,
saveSettings,
selectedStoreId,
setCouponDrawerMinimumSpend,
setCouponDrawerType,
setCouponDrawerValidDays,
setCouponDrawerValue,
setDirectMinimumSpend,
setDirectReduceAmount,
setGiftEnabled,
setGiftType,
setInviteEnabled,
setSelectedStoreId,
stats,
storeOptions,
submitCouponDrawer,
toggleShareChannel,
updatedAt,
} = useMarketingNewCustomerPage();
function parseNullableNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function openSceneCouponDrawer(scene: MarketingNewCustomerCouponScene) {
openCouponDrawer(scene);
}
</script>
<template>
<Page title="新客有礼" content-class="page-marketing-new-customer">
<div class="mnc-page">
<div class="mnc-toolbar">
<Select
class="mnc-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<span v-if="!canManage" class="mnc-readonly-tip">当前为只读权限</span>
</div>
<div class="mnc-banner">
<span class="mnc-banner-icon">
<IconifyIcon icon="lucide:gift" />
</span>
<div class="mnc-banner-text">
<strong>新客有礼</strong>
新用户首次下单自动发放优惠礼包提升新客转化率开启后每位新注册用户将自动收到您配置的欢迎礼包
</div>
</div>
<NewCustomerStatsCards v-if="hasStore" :stats="stats" />
<div v-if="!hasStore" class="mnc-empty">暂无门店请先创建门店</div>
<Spin v-else :spinning="isDetailLoading">
<Card class="mnc-card" :bordered="false">
<div class="mnc-section-title">新客礼包配置</div>
<div class="mnc-row">
<div class="mnc-row-label">启用状态</div>
<div class="mnc-row-control">
<Switch
:checked="form.giftEnabled"
:disabled="!canManage"
@update:checked="(value) => setGiftEnabled(Boolean(value))"
/>
<span class="mnc-row-hint">开启后新用户注册即发放礼包</span>
</div>
</div>
<div class="mnc-row">
<div class="mnc-row-label">礼包类型</div>
<div class="mnc-row-control mnc-inline">
<button
v-for="item in NEW_CUSTOMER_GIFT_TYPE_OPTIONS"
:key="item.value"
type="button"
class="mnc-pill"
:class="{ checked: form.giftType === item.value }"
:disabled="!canManage"
@click="setGiftType(item.value)"
>
{{ item.label }}
</button>
</div>
</div>
<template v-if="form.giftType === 'coupon'">
<div class="mnc-row mnc-row-block">
<NewCustomerCouponGroup
title="券包内容"
add-text="添加优惠券"
:coupons="form.welcomeCoupons"
:can-manage="canManage"
@add="openSceneCouponDrawer('welcome')"
@remove="(index) => removeCoupon('welcome', index)"
/>
</div>
</template>
<template v-else>
<div class="mnc-row">
<div class="mnc-row-label">直减金额</div>
<div class="mnc-row-control">
<div class="mnc-inline">
<span>¥</span>
<InputNumber
:value="form.directReduceAmount ?? undefined"
:min="0.01"
:precision="2"
:disabled="!canManage"
placeholder="如10"
@update:value="
(value) =>
setDirectReduceAmount(parseNullableNumber(value))
"
/>
</div>
<div class="mnc-row-hint">新用户首单自动减免的金额</div>
</div>
</div>
<div class="mnc-row">
<div class="mnc-row-label">使用门槛</div>
<div class="mnc-row-control">
<div class="mnc-inline">
<span>订单满</span>
<InputNumber
:value="form.directMinimumSpend ?? undefined"
:min="0"
:precision="2"
:disabled="!canManage"
placeholder="如30"
@update:value="
(value) =>
setDirectMinimumSpend(parseNullableNumber(value))
"
/>
<span>元可用</span>
</div>
</div>
</div>
</template>
</Card>
<Card class="mnc-card" :bordered="false">
<div class="mnc-section-title">老带新分享</div>
<div class="mnc-row">
<div class="mnc-row-label">启用状态</div>
<div class="mnc-row-control">
<Switch
:checked="form.inviteEnabled"
:disabled="!canManage"
@update:checked="(value) => setInviteEnabled(Boolean(value))"
/>
<span class="mnc-row-hint">
开启后顾客可在小程序内分享邀请好友
</span>
</div>
</div>
<div class="mnc-row mnc-row-block">
<NewCustomerCouponGroup
title="邀请人奖励"
description="成功邀请新用户下单后发放"
add-text="添加奖励券"
:coupons="form.inviterCoupons"
:can-manage="canManage"
@add="openSceneCouponDrawer('inviter')"
@remove="(index) => removeCoupon('inviter', index)"
/>
</div>
<div class="mnc-row mnc-row-block">
<NewCustomerCouponGroup
title="被邀请人奖励"
description="新用户通过邀请注册后额外获得,叠加新客礼包"
add-text="添加奖励券"
:coupons="form.inviteeCoupons"
:can-manage="canManage"
@add="openSceneCouponDrawer('invitee')"
@remove="(index) => removeCoupon('invitee', index)"
/>
</div>
<div class="mnc-row">
<div class="mnc-row-label">分享渠道</div>
<div class="mnc-row-control">
<div class="mnc-inline">
<button
v-for="item in NEW_CUSTOMER_SHARE_CHANNEL_OPTIONS"
:key="item.value"
type="button"
class="mnc-pill"
:class="{ checked: form.shareChannels.includes(item.value) }"
:disabled="!canManage"
@click="toggleShareChannel(item.value)"
>
{{ item.label }}
</button>
</div>
<div class="mnc-row-hint">
顾客在小程序内可通过以上渠道分享邀请
</div>
</div>
</div>
<div class="mnc-row mnc-row-block">
<div class="mnc-row-block-title">邀请记录近期</div>
<div class="mnc-invite-table">
<NewCustomerInviteRecordTable
:records="invitePager.items"
:page="invitePager.page"
:page-size="invitePager.pageSize"
:total="invitePager.totalCount"
:loading="isInviteLoading"
@page-change="handleInvitePageChange"
/>
</div>
</div>
</Card>
<Card class="mnc-save-card" :bordered="false">
<div class="mnc-save-bar">
<span v-if="updatedAt" class="mnc-updated-at">
最近更新{{ updatedAt }}
</span>
<Button :disabled="!canManage || isSaving" @click="resetSettings">
重置
</Button>
<Button
type="primary"
:disabled="!canManage"
:loading="isSaving"
@click="saveSettings"
>
保存设置
</Button>
</div>
</Card>
</Spin>
</div>
<NewCustomerCouponDrawer
:open="isCouponDrawerOpen"
:form="couponDrawerForm"
:scene="couponDrawerScene"
@close="closeCouponDrawer"
@set-coupon-type="setCouponDrawerType"
@set-value="setCouponDrawerValue"
@set-minimum-spend="setCouponDrawerMinimumSpend"
@set-valid-days="setCouponDrawerValidDays"
@submit="submitCouponDrawer"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,13 @@
/**
* 文件职责:新客有礼页面基础变量样式。
*/
.page-marketing-new-customer {
--mnc-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1);
--mnc-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
--mnc-shadow-md: 0 8px 20px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 6%);
--mnc-border: #e7eaf0;
--mnc-text: #1f2937;
--mnc-subtext: #6b7280;
--mnc-muted: #9ca3af;
--mnc-primary: #1677ff;
}

View File

@@ -0,0 +1,168 @@
/**
* 文件职责:新客有礼券列表与交互控件样式。
*/
.page-marketing-new-customer {
.mnc-pill {
height: 30px;
padding: 0 14px;
font-size: 12px;
line-height: 28px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all var(--mnc-transition);
}
.mnc-pill:hover {
color: var(--mnc-primary);
border-color: #91caff;
}
.mnc-pill.checked {
color: var(--mnc-primary);
background: #e8f3ff;
border-color: #91caff;
}
.mnc-pill:disabled {
color: #b7bcc7;
cursor: not-allowed;
background: #fafafa;
border-color: #e5e7eb;
}
.mnc-coupon-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.mnc-coupon-group-title {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 13px;
font-weight: 500;
color: var(--mnc-text);
}
.mnc-coupon-group-desc {
font-size: 12px;
font-weight: 400;
color: var(--mnc-muted);
}
.mnc-coupon-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mnc-coupon-item {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: #fff;
border: 1px solid var(--mnc-border);
border-left: 3px solid var(--mnc-primary);
border-radius: 8px;
transition: box-shadow var(--mnc-transition);
}
.mnc-coupon-item:nth-child(2) {
border-left-color: #16a34a;
}
.mnc-coupon-item:nth-child(3) {
border-left-color: #d97706;
}
.mnc-coupon-item:hover {
box-shadow: var(--mnc-shadow-sm);
}
.mnc-coupon-info {
flex: 1;
min-width: 0;
}
.mnc-coupon-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
color: var(--mnc-text);
white-space: nowrap;
}
.mnc-coupon-desc {
margin-top: 2px;
font-size: 11px;
color: var(--mnc-muted);
}
.mnc-coupon-validity {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: var(--mnc-subtext);
}
.mnc-coupon-validity .iconify {
width: 12px;
height: 12px;
}
.mnc-coupon-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
}
.mnc-coupon-remove .iconify {
width: 14px;
height: 14px;
}
.mnc-coupon-empty .ant-empty-image {
height: 32px;
}
.mnc-coupon-empty .ant-empty-description {
margin: 0;
font-size: 12px;
}
.mnc-add-coupon-btn {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
width: 100%;
padding: 10px 12px;
font-size: 13px;
color: var(--mnc-subtext);
cursor: pointer;
background: none;
border: 2px dashed #d9d9d9;
border-radius: 8px;
transition: all var(--mnc-transition);
}
.mnc-add-coupon-btn:hover {
color: var(--mnc-primary);
background: #f5f9ff;
border-color: var(--mnc-primary);
}
.mnc-add-coupon-btn .iconify {
width: 14px;
height: 14px;
}
}

View File

@@ -0,0 +1,100 @@
/**
* 文件职责:新客有礼券抽屉样式。
*/
.mnc-coupon-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;
}
.mnc-coupon-drawer-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-number {
border-color: #e5e7eb !important;
border-radius: 6px !important;
}
.ant-input-number-input {
font-size: 13px;
}
.ant-input-number:focus-within,
.ant-radio-group
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
border-color: #1677ff !important;
box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important;
}
.mnc-inline-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 13px;
color: #4b5563;
}
.mnc-inline-fields .ant-input-number {
width: 140px;
}
.mnc-field-hint {
margin-top: 4px;
font-size: 11px;
color: #9ca3af;
}
.mnc-shipping-tip {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #6b7280;
background: #f8f9fb;
border-radius: 8px;
}
.mnc-shipping-tip .iconify {
width: 16px;
height: 16px;
color: #9ca3af;
}
.mnc-coupon-drawer-footer {
display: flex;
gap: 8px;
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,7 @@
@import './base.less';
@import './layout.less';
@import './section.less';
@import './card.less';
@import './drawer.less';
@import './table.less';
@import './responsive.less';

View File

@@ -0,0 +1,183 @@
/**
* 文件职责:新客有礼页面布局样式。
*/
.page-marketing-new-customer {
.mnc-page {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 960px;
}
.mnc-toolbar {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 14px;
background: #fff;
border: 1px solid var(--mnc-border);
border-radius: 10px;
box-shadow: var(--mnc-shadow-sm);
}
.mnc-store-select {
width: 220px;
}
.mnc-store-select .ant-select-selector {
border-radius: 8px !important;
}
.mnc-readonly-tip {
padding-inline: 10px;
margin-left: auto;
font-size: 12px;
line-height: 26px;
color: #d97706;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 999px;
}
.mnc-banner {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
background: linear-gradient(135deg, #eef6ff, #f8fbff);
border: 1px solid #d5e6ff;
border-radius: 10px;
}
.mnc-banner-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: var(--mnc-primary);
background: #deecff;
border-radius: 10px;
}
.mnc-banner-icon .iconify {
width: 20px;
height: 20px;
}
.mnc-banner-text {
font-size: 13px;
line-height: 1.6;
color: var(--mnc-subtext);
}
.mnc-banner-text strong {
margin-right: 6px;
font-weight: 600;
color: var(--mnc-text);
}
.mnc-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.mnc-stat-card {
padding: 18px 20px;
background: #fff;
border: 1px solid var(--mnc-border);
border-radius: 10px;
box-shadow: var(--mnc-shadow-sm);
transition: box-shadow var(--mnc-transition);
}
.mnc-stat-card:hover {
box-shadow: var(--mnc-shadow-md);
}
.mnc-stat-label {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--mnc-muted);
}
.mnc-stat-label .iconify {
width: 14px;
height: 14px;
color: var(--mnc-primary);
}
.mnc-stat-value {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: var(--mnc-text);
}
.mnc-stat-unit {
font-size: 13px;
font-weight: 400;
color: var(--mnc-muted);
}
.mnc-stat-trend {
display: inline-flex;
gap: 2px;
align-items: center;
font-size: 12px;
font-weight: 500;
}
.mnc-stat-trend.up {
color: #16a34a;
}
.mnc-stat-trend.down {
color: #dc2626;
}
.mnc-stat-trend .iconify {
width: 14px;
height: 14px;
}
.mnc-stat-sub {
margin-top: 4px;
font-size: 11px;
color: var(--mnc-muted);
}
.mnc-empty {
padding: 28px 14px;
font-size: 13px;
color: var(--mnc-muted);
text-align: center;
background: #fff;
border: 1px solid var(--mnc-border);
border-radius: 10px;
box-shadow: var(--mnc-shadow-sm);
}
.mnc-card,
.mnc-save-card {
border: 1px solid var(--mnc-border);
border-radius: 10px;
box-shadow: var(--mnc-shadow-sm);
}
.mnc-card .ant-card-body {
padding: 18px;
}
.mnc-save-card .ant-card-body {
padding: 14px 18px;
}
}

View File

@@ -0,0 +1,59 @@
/**
* 文件职责:新客有礼页面响应式样式。
*/
.page-marketing-new-customer {
@media (width <= 992px) {
.mnc-page {
max-width: none;
}
.mnc-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mnc-row {
flex-direction: column;
gap: 8px;
}
.mnc-row-label {
width: auto;
padding-top: 0;
}
}
@media (width <= 768px) {
.mnc-toolbar {
flex-wrap: wrap;
}
.mnc-readonly-tip {
margin-left: 0;
}
.mnc-stats {
grid-template-columns: 1fr;
}
.mnc-stat-value {
font-size: 24px;
}
.mnc-coupon-item {
flex-wrap: wrap;
}
.mnc-coupon-validity {
margin-right: auto;
}
.mnc-save-bar {
flex-wrap: wrap;
}
.mnc-updated-at {
width: 100%;
margin-right: 0;
}
}
}

View File

@@ -0,0 +1,95 @@
/**
* 文件职责:新客有礼页面区块与行结构样式。
*/
.page-marketing-new-customer {
.mnc-section-title {
padding-left: 10px;
margin-bottom: 16px;
font-size: 15px;
font-weight: 600;
color: var(--mnc-text);
border-left: 3px solid var(--mnc-primary);
}
.mnc-row {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 14px 0;
border-bottom: 1px solid #f3f4f6;
}
.mnc-row:last-child {
border-bottom: none;
}
.mnc-row-block {
flex-direction: column;
gap: 10px;
align-items: stretch;
}
.mnc-row-label {
flex-shrink: 0;
width: 120px;
padding-top: 4px;
font-size: 13px;
font-weight: 500;
line-height: 22px;
color: var(--mnc-text);
}
.mnc-row-control {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
.mnc-row-control .ant-switch {
margin-right: 6px;
}
.mnc-row-hint {
font-size: 11px;
color: var(--mnc-muted);
}
.mnc-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.mnc-inline .ant-input-number {
width: 100px;
}
.mnc-inline .ant-input-number-input {
font-size: 13px;
}
.mnc-row-block-title {
font-size: 13px;
font-weight: 500;
color: var(--mnc-text);
}
.mnc-invite-table {
width: 100%;
}
.mnc-save-bar {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.mnc-updated-at {
margin-right: auto;
font-size: 12px;
color: var(--mnc-muted);
}
}

View File

@@ -0,0 +1,42 @@
/**
* 文件职责:新客有礼邀请记录表格样式。
*/
.page-marketing-new-customer {
.mnc-invite-table {
.ant-table-wrapper .ant-table {
overflow: hidden;
border: 1px solid #f1f3f8;
border-radius: 8px;
}
.ant-table-wrapper .ant-table-thead > tr > th {
font-size: 12px;
color: #6b7280;
background: #fafcff;
}
}
.mnc-status-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 9px;
font-size: 12px;
border-radius: 999px;
}
.mnc-status-tag.is-green {
color: #166534;
background: #dcfce7;
}
.mnc-status-tag.is-orange {
color: #c2410c;
background: #ffedd5;
}
.mnc-status-tag.is-gray {
color: #475569;
background: #e2e8f0;
}
}

View File

@@ -0,0 +1,75 @@
import type {
MarketingNewCustomerCouponRuleDto,
MarketingNewCustomerCouponScene,
MarketingNewCustomerCouponType,
MarketingNewCustomerGiftType,
MarketingNewCustomerInviteRecordDto,
MarketingNewCustomerShareChannel,
MarketingNewCustomerStatsDto,
} from '#/api/marketing';
/**
* 文件职责:新客有礼页面类型定义。
*/
/** 页面可编辑优惠券项。 */
export interface NewCustomerCouponFormItem {
couponType: MarketingNewCustomerCouponType;
id: string;
minimumSpend: null | number;
scene: MarketingNewCustomerCouponScene;
sortOrder: number;
validDays: number;
value: null | number;
}
/** 页面可编辑表单。 */
export interface NewCustomerPageForm {
directMinimumSpend: null | number;
directReduceAmount: null | number;
giftEnabled: boolean;
giftType: MarketingNewCustomerGiftType;
inviteEnabled: boolean;
inviteeCoupons: NewCustomerCouponFormItem[];
inviterCoupons: NewCustomerCouponFormItem[];
shareChannels: MarketingNewCustomerShareChannel[];
welcomeCoupons: NewCustomerCouponFormItem[];
}
/** 券抽屉表单。 */
export interface NewCustomerCouponDrawerForm {
couponType: MarketingNewCustomerCouponType;
minimumSpend: null | number;
validDays: number;
value: null | number;
}
/** 邀请记录分页模型。 */
export interface NewCustomerInviteRecordPager {
items: MarketingNewCustomerInviteRecordDto[];
page: number;
pageSize: number;
totalCount: number;
}
/** 统计视图模型。 */
export type NewCustomerStatsViewModel = MarketingNewCustomerStatsDto;
/** 邀请记录视图模型。 */
export type NewCustomerInviteRecordViewModel =
MarketingNewCustomerInviteRecordDto;
/** 映射为页面可编辑券项。 */
export function toCouponFormItem(
source: MarketingNewCustomerCouponRuleDto,
): NewCustomerCouponFormItem {
return {
id: source.id,
scene: source.scene,
couponType: source.couponType,
value: source.value,
minimumSpend: source.minimumSpend,
validDays: source.validDays,
sortOrder: source.sortOrder,
};
}

View File

@@ -0,0 +1,511 @@
<script setup lang="ts">
/**
* 文件职责:次卡编辑抽屉。
*/
import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
import type { Dayjs } from 'dayjs';
import type { PunchCardEditorForm } from '../types';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
Button,
DatePicker,
Drawer,
Form,
Input,
InputNumber,
message,
Select,
Switch,
Upload,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { uploadTenantFileApi } from '#/api/files';
import {
PUNCH_CARD_EXPIRE_STRATEGY_OPTIONS,
PUNCH_CARD_NOTIFY_CHANNEL_OPTIONS,
PUNCH_CARD_SCOPE_OPTIONS,
PUNCH_CARD_USAGE_MODE_OPTIONS,
PUNCH_CARD_VALIDITY_OPTIONS,
} from '../composables/punch-card-page/constants';
const props = defineProps<{
canManage: boolean;
categoryOptions: Array<{ label: string; value: string }>;
form: PunchCardEditorForm;
loading: boolean;
open: boolean;
scopeProductNames: Array<{ id: string; name: string }>;
submitText: string;
submitting: boolean;
tagOptions: Array<{ label: string; value: string }>;
title: string;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'openScopeProductPicker'): void;
(event: 'setAllowTransfer', value: boolean): void;
(event: 'setCoverImageUrl', value: string): void;
(event: 'setDailyLimit', value: null | number): void;
(event: 'setDescription', value: string): void;
(
event: 'setExpireStrategy',
value: PunchCardEditorForm['expireStrategy'],
): void;
(event: 'setName', value: string): void;
(event: 'setOriginalPrice', value: null | number): void;
(event: 'setPerOrderLimit', value: null | number): void;
(event: 'setPerUserPurchaseLimit', value: null | number): void;
(event: 'setSalePrice', value: null | number): void;
(event: 'setScopeCategoryIds', value: string[]): void;
(event: 'setScopeProductIds', value: string[]): void;
(event: 'setScopeTagIds', value: string[]): void;
(
event: 'setScopeType',
value: PunchCardEditorForm['scope']['scopeType'],
): void;
(event: 'setTotalTimes', value: null | number): void;
(event: 'setUsageCapAmount', value: null | number): void;
(event: 'setUsageMode', value: PunchCardEditorForm['usageMode']): void;
(
event: 'setValidDateRange',
value: PunchCardEditorForm['validDateRange'],
): void;
(event: 'setValidityDays', value: null | number): void;
(event: 'setValidityType', value: PunchCardEditorForm['validityType']): void;
(event: 'submit'): void;
(event: 'toggleNotifyChannel', value: string): void;
}>();
const isUploading = ref(false);
type RangePickerValue = [Dayjs, Dayjs] | [string, string] | null;
function parseNullableNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function setScopeType(value: unknown) {
if (
value === 'all' ||
value === 'category' ||
value === 'tag' ||
value === 'product'
) {
emit('setScopeType', value);
}
}
function setValidityType(value: unknown) {
if (value === 'days' || value === 'range') {
emit('setValidityType', value);
}
}
function setUsageMode(value: unknown) {
if (value === 'free' || value === 'cap') {
emit('setUsageMode', value);
}
}
function setExpireStrategy(value: unknown) {
if (value === 'invalidate' || value === 'refund') {
emit('setExpireStrategy', value);
}
}
function setValidDateRange(value: RangePickerValue) {
if (!value) {
emit('setValidDateRange', null);
return;
}
const [start, end] = value;
if (dayjs.isDayjs(start) && dayjs.isDayjs(end)) {
emit('setValidDateRange', [start, end]);
return;
}
if (typeof start === 'string' && typeof end === 'string') {
const parsedStart = dayjs(start);
const parsedEnd = dayjs(end);
if (parsedStart.isValid() && parsedEnd.isValid()) {
emit('setValidDateRange', [parsedStart, parsedEnd]);
}
}
}
function setScopeCategoryIds(value: unknown) {
if (Array.isArray(value)) {
emit('setScopeCategoryIds', value.map(String));
}
}
function setScopeTagIds(value: unknown) {
if (Array.isArray(value)) {
emit('setScopeTagIds', value.map(String));
}
}
function removeScopeProduct(id: string) {
emit(
'setScopeProductIds',
props.form.scope.productIds.filter((item) => item !== id),
);
}
async function handleUploadChange(info: UploadChangeParam<UploadFile>) {
const rawFile = info.file.originFileObj;
if (!rawFile) {
return;
}
isUploading.value = true;
try {
const result = await uploadTenantFileApi(rawFile, 'dish_image');
if (result.url) {
emit('setCoverImageUrl', result.url);
message.success('上传成功');
} else {
message.error('上传失败');
}
} catch (error) {
console.error(error);
message.error('上传失败');
} finally {
isUploading.value = false;
}
}
</script>
<template>
<Drawer
:open="open"
:title="title"
width="560"
class="mpc-editor-drawer"
@close="emit('close')"
>
<Form layout="vertical" class="mpc-editor-form">
<Form.Item label="次卡名称" required>
<Input
:value="form.name"
:disabled="!canManage || loading"
placeholder="如:咖啡月卡、早餐周卡"
:maxlength="64"
@update:value="(value) => emit('setName', String(value ?? ''))"
/>
</Form.Item>
<Form.Item label="封面图片">
<div class="mpc-cover-uploader">
<img
v-if="form.coverImageUrl"
class="mpc-cover-preview"
:src="form.coverImageUrl"
alt="cover"
/>
<Upload
:show-upload-list="false"
:before-upload="() => false"
:disabled="!canManage || loading || isUploading"
@change="handleUploadChange"
>
<Button :loading="isUploading" :disabled="!canManage || loading">
<IconifyIcon icon="lucide:upload-cloud" />
{{ form.coverImageUrl ? '重新上传' : '点击上传封面' }}
</Button>
</Upload>
</div>
</Form.Item>
<div class="mpc-inline-row">
<Form.Item label="售价" required>
<InputNumber
:value="form.salePrice ?? undefined"
:min="0.01"
:precision="2"
:disabled="!canManage || loading"
placeholder="99"
@update:value="
(value) => emit('setSalePrice', parseNullableNumber(value))
"
/>
</Form.Item>
<Form.Item label="原价">
<InputNumber
:value="form.originalPrice ?? undefined"
:min="0"
:precision="2"
:disabled="!canManage || loading"
placeholder="150"
@update:value="
(value) => emit('setOriginalPrice', parseNullableNumber(value))
"
/>
</Form.Item>
</div>
<Form.Item label="总次数" required>
<InputNumber
:value="form.totalTimes ?? undefined"
:min="1"
:precision="0"
:disabled="!canManage || loading"
placeholder="30"
@update:value="
(value) => emit('setTotalTimes', parseNullableNumber(value))
"
/>
<div class="mpc-form-hint">购买后可使用的总次数</div>
</Form.Item>
<Form.Item label="有效期" required>
<Select
:value="form.validityType"
:options="PUNCH_CARD_VALIDITY_OPTIONS"
:disabled="!canManage || loading"
@update:value="setValidityType"
/>
<div v-if="form.validityType === 'days'" class="mpc-inline-fields">
<span>购买后</span>
<InputNumber
:value="form.validityDays ?? undefined"
:min="1"
:precision="0"
:disabled="!canManage || loading"
placeholder="30"
@update:value="
(value) => emit('setValidityDays', parseNullableNumber(value))
"
/>
<span>天内有效</span>
</div>
<DatePicker.RangePicker
v-else
:value="form.validDateRange ?? undefined"
:disabled="!canManage || loading"
class="mpc-range-picker"
@update:value="setValidDateRange"
/>
</Form.Item>
<div class="mpc-section-divider"></div>
<Form.Item label="适用范围" required>
<Select
:value="form.scope.scopeType"
:options="PUNCH_CARD_SCOPE_OPTIONS"
:disabled="!canManage || loading"
@update:value="setScopeType"
/>
<template v-if="form.scope.scopeType === 'all'">
<div class="mpc-form-hint">持卡可兑换店内任意商品</div>
</template>
<template v-else-if="form.scope.scopeType === 'category'">
<Select
mode="multiple"
:value="form.scope.categoryIds"
:options="categoryOptions"
:disabled="!canManage || loading"
placeholder="请选择商品分类"
@update:value="setScopeCategoryIds"
/>
<div class="mpc-form-hint">
可多选,持卡可兑换所选分类下的任意商品
</div>
</template>
<template v-else-if="form.scope.scopeType === 'tag'">
<Select
mode="multiple"
:value="form.scope.tagIds"
:options="tagOptions"
:disabled="!canManage || loading"
placeholder="请选择商品标签"
@update:value="setScopeTagIds"
/>
<div class="mpc-form-hint">可多选,持卡可兑换带有所选标签的商品</div>
</template>
<template v-else>
<Button
:disabled="!canManage || loading"
@click="emit('openScopeProductPicker')"
>
<IconifyIcon icon="lucide:plus" />
选择商品
</Button>
<div
v-if="scopeProductNames.length > 0"
class="mpc-selected-products"
>
<span
v-for="item in scopeProductNames"
:key="item.id"
class="mpc-selected-product"
>
<span class="mpc-selected-product-name">{{ item.name }}</span>
<button
type="button"
class="mpc-selected-product-remove"
:disabled="!canManage || loading"
@click="removeScopeProduct(item.id)"
>
<IconifyIcon icon="lucide:x" />
</button>
</span>
</div>
<div v-else class="mpc-form-hint">可多选,持卡仅可兑换指定商品</div>
</template>
</Form.Item>
<Form.Item label="使用模式" required>
<Select
:value="form.usageMode"
:options="PUNCH_CARD_USAGE_MODE_OPTIONS"
:disabled="!canManage || loading"
@update:value="setUsageMode"
/>
<div v-if="form.usageMode === 'free'" class="mpc-form-hint">
每次使用免费兑换一件适用范围内商品,不限原价
</div>
<div v-else class="mpc-inline-fields">
<span>每次使用上限</span>
<InputNumber
:value="form.usageCapAmount ?? undefined"
:min="0.01"
:precision="2"
:disabled="!canManage || loading"
placeholder="20"
@update:value="
(value) => emit('setUsageCapAmount', parseNullableNumber(value))
"
/>
<span>元</span>
</div>
</Form.Item>
<div class="mpc-section-divider"></div>
<div class="mpc-inline-row">
<Form.Item label="每日限用">
<InputNumber
:value="form.dailyLimit ?? undefined"
:min="0"
:precision="0"
:disabled="!canManage || loading"
placeholder="不限"
@update:value="
(value) => emit('setDailyLimit', parseNullableNumber(value))
"
/>
</Form.Item>
<Form.Item label="每单限用">
<InputNumber
:value="form.perOrderLimit ?? undefined"
:min="0"
:precision="0"
:disabled="!canManage || loading"
placeholder="不限"
@update:value="
(value) => emit('setPerOrderLimit', parseNullableNumber(value))
"
/>
</Form.Item>
<Form.Item label="每人限购">
<InputNumber
:value="form.perUserPurchaseLimit ?? undefined"
:min="0"
:precision="0"
:disabled="!canManage || loading"
placeholder="不限"
@update:value="
(value) =>
emit('setPerUserPurchaseLimit', parseNullableNumber(value))
"
/>
</Form.Item>
</div>
<Form.Item label="允许转赠">
<Switch
:checked="form.allowTransfer"
:disabled="!canManage || loading"
@update:checked="(value) => emit('setAllowTransfer', Boolean(value))"
/>
</Form.Item>
<Form.Item label="过期策略">
<Select
:value="form.expireStrategy"
:options="PUNCH_CARD_EXPIRE_STRATEGY_OPTIONS"
:disabled="!canManage || loading"
@update:value="setExpireStrategy"
/>
</Form.Item>
<Form.Item label="次卡描述">
<Input.TextArea
:value="form.description"
:disabled="!canManage || loading"
:maxlength="512"
:rows="3"
placeholder="请输入次卡说明如使用规则适用门店等"
@update:value="(value) => emit('setDescription', String(value ?? ''))"
/>
</Form.Item>
<Form.Item label="购买通知">
<div class="mpc-notify-pills">
<button
v-for="item in PUNCH_CARD_NOTIFY_CHANNEL_OPTIONS"
:key="item.value"
type="button"
class="mpc-notify-pill"
:class="{ checked: form.notifyChannels.includes(item.value) }"
:disabled="!canManage || loading"
@click="emit('toggleNotifyChannel', item.value)"
>
{{ item.label }}
</button>
</div>
<div class="mpc-form-hint">顾客购买次卡后的通知方式</div>
</Form.Item>
</Form>
<template #footer>
<div class="mpc-drawer-footer">
<Button @click="emit('close')">取消</Button>
<Button
type="primary"
:loading="submitting"
:disabled="!canManage || loading"
@click="emit('submit')"
>
{{ submitText }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
/**
* 文件职责:次卡二级商品选择抽屉。
*/
import type { PunchCardPickerProductItem } from '../types';
import type { ProductStatus } from '#/api/product';
import { computed } from 'vue';
import { Button, Drawer, Empty, Input, Select, Spin } from 'ant-design-vue';
import { formatCurrency } from '../composables/punch-card-page/helpers';
const props = defineProps<{
categoryFilterId: string;
categoryOptions: Array<{ label: string; value: string }>;
keyword: string;
loading: boolean;
open: boolean;
products: PunchCardPickerProductItem[];
selectedProductIds: string[];
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'search'): void;
(event: 'setCategoryFilterId', value: string): void;
(event: 'setKeyword', value: string): void;
(event: 'toggleProduct', id: string): void;
(event: 'submit'): void;
}>();
const selectedCount = computed(() => props.selectedProductIds.length);
const allChecked = computed(
() =>
props.products.length > 0 &&
props.products.every((item) => props.selectedProductIds.includes(item.id)),
);
function setKeyword(value: string) {
emit('setKeyword', value);
}
function setCategoryFilterId(value: unknown) {
emit('setCategoryFilterId', String(value ?? ''));
}
function toggleAll() {
if (allChecked.value) {
const visibleIds = new Set(props.products.map((item) => item.id));
for (const id of props.selectedProductIds) {
if (visibleIds.has(id)) {
emit('toggleProduct', id);
}
}
return;
}
for (const item of props.products) {
if (!props.selectedProductIds.includes(item.id)) {
emit('toggleProduct', item.id);
}
}
}
function resolveProductStatusText(status: ProductStatus) {
if (status === 'on_sale') {
return '在售';
}
if (status === 'sold_out') {
return '沽清';
}
return '下架';
}
function resolveProductStatusClass(status: ProductStatus) {
if (status === 'on_sale') {
return 'is-green';
}
if (status === 'sold_out') {
return 'is-orange';
}
return 'is-gray';
}
</script>
<template>
<Drawer
:open="open"
title="选择适用商品"
width="760"
class="mpc-picker-drawer"
@close="emit('close')"
>
<div class="mpc-picker-toolbar">
<Input
:value="keyword"
placeholder="搜索商品名称/SPU"
allow-clear
@update:value="setKeyword"
@press-enter="emit('search')"
/>
<Select
:value="categoryFilterId"
:options="categoryOptions"
class="mpc-picker-category"
@update:value="setCategoryFilterId"
/>
<Button @click="emit('search')">搜索</Button>
<span class="mpc-picker-count">已选 {{ selectedCount }} </span>
</div>
<Spin :spinning="loading">
<div v-if="products.length === 0" class="mpc-picker-empty">
<Empty description="暂无可选商品" />
</div>
<div v-else class="mpc-picker-table-wrap">
<table class="mpc-picker-table">
<thead>
<tr>
<th class="mpc-picker-col-check">
<input
type="checkbox"
:checked="allChecked"
@change="toggleAll"
/>
</th>
<th>商品</th>
<th>分类</th>
<th class="mpc-picker-col-price">售价</th>
<th class="mpc-picker-col-status">状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in products"
:key="item.id"
:class="{ checked: selectedProductIds.includes(item.id) }"
@click="emit('toggleProduct', item.id)"
>
<td class="mpc-picker-col-check">
<input
type="checkbox"
:checked="selectedProductIds.includes(item.id)"
@change.stop="emit('toggleProduct', item.id)"
/>
</td>
<td>
<div class="mpc-picker-product-name">{{ item.name }}</div>
<div class="mpc-picker-product-spu">{{ item.spuCode }}</div>
</td>
<td>{{ item.categoryName }}</td>
<td class="mpc-picker-col-price">
{{ formatCurrency(item.price) }}
</td>
<td class="mpc-picker-col-status">
<span
class="mpc-picker-product-status"
:class="resolveProductStatusClass(item.status)"
>
{{ resolveProductStatusText(item.status) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</Spin>
<template #footer>
<div class="mpc-picker-footer">
<span class="mpc-picker-footer-info">
已选择 {{ selectedCount }} 个商品
</span>
<div class="mpc-picker-footer-actions">
<Button @click="emit('close')">取消</Button>
<Button type="primary" @click="emit('submit')">确认选择</Button>
</div>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* 文件职责:次卡列表统计卡片。
*/
import type { PunchCardStatsViewModel } from '../types';
import { IconifyIcon } from '@vben/icons';
import { formatCurrency } from '../composables/punch-card-page/helpers';
defineProps<{
stats: PunchCardStatsViewModel;
}>();
</script>
<template>
<div class="mpc-stats">
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-blue">
<IconifyIcon icon="lucide:ticket" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value">{{ stats.onSaleCount }}</div>
<div class="mpc-stat-label">在售次卡</div>
</div>
</div>
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-cyan">
<IconifyIcon icon="lucide:shopping-cart" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value">{{ stats.totalSoldCount }}</div>
<div class="mpc-stat-label">累计售出</div>
</div>
</div>
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-green">
<IconifyIcon icon="lucide:wallet" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value mpc-stat-value-green">
{{ formatCurrency(stats.totalRevenueAmount) }}
</div>
<div class="mpc-stat-label">累计收入</div>
</div>
</div>
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-orange">
<IconifyIcon icon="lucide:activity" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value mpc-stat-value-orange">
{{ stats.activeInUseCount }}
</div>
<div class="mpc-stat-label">使用中</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
/**
* 文件职责:次卡列表卡片。
*/
import type { PunchCardTemplateCardViewModel } from '../types';
import { IconifyIcon } from '@vben/icons';
import {
PUNCH_CARD_SCOPE_TEXT_MAP,
PUNCH_CARD_STATUS_TEXT_MAP,
} from '../composables/punch-card-page/constants';
import { formatCurrency } from '../composables/punch-card-page/helpers';
const props = defineProps<{
canManage: boolean;
item: PunchCardTemplateCardViewModel;
}>();
const emit = defineEmits<{
(event: 'edit', row: PunchCardTemplateCardViewModel): void;
(event: 'remove', row: PunchCardTemplateCardViewModel): void;
(event: 'toggleStatus', row: PunchCardTemplateCardViewModel): void;
}>();
function resolveScopeClass(
scopeType: PunchCardTemplateCardViewModel['scopeType'],
) {
if (scopeType === 'category') {
return 'is-blue';
}
if (scopeType === 'tag') {
return 'is-green';
}
if (scopeType === 'product') {
return 'is-purple';
}
return 'is-orange';
}
function resolveUsageModeText(row: PunchCardTemplateCardViewModel) {
if (row.usageMode === 'cap') {
return `上限${formatCurrency(row.usageCapAmount ?? 0)}/次`;
}
return '完全免费';
}
function resolveDailyLimitText(value: null | number) {
if (!value || value <= 0) {
return '不限次/日';
}
return `每日限${value}`;
}
function resolveStatusClass(status: PunchCardTemplateCardViewModel['status']) {
return status === 'enabled' ? 'is-green' : 'is-gray';
}
</script>
<template>
<div class="mpc-card" :class="{ 'mpc-card-off': item.isDimmed }">
<div class="mpc-card-cover">
<img
v-if="item.coverImageUrl"
:src="item.coverImageUrl"
:alt="item.name"
class="mpc-card-cover-image"
/>
<div v-else class="mpc-card-cover-fallback">
<IconifyIcon icon="lucide:ticket" class="mpc-card-cover-icon" />
<div class="mpc-card-cover-count">
{{ item.totalTimes }}
<small></small>
</div>
</div>
</div>
<div class="mpc-card-body">
<div class="mpc-card-name-row">
<div class="mpc-card-name">{{ item.name }}</div>
<span class="mpc-scope-tag" :class="resolveScopeClass(item.scopeType)">
{{ PUNCH_CARD_SCOPE_TEXT_MAP[item.scopeType] }}
</span>
</div>
<div class="mpc-card-price-row">
<span class="mpc-card-price-now">{{
formatCurrency(item.salePrice)
}}</span>
<span v-if="item.originalPrice" class="mpc-card-price-origin">
{{ formatCurrency(item.originalPrice) }}
</span>
</div>
<div class="mpc-card-info-tags">
<span class="mpc-card-info-tag">{{ item.validitySummary }}</span>
<span class="mpc-card-info-tag">{{ resolveUsageModeText(item) }}</span>
<span class="mpc-card-info-tag">{{
resolveDailyLimitText(item.dailyLimit)
}}</span>
</div>
<div class="mpc-card-meta">
<span>已售 {{ item.soldCount }}</span>
<span>使用中 {{ item.activeCount }}</span>
</div>
<div class="mpc-card-actions">
<button
type="button"
class="g-action"
:disabled="!props.canManage"
@click="emit('edit', item)"
>
编辑
</button>
<button
type="button"
class="g-action"
:disabled="!props.canManage"
@click="emit('toggleStatus', item)"
>
{{ item.status === 'enabled' ? '下架' : '上架' }}
</button>
<button
type="button"
class="g-action g-action-danger"
:disabled="!props.canManage"
@click="emit('remove', item)"
>
删除
</button>
<span class="mpc-status-tag" :class="resolveStatusClass(item.status)">
{{ PUNCH_CARD_STATUS_TEXT_MAP[item.status] }}
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* 文件职责:次卡使用记录表格。
*/
import type { PunchCardUsageRecordViewModel } from '../types';
import { Table } from 'ant-design-vue';
import { resolveUsageStatusClass } from '../composables/punch-card-page/helpers';
defineProps<{
loading: boolean;
page: number;
pageSize: number;
records: PunchCardUsageRecordViewModel[];
total: number;
}>();
const emit = defineEmits<{
(event: 'pageChange', page: number, pageSize: number): void;
}>();
const columns = [
{
title: '使用单号',
dataIndex: 'recordNo',
key: 'recordNo',
width: 170,
},
{
title: '会员',
dataIndex: 'memberName',
key: 'memberName',
width: 160,
},
{
title: '次卡',
dataIndex: 'punchCardName',
key: 'punchCardName',
width: 140,
},
{
title: '兑换商品',
dataIndex: 'productName',
key: 'productName',
},
{
title: '使用时间',
dataIndex: 'usedAt',
key: 'usedAt',
width: 170,
},
{
title: '剩余次数',
dataIndex: 'remainingTimesAfterUse',
key: 'remainingTimesAfterUse',
width: 100,
},
{
title: '状态',
dataIndex: 'displayStatus',
key: 'displayStatus',
width: 110,
},
];
function handleTableChange(pagination: {
current?: number;
pageSize?: number;
}) {
emit('pageChange', pagination.current ?? 1, pagination.pageSize ?? 10);
}
</script>
<template>
<Table
:loading="loading"
:columns="columns"
:data-source="records"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (value) => `共 ${value} 条`,
}"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'recordNo'">
<span class="mpc-record-no">{{ record.recordNo }}</span>
</template>
<template v-else-if="column.key === 'memberName'">
<div class="mpc-record-member">
<span class="mpc-record-member-name">{{ record.memberName }}</span>
<span class="mpc-record-member-phone">{{
record.memberPhoneMasked
}}</span>
</div>
</template>
<template v-else-if="column.key === 'remainingTimesAfterUse'">
<span class="mpc-record-remaining">
{{ record.remainingTimesAfterUse }}/{{ record.totalTimes }}
</span>
</template>
<template v-else-if="column.key === 'displayStatus'">
<span
class="mpc-record-status"
:class="resolveUsageStatusClass(record.displayStatus)"
>
{{ record.displayStatusText }}
</span>
</template>
</template>
</Table>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* 文件职责:次卡使用记录统计卡片。
*/
import type { PunchCardUsageStatsViewModel } from '../types';
import { IconifyIcon } from '@vben/icons';
defineProps<{
stats: PunchCardUsageStatsViewModel;
}>();
</script>
<template>
<div class="mpc-record-stats">
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-blue">
<IconifyIcon icon="lucide:clock-3" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value">{{ stats.todayUsedCount }}</div>
<div class="mpc-stat-label">今日使用</div>
</div>
</div>
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-cyan">
<IconifyIcon icon="lucide:calendar-days" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value">{{ stats.monthUsedCount }}</div>
<div class="mpc-stat-label">本月使用</div>
</div>
</div>
<div class="mpc-stat-card">
<span class="mpc-stat-icon mpc-stat-orange">
<IconifyIcon icon="lucide:alarm-clock" />
</span>
<div class="mpc-stat-main">
<div class="mpc-stat-value mpc-stat-value-orange">
{{ stats.expiringSoonCount }}
</div>
<div class="mpc-stat-label">即将过期7天内</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,96 @@
import type { Ref } from 'vue';
import type { PunchCardTemplateCardViewModel } from '#/views/marketing/punch-card/types';
/**
* 文件职责:次卡卡片动作(上下架/删除)。
*/
import { message, Modal } from 'ant-design-vue';
import {
changeMarketingPunchCardStatusApi,
deleteMarketingPunchCardApi,
} from '#/api/marketing';
interface CreateCardActionsOptions {
canManage: Ref<boolean>;
loadPunchCardList: () => Promise<void>;
loadUsageRecords: () => Promise<void>;
selectedStoreId: Ref<string>;
}
export function createCardActions(options: CreateCardActionsOptions) {
async function toggleStatus(item: PunchCardTemplateCardViewModel) {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled';
const confirmText = nextStatus === 'enabled' ? '上架' : '下架';
Modal.confirm({
title: `${confirmText}次卡`,
content: `确认${confirmText}${item.name}」吗?`,
onOk: async () => {
try {
await changeMarketingPunchCardStatusApi({
storeId: options.selectedStoreId.value,
punchCardId: item.id,
status: nextStatus,
});
message.success(`${confirmText}成功`);
await Promise.all([
options.loadPunchCardList(),
options.loadUsageRecords(),
]);
} catch (error) {
console.error(error);
message.error(`${confirmText}失败`);
}
},
});
}
async function removeCard(item: PunchCardTemplateCardViewModel) {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
Modal.confirm({
title: '删除次卡',
content: `确认删除「${item.name}」吗?删除后不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
try {
await deleteMarketingPunchCardApi({
storeId: options.selectedStoreId.value,
punchCardId: item.id,
});
message.success('删除成功');
await Promise.all([
options.loadPunchCardList(),
options.loadUsageRecords(),
]);
} catch (error) {
console.error(error);
message.error('删除失败');
}
},
});
}
return {
removeCard,
toggleStatus,
};
}

View File

@@ -0,0 +1,184 @@
import type {
MarketingPunchCardExpireStrategy,
MarketingPunchCardScopeType,
MarketingPunchCardStatus,
MarketingPunchCardUsageFilterStatus,
MarketingPunchCardUsageMode,
MarketingPunchCardValidityType,
} from '#/api/marketing';
import type {
PunchCardEditorForm,
PunchCardListFilterForm,
PunchCardScopeForm,
PunchCardUsageFilterForm,
PunchCardUsageStatsViewModel,
} from '#/views/marketing/punch-card/types';
/**
* 文件职责:次卡页面常量与默认值。
*/
/** 查看权限码。 */
export const PUNCH_CARD_VIEW_PERMISSION = 'tenant:marketing:punch-card:view';
/** 管理权限码。 */
export const PUNCH_CARD_MANAGE_PERMISSION =
'tenant:marketing:punch-card:manage';
/** 列表状态筛选项。 */
export const PUNCH_CARD_STATUS_FILTER_OPTIONS: Array<{
label: string;
value: '' | MarketingPunchCardStatus;
}> = [
{ label: '全部状态', value: '' },
{ label: '上架', value: 'enabled' },
{ label: '下架', value: 'disabled' },
];
/** 使用记录状态筛选项。 */
export const PUNCH_CARD_RECORD_STATUS_FILTER_OPTIONS: Array<{
label: string;
value: '' | MarketingPunchCardUsageFilterStatus;
}> = [
{ label: '全部状态', value: '' },
{ label: '正常使用', value: 'normal' },
{ label: '已用完', value: 'used_up' },
{ label: '已过期', value: 'expired' },
];
/** 有效期类型选项。 */
export const PUNCH_CARD_VALIDITY_OPTIONS: Array<{
label: string;
value: MarketingPunchCardValidityType;
}> = [
{ label: '固定天数', value: 'days' },
{ label: '日期范围', value: 'range' },
];
/** 适用范围选项。 */
export const PUNCH_CARD_SCOPE_OPTIONS: Array<{
label: string;
value: MarketingPunchCardScopeType;
}> = [
{ label: '全部商品', value: 'all' },
{ label: '指定分类', value: 'category' },
{ label: '指定标签', value: 'tag' },
{ label: '指定商品', value: 'product' },
];
/** 使用模式选项。 */
export const PUNCH_CARD_USAGE_MODE_OPTIONS: Array<{
label: string;
value: MarketingPunchCardUsageMode;
}> = [
{ label: '完全免费', value: 'free' },
{ label: '金额上限', value: 'cap' },
];
/** 过期策略选项。 */
export const PUNCH_CARD_EXPIRE_STRATEGY_OPTIONS: Array<{
label: string;
value: MarketingPunchCardExpireStrategy;
}> = [
{ label: '剩余次数作废', value: 'invalidate' },
{ label: '可申请退款', value: 'refund' },
];
/** 通知渠道选项。 */
export const PUNCH_CARD_NOTIFY_CHANNEL_OPTIONS: Array<{
label: string;
value: string;
}> = [
{ label: '站内消息', value: 'in_app' },
{ label: '短信通知', value: 'sms' },
];
/** 适用范围文案。 */
export const PUNCH_CARD_SCOPE_TEXT_MAP: Record<
MarketingPunchCardScopeType,
string
> = {
all: '全部商品',
category: '指定分类',
tag: '指定标签',
product: '指定商品',
};
/** 状态文案。 */
export const PUNCH_CARD_STATUS_TEXT_MAP: Record<
MarketingPunchCardStatus,
string
> = {
enabled: '上架',
disabled: '下架',
};
/** 记录状态文案。 */
export const PUNCH_CARD_RECORD_STATUS_TEXT_MAP: Record<string, string> = {
normal: '正常使用',
almost_used_up: '即将用完',
used_up: '已用完',
expired: '已过期',
};
/** 创建默认列表筛选。 */
export function createDefaultPunchCardListFilterForm(): PunchCardListFilterForm {
return {
status: '',
};
}
/** 创建默认使用记录筛选。 */
export function createDefaultPunchCardUsageFilterForm(): PunchCardUsageFilterForm {
return {
templateId: '',
status: '',
};
}
/** 创建默认适用范围。 */
export function createDefaultPunchCardScopeForm(
scopeType: MarketingPunchCardScopeType = 'all',
): PunchCardScopeForm {
return {
scopeType,
categoryIds: [],
tagIds: [],
productIds: [],
};
}
/** 创建默认编辑表单。 */
export function createDefaultPunchCardEditorForm(): PunchCardEditorForm {
return {
id: '',
name: '',
coverImageUrl: '',
salePrice: null,
originalPrice: null,
totalTimes: null,
validityType: 'days',
validityDays: 30,
validDateRange: null,
scope: createDefaultPunchCardScopeForm('all'),
usageMode: 'free',
usageCapAmount: null,
dailyLimit: 1,
perOrderLimit: 1,
perUserPurchaseLimit: null,
allowTransfer: false,
expireStrategy: 'invalidate',
description: '',
notifyChannels: ['in_app'],
status: 'enabled',
};
}
/** 创建默认记录统计。 */
export function createDefaultPunchCardUsageStats(): PunchCardUsageStatsViewModel {
return {
todayUsedCount: 0,
monthUsedCount: 0,
expiringSoonCount: 0,
};
}

View File

@@ -0,0 +1,186 @@
import type { Ref } from 'vue';
import type { MarketingPunchCardStatsDto } from '#/api/marketing';
import type { StoreListItemDto } from '#/api/store';
import type {
PunchCardListFilterForm,
PunchCardTemplateCardViewModel,
PunchCardTemplateOptionViewModel,
PunchCardUsageFilterForm,
PunchCardUsagePager,
PunchCardUsageStatsViewModel,
} from '#/views/marketing/punch-card/types';
/**
* 文件职责:次卡页面数据拉取动作。
*/
import { message } from 'ant-design-vue';
import {
getMarketingPunchCardListApi,
getMarketingPunchCardUsageRecordListApi,
} from '#/api/marketing';
import { getStoreListApi } from '#/api/store';
import { createDefaultPunchCardUsageStats } from './constants';
import { toUsageRecordViewModel } from './helpers';
interface CreateDataActionsOptions {
isListLoading: Ref<boolean>;
isRecordLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
listFilterForm: PunchCardListFilterForm;
listKeyword: Ref<string>;
listPage: Ref<number>;
listPageSize: Ref<number>;
listRows: Ref<PunchCardTemplateCardViewModel[]>;
listStats: Ref<MarketingPunchCardStatsDto>;
listTotalCount: Ref<number>;
recordFilterForm: PunchCardUsageFilterForm;
recordKeyword: Ref<string>;
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
templateOptions: Ref<PunchCardTemplateOptionViewModel[]>;
usagePager: Ref<PunchCardUsagePager>;
usageStats: Ref<PunchCardUsageStatsViewModel>;
}
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.listRows.value = [];
options.listTotalCount.value = 0;
options.usagePager.value = {
...options.usagePager.value,
items: [],
totalCount: 0,
};
options.templateOptions.value = [];
options.listStats.value = {
onSaleCount: 0,
totalSoldCount: 0,
totalRevenueAmount: 0,
activeInUseCount: 0,
};
options.usageStats.value = createDefaultPunchCardUsageStats();
return;
}
if (!options.selectedStoreId.value) {
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
return;
}
const exists = options.stores.value.some(
(item) => item.id === options.selectedStoreId.value,
);
if (!exists) {
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
options.isStoreLoading.value = false;
}
}
async function loadPunchCardList() {
if (!options.selectedStoreId.value) {
options.listRows.value = [];
options.listTotalCount.value = 0;
return;
}
options.isListLoading.value = true;
try {
const result = await getMarketingPunchCardListApi({
storeId: options.selectedStoreId.value,
page: options.listPage.value,
pageSize: options.listPageSize.value,
status: options.listFilterForm.status,
keyword: options.listKeyword.value.trim() || undefined,
});
options.listRows.value = result.items;
options.listTotalCount.value = result.totalCount;
options.listPage.value = result.page;
options.listPageSize.value = result.pageSize;
options.listStats.value = result.stats;
} catch (error) {
console.error(error);
options.listRows.value = [];
options.listTotalCount.value = 0;
options.listStats.value = {
onSaleCount: 0,
totalSoldCount: 0,
totalRevenueAmount: 0,
activeInUseCount: 0,
};
message.error('加载次卡列表失败');
} finally {
options.isListLoading.value = false;
}
}
async function loadUsageRecords() {
if (!options.selectedStoreId.value) {
options.usagePager.value = {
...options.usagePager.value,
items: [],
totalCount: 0,
};
options.templateOptions.value = [];
options.usageStats.value = createDefaultPunchCardUsageStats();
return;
}
options.isRecordLoading.value = true;
try {
const result = await getMarketingPunchCardUsageRecordListApi({
storeId: options.selectedStoreId.value,
page: options.usagePager.value.page,
pageSize: options.usagePager.value.pageSize,
punchCardId: options.recordFilterForm.templateId || undefined,
status: options.recordFilterForm.status,
keyword: options.recordKeyword.value.trim() || undefined,
});
options.usagePager.value = {
items: result.items.map((item) => toUsageRecordViewModel(item)),
page: result.page,
pageSize: result.pageSize,
totalCount: result.totalCount,
};
options.templateOptions.value = result.templateOptions;
options.usageStats.value = result.stats;
} catch (error) {
console.error(error);
options.usagePager.value = {
...options.usagePager.value,
items: [],
totalCount: 0,
};
options.templateOptions.value = [];
options.usageStats.value = createDefaultPunchCardUsageStats();
message.error('加载使用记录失败');
} finally {
options.isRecordLoading.value = false;
}
}
return {
loadPunchCardList,
loadStores,
loadUsageRecords,
};
}

View File

@@ -0,0 +1,333 @@
import type { Ref } from 'vue';
import type { PunchCardEditorForm } from '#/views/marketing/punch-card/types';
/**
* 文件职责:次卡主抽屉动作。
*/
import { message } from 'ant-design-vue';
import {
getMarketingPunchCardDetailApi,
saveMarketingPunchCardApi,
} from '#/api/marketing';
import {
mapDetailToEditorForm,
mapEditorFormToSaveDto,
resetEditorForm,
} from './helpers';
interface CreateDrawerActionsOptions {
canManage: Ref<boolean>;
drawerMode: Ref<'create' | 'edit'>;
form: PunchCardEditorForm;
isDrawerLoading: Ref<boolean>;
isDrawerOpen: Ref<boolean>;
isDrawerSubmitting: Ref<boolean>;
loadPunchCardList: () => Promise<void>;
loadUsageRecords: () => Promise<void>;
openProductPicker: (
initialProductIds: string[],
onConfirm: (selectedProductIds: string[]) => void,
) => Promise<void>;
selectedStoreId: Ref<string>;
}
export function createDrawerActions(options: CreateDrawerActionsOptions) {
function setDrawerOpen(value: boolean) {
options.isDrawerOpen.value = value;
}
function setFormName(value: string) {
options.form.name = value;
}
function setFormCoverImageUrl(value: string) {
options.form.coverImageUrl = value;
}
function setFormSalePrice(value: null | number) {
options.form.salePrice = value;
}
function setFormOriginalPrice(value: null | number) {
options.form.originalPrice = value;
}
function setFormTotalTimes(value: null | number) {
options.form.totalTimes = value;
}
function setFormValidityType(value: PunchCardEditorForm['validityType']) {
options.form.validityType = value;
if (value === 'days') {
options.form.validDateRange = null;
return;
}
options.form.validityDays = null;
}
function setFormValidityDays(value: null | number) {
options.form.validityDays = value;
}
function setFormValidDateRange(value: [any, any] | null) {
options.form.validDateRange = value;
}
function setFormScopeType(value: PunchCardEditorForm['scope']['scopeType']) {
options.form.scope.scopeType = value;
options.form.scope.categoryIds = [];
options.form.scope.tagIds = [];
options.form.scope.productIds = [];
}
function setScopeCategoryIds(value: string[]) {
options.form.scope.categoryIds = [...value];
}
function setScopeTagIds(value: string[]) {
options.form.scope.tagIds = [...value];
}
function setScopeProductIds(value: string[]) {
options.form.scope.productIds = [...value];
}
function setFormUsageMode(value: PunchCardEditorForm['usageMode']) {
options.form.usageMode = value;
if (value === 'free') {
options.form.usageCapAmount = null;
}
}
function setFormUsageCapAmount(value: null | number) {
options.form.usageCapAmount = value;
}
function setFormDailyLimit(value: null | number) {
options.form.dailyLimit = value;
}
function setFormPerOrderLimit(value: null | number) {
options.form.perOrderLimit = value;
}
function setFormPerUserPurchaseLimit(value: null | number) {
options.form.perUserPurchaseLimit = value;
}
function setFormAllowTransfer(value: boolean) {
options.form.allowTransfer = value;
}
function setFormExpireStrategy(value: PunchCardEditorForm['expireStrategy']) {
options.form.expireStrategy = value;
}
function setFormDescription(value: string) {
options.form.description = value;
}
function toggleNotifyChannel(value: string) {
if (options.form.notifyChannels.includes(value)) {
options.form.notifyChannels = options.form.notifyChannels.filter(
(item) => item !== value,
);
return;
}
options.form.notifyChannels = [...options.form.notifyChannels, value];
}
async function openCreateDrawer() {
if (!options.canManage.value) {
return;
}
options.drawerMode.value = 'create';
resetEditorForm(options.form);
options.isDrawerOpen.value = true;
}
async function openEditDrawer(id: string) {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
options.drawerMode.value = 'edit';
options.isDrawerLoading.value = true;
options.isDrawerOpen.value = true;
try {
const detail = await getMarketingPunchCardDetailApi({
storeId: options.selectedStoreId.value,
punchCardId: id,
});
Object.assign(options.form, mapDetailToEditorForm(detail));
} catch (error) {
console.error(error);
options.isDrawerOpen.value = false;
message.error('加载次卡详情失败');
} finally {
options.isDrawerLoading.value = false;
}
}
async function openScopeProductPicker() {
if (!options.canManage.value) {
return;
}
await options.openProductPicker(
options.form.scope.productIds,
(selectedProductIds) => {
options.form.scope.scopeType = 'product';
options.form.scope.productIds = [...selectedProductIds];
},
);
}
async function submitDrawer() {
if (!options.canManage.value) {
return;
}
if (!options.selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
const name = options.form.name.trim();
if (!name) {
message.warning('请输入次卡名称');
return;
}
if (!options.form.salePrice || options.form.salePrice <= 0) {
message.warning('售价必须大于 0');
return;
}
if (!options.form.totalTimes || options.form.totalTimes <= 0) {
message.warning('总次数必须大于 0');
return;
}
if (
options.form.originalPrice !== null &&
options.form.originalPrice !== undefined &&
options.form.originalPrice > 0 &&
options.form.originalPrice < options.form.salePrice
) {
message.warning('原价不能小于售价');
return;
}
if (
options.form.validityType === 'days' &&
(!options.form.validityDays || options.form.validityDays <= 0)
) {
message.warning('请输入有效天数');
return;
}
if (options.form.validityType === 'range' && !options.form.validDateRange) {
message.warning('请选择有效日期范围');
return;
}
if (
options.form.scope.scopeType === 'category' &&
options.form.scope.categoryIds.length === 0
) {
message.warning('请至少选择一个分类');
return;
}
if (
options.form.scope.scopeType === 'tag' &&
options.form.scope.tagIds.length === 0
) {
message.warning('请至少选择一个标签');
return;
}
if (
options.form.scope.scopeType === 'product' &&
options.form.scope.productIds.length === 0
) {
message.warning('请至少选择一个商品');
return;
}
if (
options.form.usageMode === 'cap' &&
(!options.form.usageCapAmount || options.form.usageCapAmount <= 0)
) {
message.warning('请输入单次金额上限');
return;
}
if (options.form.notifyChannels.length === 0) {
message.warning('请至少选择一种购买通知方式');
return;
}
options.isDrawerSubmitting.value = true;
try {
const payload = mapEditorFormToSaveDto(
options.form,
options.selectedStoreId.value,
);
await saveMarketingPunchCardApi(payload);
message.success('保存成功');
options.isDrawerOpen.value = false;
await Promise.all([
options.loadPunchCardList(),
options.loadUsageRecords(),
]);
} catch (error) {
console.error(error);
message.error('保存失败');
} finally {
options.isDrawerSubmitting.value = false;
}
}
return {
openCreateDrawer,
openEditDrawer,
openScopeProductPicker,
setDrawerOpen,
setFormAllowTransfer,
setFormCoverImageUrl,
setFormDailyLimit,
setFormDescription,
setFormExpireStrategy,
setFormName,
setFormOriginalPrice,
setFormPerOrderLimit,
setFormPerUserPurchaseLimit,
setFormSalePrice,
setFormScopeType,
setFormTotalTimes,
setFormUsageCapAmount,
setFormUsageMode,
setFormValidDateRange,
setFormValidityDays,
setFormValidityType,
setScopeCategoryIds,
setScopeProductIds,
setScopeTagIds,
submitDrawer,
toggleNotifyChannel,
};
}

View File

@@ -0,0 +1,242 @@
import type { Dayjs } from 'dayjs';
import type {
MarketingPunchCardDetailDto,
MarketingPunchCardUsageRecordDto,
SaveMarketingPunchCardDto,
} from '#/api/marketing';
import type {
PunchCardEditorForm,
PunchCardUsageRecordViewModel,
} from '#/views/marketing/punch-card/types';
import dayjs from 'dayjs';
import {
createDefaultPunchCardEditorForm,
createDefaultPunchCardScopeForm,
PUNCH_CARD_RECORD_STATUS_TEXT_MAP,
} from './constants';
/**
* 文件职责:次卡页面工具方法。
*/
/** 金额格式化。 */
export function formatCurrency(value: null | number | undefined) {
const amount = Number(value ?? 0);
if (Number.isNaN(amount)) {
return '¥0';
}
if (Math.abs(amount) >= 1000) {
return `¥${amount.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}`;
}
return `¥${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`;
}
/** 记录状态转文案。 */
export function resolveUsageStatusText(status: string) {
return PUNCH_CARD_RECORD_STATUS_TEXT_MAP[status] ?? '正常使用';
}
/** 记录状态转样式类。 */
export function resolveUsageStatusClass(status: string) {
if (status === 'normal') {
return 'is-green';
}
if (status === 'almost_used_up') {
return 'is-orange';
}
return 'is-gray';
}
/** DTO 转列表视图模型。 */
export function toUsageRecordViewModel(
source: MarketingPunchCardUsageRecordDto,
): PunchCardUsageRecordViewModel {
return {
...source,
displayStatusText: resolveUsageStatusText(source.displayStatus),
};
}
/** 详情转编辑表单。 */
export function mapDetailToEditorForm(
detail: MarketingPunchCardDetailDto,
): PunchCardEditorForm {
const dateRange = toValidDateRange(detail.validFrom, detail.validTo);
const form = createDefaultPunchCardEditorForm();
form.id = detail.id;
form.name = detail.name;
form.coverImageUrl = detail.coverImageUrl ?? '';
form.salePrice = detail.salePrice;
form.originalPrice = detail.originalPrice;
form.totalTimes = detail.totalTimes;
form.validityType = detail.validityType;
form.validityDays = detail.validityDays;
form.validDateRange = dateRange;
form.scope = {
scopeType: detail.scope.scopeType,
categoryIds: [...detail.scope.categoryIds],
tagIds: [...detail.scope.tagIds],
productIds: [...detail.scope.productIds],
};
form.usageMode = detail.usageMode;
form.usageCapAmount = detail.usageCapAmount;
form.dailyLimit = detail.dailyLimit;
form.perOrderLimit = detail.perOrderLimit;
form.perUserPurchaseLimit = detail.perUserPurchaseLimit;
form.allowTransfer = detail.allowTransfer;
form.expireStrategy = detail.expireStrategy;
form.description = detail.description ?? '';
form.notifyChannels =
detail.notifyChannels.length > 0 ? [...detail.notifyChannels] : ['in_app'];
form.status = detail.status;
return form;
}
/** 编辑表单转保存 DTO。 */
export function mapEditorFormToSaveDto(
form: PunchCardEditorForm,
storeId: string,
): SaveMarketingPunchCardDto {
const validFrom =
form.validityType === 'range' && form.validDateRange
? form.validDateRange[0].format('YYYY-MM-DD')
: null;
const validTo =
form.validityType === 'range' && form.validDateRange
? form.validDateRange[1].format('YYYY-MM-DD')
: null;
return {
id: form.id || undefined,
storeId,
name: form.name.trim(),
coverImageUrl: form.coverImageUrl.trim() || undefined,
salePrice: Number(form.salePrice ?? 0),
originalPrice:
form.originalPrice === null || form.originalPrice === undefined
? null
: Number(form.originalPrice),
totalTimes: Number(form.totalTimes ?? 0),
validityType: form.validityType,
validityDays:
form.validityType === 'days'
? Number(form.validityDays ?? 0) || null
: null,
validFrom,
validTo,
scopeType: form.scope.scopeType,
scopeCategoryIds: [...form.scope.categoryIds],
scopeTagIds: [...form.scope.tagIds],
scopeProductIds: [...form.scope.productIds],
usageMode: form.usageMode,
usageCapAmount:
form.usageMode === 'cap'
? Number(form.usageCapAmount ?? 0) || null
: null,
dailyLimit:
form.dailyLimit === null || form.dailyLimit === undefined
? null
: Number(form.dailyLimit) || null,
perOrderLimit:
form.perOrderLimit === null || form.perOrderLimit === undefined
? null
: Number(form.perOrderLimit) || null,
perUserPurchaseLimit:
form.perUserPurchaseLimit === null ||
form.perUserPurchaseLimit === undefined
? null
: Number(form.perUserPurchaseLimit) || null,
allowTransfer: form.allowTransfer,
expireStrategy: form.expireStrategy,
description: form.description.trim() || undefined,
notifyChannels: [...form.notifyChannels],
};
}
/** 创建空编辑表单。 */
export function resetEditorForm(form: PunchCardEditorForm) {
const value = createDefaultPunchCardEditorForm();
form.id = value.id;
form.name = value.name;
form.coverImageUrl = value.coverImageUrl;
form.salePrice = value.salePrice;
form.originalPrice = value.originalPrice;
form.totalTimes = value.totalTimes;
form.validityType = value.validityType;
form.validityDays = value.validityDays;
form.validDateRange = value.validDateRange;
form.scope = createDefaultPunchCardScopeForm(value.scope.scopeType);
form.usageMode = value.usageMode;
form.usageCapAmount = value.usageCapAmount;
form.dailyLimit = value.dailyLimit;
form.perOrderLimit = value.perOrderLimit;
form.perUserPurchaseLimit = value.perUserPurchaseLimit;
form.allowTransfer = value.allowTransfer;
form.expireStrategy = value.expireStrategy;
form.description = value.description;
form.notifyChannels = [...value.notifyChannels];
form.status = value.status;
}
/** 深拷贝范围。 */
export function cloneScope(scope: PunchCardEditorForm['scope']) {
return {
scopeType: scope.scopeType,
categoryIds: [...scope.categoryIds],
tagIds: [...scope.tagIds],
productIds: [...scope.productIds],
};
}
/** base64 下载。 */
export function downloadBase64File(
fileName: string,
fileContentBase64: string,
) {
const blob = decodeBase64ToBlob(fileContentBase64);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function toValidDateRange(
validFrom: null | string,
validTo: null | string,
): [Dayjs, Dayjs] | null {
if (!validFrom || !validTo) {
return null;
}
const from = dayjs(validFrom);
const to = dayjs(validTo);
if (!from.isValid() || !to.isValid()) {
return null;
}
return [from, to];
}
function decodeBase64ToBlob(base64: string) {
const binary = atob(base64);
const length = binary.length;
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index++) {
bytes[index] = binary.codePointAt(index) ?? 0;
}
return new Blob([bytes], { type: 'text/csv;charset=utf-8;' });
}

View File

@@ -0,0 +1,160 @@
import type { Ref } from 'vue';
import type {
PunchCardPickerCategoryItem,
PunchCardPickerProductItem,
} from '#/views/marketing/punch-card/types';
/**
* 文件职责:次卡二级商品选择抽屉动作。
*/
import { message } from 'ant-design-vue';
import {
getProductCategoryListApi,
searchProductPickerApi,
} from '#/api/product';
interface CreatePickerActionsOptions {
isPickerLoading: Ref<boolean>;
isPickerOpen: Ref<boolean>;
pickerCategories: Ref<PunchCardPickerCategoryItem[]>;
pickerCategoryFilterId: Ref<string>;
pickerKeyword: Ref<string>;
pickerProducts: Ref<PunchCardPickerProductItem[]>;
pickerSelectedProductIds: Ref<string[]>;
resolveStoreId: () => string;
}
export function createPickerActions(options: CreatePickerActionsOptions) {
let onConfirm: ((productIds: string[]) => void) | null = null;
function setPickerOpen(value: boolean) {
options.isPickerOpen.value = value;
if (!value) {
onConfirm = null;
}
}
function setPickerKeyword(value: string) {
options.pickerKeyword.value = value;
}
function setPickerCategoryFilterId(value: string) {
options.pickerCategoryFilterId.value = value;
}
function setPickerSelectedProductIds(value: string[]) {
options.pickerSelectedProductIds.value = [...value];
}
function togglePickerProduct(id: string) {
if (options.pickerSelectedProductIds.value.includes(id)) {
options.pickerSelectedProductIds.value =
options.pickerSelectedProductIds.value.filter((item) => item !== id);
return;
}
options.pickerSelectedProductIds.value = [
...options.pickerSelectedProductIds.value,
id,
];
}
async function loadPickerCategories() {
const storeId = options.resolveStoreId();
if (!storeId) {
options.pickerCategories.value = [];
return;
}
options.pickerCategories.value = await getProductCategoryListApi(storeId);
}
async function loadPickerProducts() {
const storeId = options.resolveStoreId();
if (!storeId) {
options.pickerProducts.value = [];
return;
}
options.pickerProducts.value = await searchProductPickerApi({
storeId,
keyword: options.pickerKeyword.value.trim() || undefined,
categoryId: options.pickerCategoryFilterId.value || undefined,
limit: 500,
});
}
async function reloadPickerData() {
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 searchPickerProducts() {
options.isPickerLoading.value = true;
try {
await loadPickerProducts();
} catch (error) {
console.error(error);
options.pickerProducts.value = [];
message.error('加载商品失败');
} finally {
options.isPickerLoading.value = false;
}
}
async function openProductPicker(
initialProductIds: string[],
callback: (productIds: string[]) => void,
) {
const storeId = options.resolveStoreId();
if (!storeId) {
message.warning('请先选择门店后再选择商品');
return;
}
options.pickerSelectedProductIds.value = [...initialProductIds];
options.pickerKeyword.value = '';
options.pickerCategoryFilterId.value = '';
onConfirm = callback;
options.isPickerOpen.value = true;
await reloadPickerData();
}
function submitPicker() {
if (options.pickerSelectedProductIds.value.length === 0) {
message.warning('请至少选择一个商品');
return;
}
onConfirm?.([...options.pickerSelectedProductIds.value]);
setPickerOpen(false);
}
return {
openProductPicker,
reloadPickerData,
searchPickerProducts,
setPickerCategoryFilterId,
setPickerKeyword,
setPickerOpen,
setPickerSelectedProductIds,
submitPicker,
togglePickerProduct,
};
}

View File

@@ -0,0 +1,470 @@
import type { StoreListItemDto } from '#/api/store';
import type {
PunchCardPickerCategoryItem,
PunchCardPickerProductItem,
PunchCardTabKey,
PunchCardTemplateCardViewModel,
PunchCardTemplateOptionViewModel,
PunchCardUsagePager,
} from '#/views/marketing/punch-card/types';
/**
* 文件职责:次卡页面状态与行为编排。
*/
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { exportMarketingPunchCardUsageRecordApi } from '#/api/marketing';
import {
getProductCategoryListApi,
getProductLabelListApi,
} from '#/api/product';
import { createDefaultPunchCardUsagePager } from '../types';
import { createCardActions } from './punch-card-page/card-actions';
import {
createDefaultPunchCardEditorForm,
createDefaultPunchCardListFilterForm,
createDefaultPunchCardUsageFilterForm,
createDefaultPunchCardUsageStats,
PUNCH_CARD_MANAGE_PERMISSION,
} from './punch-card-page/constants';
import { createDataActions } from './punch-card-page/data-actions';
import { createDrawerActions } from './punch-card-page/drawer-actions';
import { downloadBase64File } from './punch-card-page/helpers';
import { createPickerActions } from './punch-card-page/picker-actions';
export function useMarketingPunchCardPage() {
const accessStore = useAccessStore();
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const activeTab = ref<PunchCardTabKey>('list');
const listFilterForm = reactive(createDefaultPunchCardListFilterForm());
const listKeyword = ref('');
const listPage = ref(1);
const listPageSize = ref(4);
const listRows = ref<PunchCardTemplateCardViewModel[]>([]);
const listTotalCount = ref(0);
const listStats = ref({
onSaleCount: 0,
totalSoldCount: 0,
totalRevenueAmount: 0,
activeInUseCount: 0,
});
const isListLoading = ref(false);
const recordFilterForm = reactive(createDefaultPunchCardUsageFilterForm());
const recordKeyword = ref('');
const usagePager = ref<PunchCardUsagePager>(
createDefaultPunchCardUsagePager(),
);
const usageStats = ref(createDefaultPunchCardUsageStats());
const templateOptions = ref<PunchCardTemplateOptionViewModel[]>([]);
const isRecordLoading = ref(false);
const isDrawerOpen = ref(false);
const isDrawerLoading = ref(false);
const isDrawerSubmitting = ref(false);
const drawerMode = ref<'create' | 'edit'>('create');
const form = reactive(createDefaultPunchCardEditorForm());
const isPickerOpen = ref(false);
const isPickerLoading = ref(false);
const pickerKeyword = ref('');
const pickerCategoryFilterId = ref('');
const pickerCategories = ref<PunchCardPickerCategoryItem[]>([]);
const pickerProducts = ref<PunchCardPickerProductItem[]>([]);
const pickerSelectedProductIds = ref<string[]>([]);
const scopeCategoryOptions = ref<Array<{ label: string; value: string }>>([]);
const scopeTagOptions = ref<Array<{ label: string; value: string }>>([]);
const accessCodeSet = computed(
() => new Set((accessStore.accessCodes ?? []).map(String)),
);
const canManage = computed(() =>
accessCodeSet.value.has(PUNCH_CARD_MANAGE_PERMISSION),
);
const hasStore = computed(() => stores.value.length > 0);
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const pickerCategoryOptions = computed(() => [
{ label: '全部分类', value: '' },
...pickerCategories.value.map((item) => ({
label: item.name,
value: item.id,
})),
]);
const drawerTitle = computed(() =>
drawerMode.value === 'create' ? '创建次卡' : '编辑次卡',
);
const drawerSubmitText = computed(() => '保存');
function resolveStoreId() {
return selectedStoreId.value;
}
const { loadStores, loadPunchCardList, loadUsageRecords } = createDataActions(
{
stores,
selectedStoreId,
isStoreLoading,
isListLoading,
listFilterForm,
listKeyword,
listPage,
listPageSize,
listRows,
listStats,
listTotalCount,
isRecordLoading,
recordFilterForm,
recordKeyword,
usagePager,
usageStats,
templateOptions,
},
);
const {
openProductPicker,
reloadPickerData,
searchPickerProducts,
setPickerCategoryFilterId,
setPickerKeyword,
setPickerOpen,
setPickerSelectedProductIds,
submitPicker,
togglePickerProduct,
} = createPickerActions({
isPickerLoading,
isPickerOpen,
pickerCategories,
pickerCategoryFilterId,
pickerKeyword,
pickerProducts,
pickerSelectedProductIds,
resolveStoreId,
});
const {
openCreateDrawer,
openEditDrawer,
openScopeProductPicker,
setDrawerOpen,
setFormAllowTransfer,
setFormCoverImageUrl,
setFormDailyLimit,
setFormDescription,
setFormExpireStrategy,
setFormName,
setFormOriginalPrice,
setFormPerOrderLimit,
setFormPerUserPurchaseLimit,
setFormSalePrice,
setFormScopeType,
setFormTotalTimes,
setFormUsageCapAmount,
setFormUsageMode,
setFormValidDateRange,
setFormValidityDays,
setFormValidityType,
setScopeCategoryIds,
setScopeProductIds,
setScopeTagIds,
submitDrawer,
toggleNotifyChannel,
} = createDrawerActions({
canManage,
form,
drawerMode,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
loadPunchCardList,
loadUsageRecords,
selectedStoreId,
openProductPicker,
});
const { removeCard, toggleStatus } = createCardActions({
canManage,
selectedStoreId,
loadPunchCardList,
loadUsageRecords,
});
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setActiveTab(value: PunchCardTabKey) {
activeTab.value = value;
}
function setListKeyword(value: string) {
listKeyword.value = value;
}
function setListStatusFilter(value: '' | 'disabled' | 'enabled') {
listFilterForm.status = value;
}
function setRecordKeyword(value: string) {
recordKeyword.value = value;
}
function setRecordTemplateFilter(value: string) {
recordFilterForm.templateId = value;
}
function setRecordStatusFilter(value: '' | 'expired' | 'normal' | 'used_up') {
recordFilterForm.status = value;
}
async function applyListFilters() {
listPage.value = 1;
await loadPunchCardList();
}
async function resetListFilters() {
listFilterForm.status = '';
listKeyword.value = '';
listPage.value = 1;
await loadPunchCardList();
}
async function handleListPageChange(page: number, pageSize: number) {
listPage.value = page;
listPageSize.value = pageSize;
await loadPunchCardList();
}
async function applyRecordFilters() {
usagePager.value = {
...usagePager.value,
page: 1,
};
await loadUsageRecords();
}
async function resetRecordFilters() {
recordFilterForm.templateId = '';
recordFilterForm.status = '';
recordKeyword.value = '';
usagePager.value = {
...usagePager.value,
page: 1,
};
await loadUsageRecords();
}
async function handleRecordPageChange(page: number, pageSize: number) {
usagePager.value = {
...usagePager.value,
page,
pageSize,
};
await loadUsageRecords();
}
async function handlePickerCategoryChange(value: string) {
setPickerCategoryFilterId(value);
await searchPickerProducts();
}
async function handlePickerSearch() {
await searchPickerProducts();
}
async function exportUsageRecords() {
if (!selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
try {
const result = await exportMarketingPunchCardUsageRecordApi({
storeId: selectedStoreId.value,
punchCardId: recordFilterForm.templateId || undefined,
status: recordFilterForm.status,
keyword: recordKeyword.value.trim() || undefined,
});
downloadBase64File(result.fileName, result.fileContentBase64);
message.success(`导出成功,共 ${result.totalCount}`);
} catch (error) {
console.error(error);
message.error('导出失败');
}
}
async function loadScopeOptions() {
if (!selectedStoreId.value) {
scopeCategoryOptions.value = [];
scopeTagOptions.value = [];
return;
}
try {
const [categories, labels] = await Promise.all([
getProductCategoryListApi(selectedStoreId.value),
getProductLabelListApi({
storeId: selectedStoreId.value,
status: 'enabled',
}),
]);
scopeCategoryOptions.value = categories.map((item) => ({
label: item.name,
value: item.id,
}));
scopeTagOptions.value = labels.map((item) => ({
label: item.name,
value: item.id,
}));
} catch (error) {
console.error(error);
scopeCategoryOptions.value = [];
scopeTagOptions.value = [];
}
}
watch(selectedStoreId, async () => {
listPage.value = 1;
listFilterForm.status = '';
listKeyword.value = '';
usagePager.value = {
...usagePager.value,
page: 1,
pageSize: 10,
};
recordFilterForm.templateId = '';
recordFilterForm.status = '';
recordKeyword.value = '';
await Promise.all([
loadScopeOptions(),
loadPunchCardList(),
loadUsageRecords(),
]);
});
onMounted(async () => {
await loadStores();
if (selectedStoreId.value) {
await Promise.all([
loadScopeOptions(),
loadPunchCardList(),
loadUsageRecords(),
]);
}
});
return {
activeTab,
applyListFilters,
applyRecordFilters,
canManage,
drawerSubmitText,
drawerTitle,
drawerMode,
exportUsageRecords,
form,
handleListPageChange,
handlePickerCategoryChange,
handlePickerSearch,
handleRecordPageChange,
hasStore,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
isListLoading,
isPickerLoading,
isPickerOpen,
isRecordLoading,
isStoreLoading,
listFilterForm,
listKeyword,
listPage,
listPageSize,
listRows,
listStats,
listTotalCount,
openCreateDrawer,
openEditDrawer,
openScopeProductPicker,
pickerCategories,
pickerCategoryFilterId,
pickerCategoryOptions,
pickerKeyword,
pickerProducts,
pickerSelectedProductIds,
recordFilterForm,
recordKeyword,
reloadPickerData,
removeCard,
resetListFilters,
resetRecordFilters,
scopeCategoryOptions,
scopeTagOptions,
selectedStoreId,
setActiveTab,
setDrawerOpen,
setFormAllowTransfer,
setFormCoverImageUrl,
setFormDailyLimit,
setFormDescription,
setFormExpireStrategy,
setFormName,
setFormOriginalPrice,
setFormPerOrderLimit,
setFormPerUserPurchaseLimit,
setFormSalePrice,
setFormScopeType,
setFormTotalTimes,
setFormUsageCapAmount,
setFormUsageMode,
setFormValidDateRange,
setFormValidityDays,
setFormValidityType,
setListKeyword,
setListStatusFilter,
setPickerCategoryFilterId,
setPickerKeyword,
setPickerOpen,
setPickerSelectedProductIds,
setRecordKeyword,
setRecordStatusFilter,
setRecordTemplateFilter,
setScopeCategoryIds,
setScopeProductIds,
setScopeTagIds,
setSelectedStoreId,
storeOptions,
submitDrawer,
submitPicker,
templateOptions,
toggleNotifyChannel,
togglePickerProduct,
toggleStatus,
usagePager,
usageStats,
};
}

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
/**
* 文件职责:营销中心-次卡管理页面主视图。
*/
import type {
MarketingPunchCardStatus,
MarketingPunchCardUsageFilterStatus,
} from '#/api/marketing';
import { Page } from '@vben/common-ui';
import { Button, Empty, Input, Pagination, Select, Spin } from 'ant-design-vue';
import PunchCardEditorDrawer from './components/PunchCardEditorDrawer.vue';
import PunchCardProductPickerDrawer from './components/PunchCardProductPickerDrawer.vue';
import PunchCardStatsCards from './components/PunchCardStatsCards.vue';
import PunchCardTemplateCard from './components/PunchCardTemplateCard.vue';
import PunchCardUsageRecordTable from './components/PunchCardUsageRecordTable.vue';
import PunchCardUsageStatsCards from './components/PunchCardUsageStatsCards.vue';
import {
PUNCH_CARD_RECORD_STATUS_FILTER_OPTIONS,
PUNCH_CARD_STATUS_FILTER_OPTIONS,
} from './composables/punch-card-page/constants';
import { useMarketingPunchCardPage } from './composables/useMarketingPunchCardPage';
const {
activeTab,
applyListFilters,
applyRecordFilters,
canManage,
drawerSubmitText,
drawerTitle,
exportUsageRecords,
form,
handleListPageChange,
handlePickerCategoryChange,
handlePickerSearch,
handleRecordPageChange,
hasStore,
isDrawerLoading,
isDrawerOpen,
isDrawerSubmitting,
isListLoading,
isPickerLoading,
isPickerOpen,
isRecordLoading,
isStoreLoading,
listFilterForm,
listKeyword,
listPage,
listPageSize,
listRows,
listStats,
listTotalCount,
openCreateDrawer,
openEditDrawer,
openScopeProductPicker,
pickerCategoryFilterId,
pickerCategoryOptions,
pickerKeyword,
pickerProducts,
pickerSelectedProductIds,
recordFilterForm,
recordKeyword,
removeCard,
resetListFilters,
resetRecordFilters,
scopeCategoryOptions,
scopeTagOptions,
selectedStoreId,
setActiveTab,
setDrawerOpen,
setFormAllowTransfer,
setFormCoverImageUrl,
setFormDailyLimit,
setFormDescription,
setFormExpireStrategy,
setFormName,
setFormOriginalPrice,
setFormPerOrderLimit,
setFormPerUserPurchaseLimit,
setFormSalePrice,
setFormScopeType,
setFormTotalTimes,
setFormUsageCapAmount,
setFormUsageMode,
setFormValidDateRange,
setFormValidityDays,
setFormValidityType,
setListKeyword,
setListStatusFilter,
setPickerKeyword,
setPickerOpen,
setRecordKeyword,
setRecordStatusFilter,
setRecordTemplateFilter,
setScopeCategoryIds,
setScopeProductIds,
setScopeTagIds,
setSelectedStoreId,
storeOptions,
submitDrawer,
submitPicker,
templateOptions,
toggleNotifyChannel,
togglePickerProduct,
toggleStatus,
usagePager,
usageStats,
} = useMarketingPunchCardPage();
function onListStatusFilterChange(value: unknown) {
const next =
typeof value === 'string' && value
? (value as MarketingPunchCardStatus)
: '';
setListStatusFilter(next);
void applyListFilters();
}
function onRecordStatusFilterChange(value: unknown) {
const next =
typeof value === 'string' && value
? (value as MarketingPunchCardUsageFilterStatus)
: '';
setRecordStatusFilter(next);
void applyRecordFilters();
}
function onRecordTemplateFilterChange(value: unknown) {
const next = typeof value === 'string' ? value : '';
setRecordTemplateFilter(next);
void applyRecordFilters();
}
</script>
<template>
<Page title="次卡管理" content-class="page-marketing-punch-card">
<div class="mpc-page">
<div class="mpc-toolbar mpc-toolbar-top">
<Select
class="mpc-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<div class="mpc-segments">
<button
type="button"
class="mpc-segment-item"
:class="{ active: activeTab === 'list' }"
@click="setActiveTab('list')"
>
次卡列表
</button>
<button
type="button"
class="mpc-segment-item"
:class="{ active: activeTab === 'records' }"
@click="setActiveTab('records')"
>
使用记录
</button>
</div>
<span v-if="!canManage" class="mpc-readonly-tip">当前为只读权限</span>
</div>
<div v-if="!hasStore" class="mpc-empty">暂无门店请先创建门店</div>
<template v-else>
<section v-show="activeTab === 'list'" class="mpc-tab-panel">
<PunchCardStatsCards :stats="listStats" />
<div class="mpc-toolbar">
<Select
class="mpc-filter-select"
:value="listFilterForm.status"
:options="PUNCH_CARD_STATUS_FILTER_OPTIONS"
placeholder="全部状态"
@update:value="onListStatusFilterChange"
/>
<Input
class="mpc-search"
:value="listKeyword"
placeholder="搜索次卡名称"
allow-clear
@update:value="(value) => setListKeyword(String(value ?? ''))"
@press-enter="applyListFilters"
/>
<Button @click="applyListFilters">搜索</Button>
<Button @click="resetListFilters">重置</Button>
<span class="mpc-spacer"></span>
<Button
type="primary"
:disabled="!canManage"
@click="openCreateDrawer"
>
创建次卡
</Button>
</div>
<Spin :spinning="isListLoading">
<div v-if="listRows.length > 0" class="mpc-card-grid">
<PunchCardTemplateCard
v-for="item in listRows"
:key="item.id"
:item="item"
:can-manage="canManage"
@edit="(row) => openEditDrawer(row.id)"
@toggle-status="toggleStatus"
@remove="removeCard"
/>
</div>
<div v-else class="mpc-empty">
<Empty description="暂无次卡" />
</div>
<div v-if="listRows.length > 0" class="mpc-pagination">
<Pagination
:current="listPage"
:page-size="listPageSize"
:total="listTotalCount"
show-size-changer
:page-size-options="['4', '8', '12', '20']"
:show-total="(value) => `${value}`"
@change="handleListPageChange"
/>
</div>
</Spin>
</section>
<section v-show="activeTab === 'records'" class="mpc-tab-panel">
<div class="mpc-toolbar">
<Select
class="mpc-filter-select"
:value="recordFilterForm.templateId"
:options="[
{ label: '全部次卡', value: '' },
...templateOptions.map((item) => ({
label: item.name,
value: item.templateId,
})),
]"
placeholder="全部次卡"
@update:value="onRecordTemplateFilterChange"
/>
<Select
class="mpc-filter-select"
:value="recordFilterForm.status"
:options="PUNCH_CARD_RECORD_STATUS_FILTER_OPTIONS"
placeholder="全部状态"
@update:value="onRecordStatusFilterChange"
/>
<Input
class="mpc-search"
:value="recordKeyword"
placeholder="搜索会员/商品"
allow-clear
@update:value="(value) => setRecordKeyword(String(value ?? ''))"
@press-enter="applyRecordFilters"
/>
<Button @click="applyRecordFilters">搜索</Button>
<Button @click="resetRecordFilters">重置</Button>
<span class="mpc-spacer"></span>
<Button :disabled="!canManage" @click="exportUsageRecords">
导出
</Button>
</div>
<PunchCardUsageStatsCards :stats="usageStats" />
<div class="mpc-table-panel">
<PunchCardUsageRecordTable
:records="usagePager.items"
:loading="isRecordLoading"
:page="usagePager.page"
:page-size="usagePager.pageSize"
:total="usagePager.totalCount"
@page-change="handleRecordPageChange"
/>
</div>
</section>
</template>
</div>
<PunchCardEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
:submit-text="drawerSubmitText"
:submitting="isDrawerSubmitting"
:loading="isDrawerLoading"
:can-manage="canManage"
:form="form"
:category-options="scopeCategoryOptions"
:tag-options="scopeTagOptions"
:scope-product-names="
form.scope.productIds.map((id) => {
const product = pickerProducts.find((item) => item.id === id);
return {
id,
name: product?.name ?? id,
};
})
"
@close="setDrawerOpen(false)"
@set-name="setFormName"
@set-cover-image-url="setFormCoverImageUrl"
@set-sale-price="setFormSalePrice"
@set-original-price="setFormOriginalPrice"
@set-total-times="setFormTotalTimes"
@set-validity-type="setFormValidityType"
@set-validity-days="setFormValidityDays"
@set-valid-date-range="setFormValidDateRange"
@set-scope-type="setFormScopeType"
@set-scope-category-ids="setScopeCategoryIds"
@set-scope-tag-ids="setScopeTagIds"
@set-scope-product-ids="setScopeProductIds"
@open-scope-product-picker="openScopeProductPicker"
@set-usage-mode="setFormUsageMode"
@set-usage-cap-amount="setFormUsageCapAmount"
@set-daily-limit="setFormDailyLimit"
@set-per-order-limit="setFormPerOrderLimit"
@set-per-user-purchase-limit="setFormPerUserPurchaseLimit"
@set-allow-transfer="setFormAllowTransfer"
@set-expire-strategy="setFormExpireStrategy"
@set-description="setFormDescription"
@toggle-notify-channel="toggleNotifyChannel"
@submit="submitDrawer"
/>
<PunchCardProductPickerDrawer
:open="isPickerOpen"
:loading="isPickerLoading"
:keyword="pickerKeyword"
:category-filter-id="pickerCategoryFilterId"
:category-options="pickerCategoryOptions"
:products="pickerProducts"
:selected-product-ids="pickerSelectedProductIds"
@close="setPickerOpen(false)"
@set-keyword="setPickerKeyword"
@set-category-filter-id="handlePickerCategoryChange"
@toggle-product="togglePickerProduct"
@search="handlePickerSearch"
@submit="submitPicker"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,29 @@
/**
* 文件职责:次卡页面基础变量。
*/
.page-marketing-punch-card {
--mpc-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1);
--mpc-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
--mpc-shadow-md: 0 8px 22px rgb(0 0 0 / 10%), 0 2px 6px rgb(0 0 0 / 8%);
--mpc-border: #e7eaf0;
--mpc-text: #1f2937;
--mpc-subtext: #6b7280;
--mpc-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,196 @@
/**
* 文件职责:次卡卡片样式。
*/
.page-marketing-punch-card {
.mpc-card {
display: flex;
overflow: hidden;
background: #fff;
border: 1px solid var(--mpc-border);
border-radius: 12px;
box-shadow: var(--mpc-shadow-sm);
transition: box-shadow var(--mpc-transition);
}
.mpc-card:hover {
box-shadow: var(--mpc-shadow-md);
}
.mpc-card-off {
opacity: 0.58;
}
.mpc-card-cover {
flex-shrink: 0;
width: 148px;
min-height: 188px;
overflow: hidden;
background: linear-gradient(135deg, #0f172a, #334155);
}
.mpc-card-cover-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.mpc-card-cover-fallback {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
}
.mpc-card-cover-icon {
width: 64px;
height: 64px;
opacity: 0.28;
}
.mpc-card-cover-count {
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.mpc-card-cover-count small {
margin-left: 2px;
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
.mpc-card-body {
display: flex;
flex: 1;
flex-direction: column;
padding: 15px 16px 13px;
}
.mpc-card-name-row {
display: flex;
gap: 8px;
align-items: center;
}
.mpc-card-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
color: var(--mpc-text);
white-space: nowrap;
}
.mpc-scope-tag {
display: inline-flex;
flex-shrink: 0;
align-items: center;
height: 22px;
padding: 0 8px;
font-size: 11px;
border-radius: 999px;
}
.mpc-scope-tag.is-blue {
color: #1677ff;
background: #e6f4ff;
}
.mpc-scope-tag.is-green {
color: #16a34a;
background: #dcfce7;
}
.mpc-scope-tag.is-purple {
color: #7c3aed;
background: #f3e8ff;
}
.mpc-scope-tag.is-orange {
color: #d97706;
background: #fef3c7;
}
.mpc-card-price-row {
display: flex;
gap: 8px;
align-items: baseline;
margin-top: 6px;
}
.mpc-card-price-now {
font-size: 24px;
font-weight: 700;
line-height: 1;
color: #ef4444;
}
.mpc-card-price-origin {
font-size: 13px;
color: #9ca3af;
text-decoration: line-through;
}
.mpc-card-info-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.mpc-card-info-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
font-size: 11px;
color: #4b5563;
background: #f5f5f5;
border-radius: 5px;
}
.mpc-card-meta {
display: flex;
gap: 14px;
align-items: center;
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
.mpc-card-actions {
display: flex;
gap: 6px;
align-items: center;
padding-top: 10px;
margin-top: auto;
border-top: 1px solid #f3f4f6;
}
.mpc-status-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
margin-left: auto;
font-size: 11px;
border-radius: 999px;
}
.mpc-status-tag.is-green {
color: #166534;
background: #dcfce7;
}
.mpc-status-tag.is-gray {
color: #475569;
background: #e2e8f0;
}
}

View File

@@ -0,0 +1,328 @@
/**
* 文件职责:次卡主抽屉与二级抽屉样式。
*/
.mpc-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;
}
.ant-input,
.ant-input-number,
.ant-picker,
.ant-select-selector {
border-radius: 6px !important;
}
.mpc-editor-form .ant-form-item {
margin-bottom: 14px;
}
.mpc-inline-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.mpc-inline-row .ant-form-item:last-child {
grid-column: span 2;
}
.mpc-inline-fields {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
font-size: 13px;
color: #4b5563;
}
.mpc-inline-fields .ant-input-number {
width: 110px;
}
.mpc-range-picker {
width: 100%;
margin-top: 8px;
}
.mpc-form-hint {
margin-top: 6px;
font-size: 12px;
color: #9ca3af;
}
.mpc-cover-uploader {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.mpc-cover-preview {
width: 108px;
height: 76px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 10%);
}
.mpc-section-divider {
height: 1px;
margin: 18px 0;
background: linear-gradient(
90deg,
rgb(229 231 235 / 0%) 0%,
rgb(229 231 235 / 100%) 16%,
rgb(229 231 235 / 100%) 84%,
rgb(229 231 235 / 0%) 100%
);
}
.mpc-notify-pills {
display: flex;
gap: 8px;
align-items: center;
}
.mpc-notify-pill {
height: 30px;
padding: 0 14px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.2s;
}
.mpc-notify-pill:hover {
color: #1677ff;
border-color: #91caff;
}
.mpc-notify-pill.checked {
color: #1677ff;
background: #e8f3ff;
border-color: #91caff;
}
.mpc-selected-products {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mpc-selected-product {
display: inline-flex;
gap: 4px;
align-items: center;
height: 24px;
padding: 0 8px;
font-size: 12px;
color: #4b5563;
background: #f5f5f5;
border-radius: 6px;
}
.mpc-selected-product-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mpc-selected-product-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #94a3b8;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 4px;
}
.mpc-selected-product-remove:hover {
color: #ef4444;
background: #fef2f2;
}
.mpc-drawer-footer {
display: flex;
gap: 8px;
justify-content: flex-start;
}
}
.mpc-picker-drawer {
.ant-drawer-header {
min-height: 54px;
padding: 0 18px;
border-bottom: 1px solid #f0f0f0;
}
.ant-drawer-body {
padding: 14px 16px 12px;
}
.ant-drawer-footer {
padding: 10px 16px;
border-top: 1px solid #f0f0f0;
}
.mpc-picker-toolbar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.mpc-picker-toolbar .ant-input {
width: 240px;
}
.mpc-picker-category {
width: 180px;
}
.mpc-picker-count {
margin-left: auto;
font-size: 12px;
color: #9ca3af;
}
.mpc-picker-empty {
padding: 24px 0;
}
.mpc-picker-table-wrap {
max-height: 460px;
overflow: auto;
border: 1px solid #eceef2;
border-radius: 10px;
}
.mpc-picker-table {
width: 100%;
border-collapse: collapse;
}
.mpc-picker-table thead th {
position: sticky;
top: 0;
z-index: 1;
padding: 10px 8px;
font-size: 12px;
font-weight: 600;
color: #4b5563;
text-align: left;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
}
.mpc-picker-table tbody td {
padding: 10px 8px;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #f1f5f9;
}
.mpc-picker-table tbody tr {
cursor: pointer;
transition: background-color 0.2s;
}
.mpc-picker-table tbody tr:hover {
background: #f8fbff;
}
.mpc-picker-table tbody tr.checked {
background: #eef6ff;
}
.mpc-picker-col-check {
width: 42px;
}
.mpc-picker-col-price {
width: 100px;
}
.mpc-picker-col-status {
width: 90px;
}
.mpc-picker-product-name {
font-weight: 500;
color: #1f2937;
}
.mpc-picker-product-spu {
margin-top: 2px;
font-size: 12px;
color: #94a3b8;
}
.mpc-picker-product-status {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
font-size: 11px;
border-radius: 999px;
}
.mpc-picker-product-status.is-green {
color: #166534;
background: #dcfce7;
}
.mpc-picker-product-status.is-orange {
color: #d97706;
background: #fef3c7;
}
.mpc-picker-product-status.is-gray {
color: #475569;
background: #e2e8f0;
}
.mpc-picker-footer {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.mpc-picker-footer-info {
font-size: 12px;
color: #6b7280;
}
.mpc-picker-footer-actions {
display: flex;
gap: 8px;
align-items: center;
}
}

View File

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

View File

@@ -0,0 +1,199 @@
/**
* 文件职责:次卡页面布局样式。
*/
.page-marketing-punch-card {
.mpc-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.mpc-tab-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.mpc-toolbar {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 14px;
background: #fff;
border: 1px solid var(--mpc-border);
border-radius: 10px;
box-shadow: var(--mpc-shadow-sm);
}
.mpc-toolbar-top {
gap: 12px;
}
.mpc-store-select {
width: 220px;
}
.mpc-filter-select {
width: 150px;
}
.mpc-search {
width: 220px;
}
.mpc-spacer {
flex: 1;
}
.mpc-readonly-tip {
margin-left: auto;
font-size: 12px;
color: #a1a1aa;
}
.mpc-segments {
display: inline-flex;
overflow: hidden;
background: #f5f5f5;
border: 1px solid #e7e7e7;
border-radius: 9px;
}
.mpc-segment-item {
min-width: 108px;
height: 34px;
padding: 0 16px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: transparent;
border: 0;
transition: all var(--mpc-transition);
}
.mpc-segment-item.active {
font-weight: 600;
color: #1677ff;
background: #fff;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
}
.mpc-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.mpc-record-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.mpc-stat-card {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 14px;
background: #fff;
border: 1px solid var(--mpc-border);
border-radius: 10px;
box-shadow: var(--mpc-shadow-sm);
transition: box-shadow var(--mpc-transition);
}
.mpc-stat-card:hover {
box-shadow: var(--mpc-shadow-md);
}
.mpc-stat-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 18px;
border-radius: 8px;
}
.mpc-stat-blue {
color: #1677ff;
background: #e6f4ff;
}
.mpc-stat-cyan {
color: #0891b2;
background: #ecfeff;
}
.mpc-stat-green {
color: #16a34a;
background: #dcfce7;
}
.mpc-stat-orange {
color: #d97706;
background: #fef3c7;
}
.mpc-stat-main {
min-width: 0;
}
.mpc-stat-value {
overflow: hidden;
text-overflow: ellipsis;
font-size: 22px;
font-weight: 700;
line-height: 1.2;
color: var(--mpc-text);
white-space: nowrap;
}
.mpc-stat-value-green {
color: #16a34a;
}
.mpc-stat-value-orange {
color: #d97706;
}
.mpc-stat-label {
margin-top: 2px;
font-size: 12px;
color: var(--mpc-muted);
}
.mpc-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.mpc-empty {
padding: 28px 14px;
font-size: 13px;
color: #9ca3af;
text-align: center;
background: #fff;
border: 1px solid var(--mpc-border);
border-radius: 10px;
box-shadow: var(--mpc-shadow-sm);
}
.mpc-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
margin-right: 4px;
}
.mpc-table-panel {
padding: 10px 12px;
background: #fff;
border: 1px solid var(--mpc-border);
border-radius: 12px;
box-shadow: var(--mpc-shadow-sm);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 文件职责:次卡页面响应式样式。
*/
.page-marketing-punch-card {
@media (width <= 1200px) {
.mpc-toolbar {
flex-wrap: wrap;
}
.mpc-spacer {
display: none;
}
.mpc-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mpc-record-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 768px) {
.mpc-stats,
.mpc-record-stats {
grid-template-columns: 1fr;
}
.mpc-card-grid {
grid-template-columns: 1fr;
}
.mpc-card {
flex-direction: column;
}
.mpc-card-cover {
width: 100%;
min-height: 120px;
}
.mpc-inline-row {
grid-template-columns: 1fr;
}
.mpc-inline-row .ant-form-item:last-child {
grid-column: span 1;
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* 文件职责:次卡使用记录表格样式。
*/
.page-marketing-punch-card {
.mpc-record-no {
font-family: ui-monospace, SFMono-Regular, menlo, monospace;
font-size: 12px;
color: #374151;
}
.mpc-record-member {
display: flex;
flex-direction: column;
gap: 2px;
}
.mpc-record-member-name {
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.mpc-record-member-phone {
font-size: 12px;
color: #94a3b8;
}
.mpc-record-remaining {
font-weight: 600;
color: #1677ff;
}
.mpc-record-status {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
font-size: 12px;
border-radius: 999px;
}
.mpc-record-status.is-green {
color: #166534;
background: #dcfce7;
}
.mpc-record-status.is-orange {
color: #d97706;
background: #fef3c7;
}
.mpc-record-status.is-gray {
color: #475569;
background: #e2e8f0;
}
}

View File

@@ -0,0 +1,145 @@
import type { Dayjs } from 'dayjs';
import type {
MarketingPunchCardDetailDto,
MarketingPunchCardExpireStrategy,
MarketingPunchCardListItemDto,
MarketingPunchCardScopeType,
MarketingPunchCardStatsDto,
MarketingPunchCardStatus,
MarketingPunchCardTemplateOptionDto,
MarketingPunchCardUsageMode,
MarketingPunchCardUsageRecordDto,
MarketingPunchCardUsageStatsDto,
MarketingPunchCardValidityType,
} from '#/api/marketing';
import type { ProductCategoryDto, ProductPickerItemDto } from '#/api/product';
/**
* 文件职责:次卡管理页面类型定义。
*/
/** 页面分段。 */
export type PunchCardTabKey = 'list' | 'records';
/** 次卡列表筛选表单。 */
export interface PunchCardListFilterForm {
status: '' | MarketingPunchCardStatus;
}
/** 使用记录筛选表单。 */
export interface PunchCardUsageFilterForm {
status: '' | 'expired' | 'normal' | 'used_up';
templateId: string;
}
/** 次卡适用范围表单。 */
export interface PunchCardScopeForm {
categoryIds: string[];
productIds: string[];
scopeType: MarketingPunchCardScopeType;
tagIds: string[];
}
/** 次卡编辑抽屉表单。 */
export interface PunchCardEditorForm {
allowTransfer: boolean;
coverImageUrl: string;
dailyLimit: null | number;
description: string;
expireStrategy: MarketingPunchCardExpireStrategy;
id: string;
name: string;
notifyChannels: string[];
originalPrice: null | number;
perOrderLimit: null | number;
perUserPurchaseLimit: null | number;
salePrice: null | number;
scope: PunchCardScopeForm;
status: MarketingPunchCardStatus;
totalTimes: null | number;
usageCapAmount: null | number;
usageMode: MarketingPunchCardUsageMode;
validityDays: null | number;
validityType: MarketingPunchCardValidityType;
validDateRange: [Dayjs, Dayjs] | null;
}
/** 次卡卡片视图模型。 */
export type PunchCardTemplateCardViewModel = MarketingPunchCardListItemDto;
/** 次卡统计视图模型。 */
export type PunchCardStatsViewModel = MarketingPunchCardStatsDto;
/** 使用记录项视图模型。 */
export interface PunchCardUsageRecordViewModel extends MarketingPunchCardUsageRecordDto {
displayStatusText: string;
}
/** 使用记录统计视图模型。 */
export type PunchCardUsageStatsViewModel = MarketingPunchCardUsageStatsDto;
/** 使用记录分页模型。 */
export interface PunchCardUsagePager {
items: PunchCardUsageRecordViewModel[];
page: number;
pageSize: number;
totalCount: number;
}
/** 次卡下拉选项。 */
export type PunchCardTemplateOptionViewModel =
MarketingPunchCardTemplateOptionDto;
/** 二级抽屉分类项。 */
export type PunchCardPickerCategoryItem = ProductCategoryDto;
/** 二级抽屉商品项。 */
export type PunchCardPickerProductItem = ProductPickerItemDto;
/** 抽屉模式。 */
export type PunchCardDrawerMode = 'create' | 'edit';
/** 创建默认使用记录分页。 */
export function createDefaultPunchCardUsagePager(): PunchCardUsagePager {
return {
items: [],
page: 1,
pageSize: 10,
totalCount: 0,
};
}
/** 标准化详情转表单。 */
export function createEditorFormByDetail(
detail: MarketingPunchCardDetailDto,
validDateRange: [Dayjs, Dayjs] | null,
): PunchCardEditorForm {
return {
id: detail.id,
name: detail.name,
coverImageUrl: detail.coverImageUrl ?? '',
salePrice: detail.salePrice,
originalPrice: detail.originalPrice,
totalTimes: detail.totalTimes,
validityType: detail.validityType,
validityDays: detail.validityDays,
validDateRange,
scope: {
scopeType: detail.scope.scopeType,
categoryIds: [...detail.scope.categoryIds],
tagIds: [...detail.scope.tagIds],
productIds: [...detail.scope.productIds],
},
usageMode: detail.usageMode,
usageCapAmount: detail.usageCapAmount,
dailyLimit: detail.dailyLimit,
perOrderLimit: detail.perOrderLimit,
perUserPurchaseLimit: detail.perUserPurchaseLimit,
allowTransfer: detail.allowTransfer,
expireStrategy: detail.expireStrategy,
description: detail.description ?? '',
notifyChannels: [...detail.notifyChannels],
status: detail.status,
};
}