feat(@vben/web-antd): implement new customer gift page and drawers

This commit is contained in:
2026-03-02 15:58:53 +08:00
parent 9920f2e32c
commit 0e043ddd79
22 changed files with 2674 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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