feat(@vben/web-antd): implement new customer gift page and drawers
This commit is contained in:
@@ -185,4 +185,5 @@ export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) {
|
|||||||
|
|
||||||
export * from './flash-sale';
|
export * from './flash-sale';
|
||||||
export * from './full-reduction';
|
export * from './full-reduction';
|
||||||
|
export * from './new-customer';
|
||||||
export * from './seckill';
|
export * from './seckill';
|
||||||
|
|||||||
213
apps/web-antd/src/api/marketing/new-customer.ts
Normal file
213
apps/web-antd/src/api/marketing/new-customer.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
316
apps/web-antd/src/views/marketing/new-customer/index.vue
Normal file
316
apps/web-antd/src/views/marketing/new-customer/index.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
168
apps/web-antd/src/views/marketing/new-customer/styles/card.less
Normal file
168
apps/web-antd/src/views/marketing/new-customer/styles/card.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/web-antd/src/views/marketing/new-customer/types.ts
Normal file
75
apps/web-antd/src/views/marketing/new-customer/types.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user