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 './full-reduction';
|
||||
export * from './new-customer';
|
||||
export * from './punch-card';
|
||||
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