feat: implement marketing punch card management page
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
Some checks failed
Build and Deploy TenantUI / build-and-deploy (push) Failing after 1s
This commit is contained in:
@@ -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';
|
||||||
|
|||||||
334
apps/web-antd/src/api/marketing/punch-card.ts
Normal file
334
apps/web-antd/src/api/marketing/punch-card.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;' });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
363
apps/web-antd/src/views/marketing/punch-card/index.vue
Normal file
363
apps/web-antd/src/views/marketing/punch-card/index.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
apps/web-antd/src/views/marketing/punch-card/styles/card.less
Normal file
196
apps/web-antd/src/views/marketing/punch-card/styles/card.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
328
apps/web-antd/src/views/marketing/punch-card/styles/drawer.less
Normal file
328
apps/web-antd/src/views/marketing/punch-card/styles/drawer.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@import './base.less';
|
||||||
|
@import './layout.less';
|
||||||
|
@import './card.less';
|
||||||
|
@import './drawer.less';
|
||||||
|
@import './table.less';
|
||||||
|
@import './responsive.less';
|
||||||
199
apps/web-antd/src/views/marketing/punch-card/styles/layout.less
Normal file
199
apps/web-antd/src/views/marketing/punch-card/styles/layout.less
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/web-antd/src/views/marketing/punch-card/types.ts
Normal file
145
apps/web-antd/src/views/marketing/punch-card/types.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user