feat: implement marketing punch card management page
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s

This commit is contained in:
2026-03-02 21:43:47 +08:00
parent 0e043ddd79
commit be0a8e6914
24 changed files with 4450 additions and 0 deletions

View File

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

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