feat: 完成营销中心优惠券页面与抽屉
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 53s
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 53s
This commit is contained in:
184
apps/web-antd/src/api/marketing/index.ts
Normal file
184
apps/web-antd/src/api/marketing/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 文件职责:营销中心 API 与 DTO 定义。
|
||||
* 1. 维护优惠券列表、详情、保存、状态切换与删除契约。
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 优惠券类型。 */
|
||||
export type MarketingCouponType = 'amount_off' | 'discount' | 'free_delivery';
|
||||
|
||||
/** 列表展示状态。 */
|
||||
export type MarketingCouponDisplayStatus =
|
||||
| 'disabled'
|
||||
| 'ended'
|
||||
| 'ongoing'
|
||||
| 'upcoming';
|
||||
|
||||
/** 编辑状态。 */
|
||||
export type MarketingCouponEditorStatus = 'disabled' | 'enabled';
|
||||
|
||||
/** 有效期类型。 */
|
||||
export type MarketingCouponValidityType = 'days' | 'fixed';
|
||||
|
||||
/** 适用渠道。 */
|
||||
export type MarketingCouponChannel = 'delivery' | 'dine_in' | 'pickup';
|
||||
|
||||
/** 门店范围模式。 */
|
||||
export type MarketingCouponStoreScopeMode = 'all' | 'stores';
|
||||
|
||||
/** 优惠券列表查询。 */
|
||||
export interface MarketingCouponListQuery {
|
||||
couponType?: '' | MarketingCouponType;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: '' | MarketingCouponDisplayStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 优惠券详情查询。 */
|
||||
export interface MarketingCouponDetailQuery {
|
||||
couponId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存优惠券请求。 */
|
||||
export interface SaveMarketingCouponDto {
|
||||
channels: MarketingCouponChannel[];
|
||||
couponType: MarketingCouponType;
|
||||
id?: string;
|
||||
minimumSpend: null | number;
|
||||
name: string;
|
||||
perUserLimit: null | number;
|
||||
relativeValidDays: null | number;
|
||||
status: MarketingCouponEditorStatus;
|
||||
storeId: string;
|
||||
storeIds?: string[];
|
||||
storeScopeMode: MarketingCouponStoreScopeMode;
|
||||
totalQuantity: number;
|
||||
validFrom: null | string;
|
||||
validTo: null | string;
|
||||
validityType: MarketingCouponValidityType;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** 修改状态请求。 */
|
||||
export interface ChangeMarketingCouponStatusDto {
|
||||
couponId: string;
|
||||
status: MarketingCouponEditorStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除请求。 */
|
||||
export interface DeleteMarketingCouponDto {
|
||||
couponId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 统计数据。 */
|
||||
export interface MarketingCouponStatsDto {
|
||||
claimedCount: number;
|
||||
ongoingCount: number;
|
||||
redeemRate: number;
|
||||
redeemedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 列表项。 */
|
||||
export interface MarketingCouponListItemDto {
|
||||
channels: MarketingCouponChannel[];
|
||||
claimedQuantity: number;
|
||||
couponType: MarketingCouponType;
|
||||
displayStatus: MarketingCouponDisplayStatus;
|
||||
id: string;
|
||||
isDimmed: boolean;
|
||||
minimumSpend: null | number;
|
||||
name: string;
|
||||
perUserLimit: null | number;
|
||||
redeemedQuantity: number;
|
||||
relativeValidDays: null | number;
|
||||
storeIds: string[];
|
||||
storeScopeMode: MarketingCouponStoreScopeMode;
|
||||
totalQuantity: number;
|
||||
updatedAt: string;
|
||||
validFrom: null | string;
|
||||
validTo: null | string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** 列表结果。 */
|
||||
export interface MarketingCouponListResultDto {
|
||||
items: MarketingCouponListItemDto[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
stats: MarketingCouponStatsDto;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 详情数据。 */
|
||||
export interface MarketingCouponDetailDto {
|
||||
channels: MarketingCouponChannel[];
|
||||
claimedQuantity: number;
|
||||
couponType: MarketingCouponType;
|
||||
id: string;
|
||||
minimumSpend: null | number;
|
||||
name: string;
|
||||
perUserLimit: null | number;
|
||||
relativeValidDays: null | number;
|
||||
status: MarketingCouponEditorStatus;
|
||||
storeIds: string[];
|
||||
storeScopeMode: MarketingCouponStoreScopeMode;
|
||||
totalQuantity: number;
|
||||
updatedAt: string;
|
||||
validFrom: null | string;
|
||||
validTo: null | string;
|
||||
validityType: MarketingCouponValidityType;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** 获取优惠券列表。 */
|
||||
export async function getMarketingCouponListApi(
|
||||
params: MarketingCouponListQuery,
|
||||
) {
|
||||
return requestClient.get<MarketingCouponListResultDto>(
|
||||
'/marketing/coupon/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取优惠券详情。 */
|
||||
export async function getMarketingCouponDetailApi(
|
||||
params: MarketingCouponDetailQuery,
|
||||
) {
|
||||
return requestClient.get<MarketingCouponDetailDto>(
|
||||
'/marketing/coupon/detail',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存优惠券。 */
|
||||
export async function saveMarketingCouponApi(data: SaveMarketingCouponDto) {
|
||||
return requestClient.post<MarketingCouponDetailDto>(
|
||||
'/marketing/coupon/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 修改优惠券状态。 */
|
||||
export async function changeMarketingCouponStatusApi(
|
||||
data: ChangeMarketingCouponStatusDto,
|
||||
) {
|
||||
return requestClient.post<MarketingCouponDetailDto>(
|
||||
'/marketing/coupon/status',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除优惠券。 */
|
||||
export async function deleteMarketingCouponApi(data: DeleteMarketingCouponDto) {
|
||||
return requestClient.post('/marketing/coupon/delete', data);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { CouponEditorForm } from '../types';
|
||||
|
||||
import type {
|
||||
MarketingCouponChannel,
|
||||
MarketingCouponStoreScopeMode,
|
||||
MarketingCouponType,
|
||||
MarketingCouponValidityType,
|
||||
} from '#/api/marketing';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Spin,
|
||||
Switch,
|
||||
} from 'ant-design-vue';
|
||||
/**
|
||||
* 文件职责:优惠券编辑抽屉。
|
||||
* 1. 承载券基础信息、发放规则与适用范围配置。
|
||||
* 2. 对外抛出字段更新与保存事件。
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
COUPON_CHANNEL_OPTIONS,
|
||||
COUPON_EDITOR_TYPE_OPTIONS,
|
||||
COUPON_STORE_SCOPE_OPTIONS,
|
||||
COUPON_VALIDITY_OPTIONS,
|
||||
} from '../composables/coupon-page/constants';
|
||||
|
||||
defineProps<{
|
||||
form: CouponEditorForm;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
storeOptions: Array<{ label: string; value: string }>;
|
||||
submitText: string;
|
||||
submitting: boolean;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void;
|
||||
(event: 'setChannels', value: MarketingCouponChannel[]): void;
|
||||
(event: 'setCouponType', value: MarketingCouponType): void;
|
||||
(event: 'setMinimumSpend', value: null | number): void;
|
||||
(event: 'setName', value: string): void;
|
||||
(event: 'setPerUserLimit', value: null | number): void;
|
||||
(event: 'setRelativeValidDays', value: null | number): void;
|
||||
(event: 'setStatus', value: boolean): void;
|
||||
(event: 'setStoreIds', value: string[]): void;
|
||||
(event: 'setStoreScopeMode', value: MarketingCouponStoreScopeMode): void;
|
||||
(event: 'setTotalQuantity', value: null | number): void;
|
||||
(event: 'setValidDateRange', value: [Dayjs, Dayjs] | null): void;
|
||||
(event: 'setValidityType', value: MarketingCouponValidityType): void;
|
||||
(event: 'setValue', value: null | number): void;
|
||||
(event: 'submit'): void;
|
||||
}>();
|
||||
|
||||
function onChannelsChange(value: Array<MarketingCouponChannel | string>) {
|
||||
emit(
|
||||
'setChannels',
|
||||
value.filter(
|
||||
(item): item is MarketingCouponChannel =>
|
||||
item === 'delivery' || item === 'pickup' || item === 'dine_in',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onStoreIdsChange(value: Array<number | string>) {
|
||||
emit('setStoreIds', value.map(String));
|
||||
}
|
||||
|
||||
function onCouponTypeChange(value: unknown) {
|
||||
if (
|
||||
value === 'amount_off' ||
|
||||
value === 'discount' ||
|
||||
value === 'free_delivery'
|
||||
) {
|
||||
emit('setCouponType', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onValidityTypeChange(value: unknown) {
|
||||
if (value === 'fixed' || value === 'days') {
|
||||
emit('setValidityType', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onStoreScopeModeChange(value: unknown) {
|
||||
if (value === 'all' || value === 'stores') {
|
||||
emit('setStoreScopeMode', value);
|
||||
}
|
||||
}
|
||||
|
||||
function onDateRangeChange(value: [Dayjs, Dayjs] | [string, string] | null) {
|
||||
if (!value) {
|
||||
emit('setValidDateRange', null);
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, end] = value;
|
||||
if (typeof start === 'string' || typeof end === 'string') {
|
||||
emit('setValidDateRange', [dayjs(String(start)), dayjs(String(end))]);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('setValidDateRange', value as [Dayjs, Dayjs]);
|
||||
}
|
||||
|
||||
function onNameChange(value: string) {
|
||||
emit('setName', String(value ?? ''));
|
||||
}
|
||||
|
||||
function onMinimumSpendChange(value: null | number | string) {
|
||||
emit('setMinimumSpend', parseNullableNumber(value));
|
||||
}
|
||||
|
||||
function onValueChange(value: null | number | string) {
|
||||
emit('setValue', parseNullableNumber(value));
|
||||
}
|
||||
|
||||
function onTotalQuantityChange(value: null | number | string) {
|
||||
emit('setTotalQuantity', parseNullableNumber(value));
|
||||
}
|
||||
|
||||
function onPerUserLimitChange(value: null | number | string) {
|
||||
emit('setPerUserLimit', parseNullableNumber(value));
|
||||
}
|
||||
|
||||
function onRelativeValidDaysChange(value: null | number | string) {
|
||||
emit('setRelativeValidDays', parseNullableNumber(value));
|
||||
}
|
||||
|
||||
function onChannelsGroupChange(value: unknown[]) {
|
||||
onChannelsChange((value ?? []).map(String));
|
||||
}
|
||||
|
||||
function onStoreIdsSelectChange(value: unknown) {
|
||||
const values = Array.isArray(value) ? value : [];
|
||||
onStoreIdsChange(values.map(String));
|
||||
}
|
||||
|
||||
function onStatusChange(value: unknown) {
|
||||
emit('setStatus', value === true || value === 'true');
|
||||
}
|
||||
|
||||
function parseNullableNumber(value: null | number | string) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isNaN(numeric) ? null : numeric;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="open"
|
||||
:title="title"
|
||||
width="560"
|
||||
:destroy-on-close="false"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<Form layout="vertical" class="mcp-editor-form">
|
||||
<Form.Item label="券名称" required>
|
||||
<Input
|
||||
:value="form.name"
|
||||
:maxlength="64"
|
||||
placeholder="如:新用户满减券"
|
||||
@update:value="onNameChange"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="券类型" required>
|
||||
<Radio.Group
|
||||
:value="form.couponType"
|
||||
button-style="solid"
|
||||
@update:value="onCouponTypeChange"
|
||||
>
|
||||
<Radio.Button
|
||||
v-for="item in COUPON_EDITOR_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="mcp-inline-fields">
|
||||
<span>满</span>
|
||||
<InputNumber
|
||||
:value="form.minimumSpend ?? undefined"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="如:50"
|
||||
@update:value="onMinimumSpendChange"
|
||||
/>
|
||||
<span>元减</span>
|
||||
<InputNumber
|
||||
:value="form.value ?? undefined"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="如:15"
|
||||
@update:value="onValueChange"
|
||||
/>
|
||||
<span>元</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
v-if="form.couponType === 'discount'"
|
||||
label="折扣力度"
|
||||
required
|
||||
>
|
||||
<div class="mcp-inline-fields">
|
||||
<InputNumber
|
||||
:value="form.value ?? undefined"
|
||||
:min="0.01"
|
||||
:max="9.99"
|
||||
:precision="2"
|
||||
placeholder="如:8"
|
||||
@update:value="onValueChange"
|
||||
/>
|
||||
<span>折</span>
|
||||
</div>
|
||||
<div class="mcp-field-hint">输入 8 表示打 8 折,即优惠 20%</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
v-if="form.couponType === 'free_delivery'"
|
||||
label="面额设置"
|
||||
class="mcp-form-muted"
|
||||
>
|
||||
免配送费券无需设置面额
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="发放总量" required>
|
||||
<InputNumber
|
||||
:value="form.totalQuantity"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
class="mcp-input-wide"
|
||||
placeholder="如:1000"
|
||||
@update:value="onTotalQuantityChange"
|
||||
/>
|
||||
<div class="mcp-field-hint">设置后不可减少,可增加</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="每人限领">
|
||||
<InputNumber
|
||||
:value="form.perUserLimit ?? undefined"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
class="mcp-input-wide"
|
||||
placeholder="如:1"
|
||||
@update:value="onPerUserLimitChange"
|
||||
/>
|
||||
<div class="mcp-field-hint">不填则不限制</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="有效期类型" required>
|
||||
<Radio.Group
|
||||
:value="form.validityType"
|
||||
button-style="solid"
|
||||
@update:value="onValidityTypeChange"
|
||||
>
|
||||
<Radio.Button
|
||||
v-for="item in COUPON_VALIDITY_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
v-if="form.validityType === 'fixed'"
|
||||
label="有效时间范围"
|
||||
required
|
||||
>
|
||||
<DatePicker.RangePicker
|
||||
:value="form.validDateRange ?? undefined"
|
||||
format="YYYY-MM-DD"
|
||||
class="mcp-range-picker"
|
||||
@update:value="onDateRangeChange"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item v-else label="领取后有效天数" required>
|
||||
<div class="mcp-inline-fields">
|
||||
<span>领取后</span>
|
||||
<InputNumber
|
||||
:value="form.relativeValidDays ?? undefined"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
placeholder="如:7"
|
||||
@update:value="onRelativeValidDaysChange"
|
||||
/>
|
||||
<span>天内有效</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item v-if="form.couponType === 'discount'" label="使用门槛">
|
||||
<div class="mcp-inline-fields">
|
||||
<span>订单满</span>
|
||||
<InputNumber
|
||||
:value="form.minimumSpend ?? undefined"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="如:50"
|
||||
@update:value="onMinimumSpendChange"
|
||||
/>
|
||||
<span>元可用</span>
|
||||
</div>
|
||||
<div class="mcp-field-hint">不填则无门槛</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="适用渠道" required>
|
||||
<Checkbox.Group
|
||||
:value="form.channels"
|
||||
:options="COUPON_CHANNEL_OPTIONS"
|
||||
@change="onChannelsGroupChange"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="适用门店" required>
|
||||
<Radio.Group
|
||||
:value="form.storeScopeMode"
|
||||
@update:value="onStoreScopeModeChange"
|
||||
>
|
||||
<Radio
|
||||
v-for="item in COUPON_STORE_SCOPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<Select
|
||||
v-if="form.storeScopeMode === 'stores'"
|
||||
mode="multiple"
|
||||
:value="form.storeIds"
|
||||
:options="storeOptions"
|
||||
class="mcp-store-multi"
|
||||
placeholder="请选择适用门店"
|
||||
@update:value="onStoreIdsSelectChange"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="启用状态">
|
||||
<div class="mcp-switch-row">
|
||||
<Switch
|
||||
:checked="form.status === 'enabled'"
|
||||
@update:checked="onStatusChange"
|
||||
/>
|
||||
<span>{{ form.status === 'enabled' ? '启用' : '停用' }}</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
|
||||
<template #footer>
|
||||
<div class="mcp-drawer-footer">
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="emit('submit')">
|
||||
{{ submitText }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:优惠券统计卡片。
|
||||
* 1. 展示总数、进行中、已领取、已核销与核销率。
|
||||
*/
|
||||
import type { CouponStatsViewModel } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { formatInteger } from '../composables/coupon-page/helpers';
|
||||
|
||||
const props = defineProps<{
|
||||
stats: CouponStatsViewModel;
|
||||
}>();
|
||||
|
||||
const redeemText = computed(
|
||||
() =>
|
||||
`${formatInteger(props.stats.redeemedCount)}(核销率 ${props.stats.redeemRate.toFixed(1)}%)`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-stats">
|
||||
<div class="mcp-stat-card">
|
||||
<span class="mcp-stat-icon mcp-stat-blue">
|
||||
<IconifyIcon icon="lucide:ticket" />
|
||||
</span>
|
||||
<div class="mcp-stat-main">
|
||||
<div class="mcp-stat-value">
|
||||
{{ formatInteger(stats.totalCount) }}
|
||||
</div>
|
||||
<div class="mcp-stat-label">优惠券总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mcp-stat-card">
|
||||
<span class="mcp-stat-icon mcp-stat-green">
|
||||
<IconifyIcon icon="lucide:play-circle" />
|
||||
</span>
|
||||
<div class="mcp-stat-main">
|
||||
<div class="mcp-stat-value">
|
||||
{{ formatInteger(stats.ongoingCount) }}
|
||||
</div>
|
||||
<div class="mcp-stat-label">进行中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mcp-stat-card">
|
||||
<span class="mcp-stat-icon mcp-stat-orange">
|
||||
<IconifyIcon icon="lucide:download" />
|
||||
</span>
|
||||
<div class="mcp-stat-main">
|
||||
<div class="mcp-stat-value">
|
||||
{{ formatInteger(stats.claimedCount) }}
|
||||
</div>
|
||||
<div class="mcp-stat-label">已领取(张)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mcp-stat-card">
|
||||
<span class="mcp-stat-icon mcp-stat-purple">
|
||||
<IconifyIcon icon="lucide:check-circle" />
|
||||
</span>
|
||||
<div class="mcp-stat-main">
|
||||
<div class="mcp-stat-value">
|
||||
{{ formatInteger(stats.redeemedCount) }}
|
||||
</div>
|
||||
<div class="mcp-stat-label">{{ redeemText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:优惠券卡片项。
|
||||
* 1. 还原券面、规则、进度与状态操作布局。
|
||||
*/
|
||||
import type { CouponCardViewModel } from '../types';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
COUPON_STATUS_TAG_CLASS_MAP,
|
||||
COUPON_STATUS_TEXT_MAP,
|
||||
} from '../composables/coupon-page/constants';
|
||||
import {
|
||||
formatInteger,
|
||||
resolveClaimedProgressPercent,
|
||||
resolveCouponFaceConditionText,
|
||||
resolveCouponFaceValueText,
|
||||
resolveCouponRuleText,
|
||||
resolveCouponTypeClass,
|
||||
resolveCouponTypeLabel,
|
||||
resolveCouponValidityText,
|
||||
resolveProgressClass,
|
||||
} from '../composables/coupon-page/helpers';
|
||||
|
||||
defineProps<{
|
||||
item: CouponCardViewModel;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
disable: [item: CouponCardViewModel];
|
||||
edit: [item: CouponCardViewModel];
|
||||
enable: [item: CouponCardViewModel];
|
||||
remove: [item: CouponCardViewModel];
|
||||
view: [item: CouponCardViewModel];
|
||||
}>();
|
||||
|
||||
function resolveClaimedText(item: CouponCardViewModel) {
|
||||
return `已领 ${formatInteger(item.claimedQuantity)}/${formatInteger(item.totalQuantity)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-coupon" :class="{ 'mcp-dimmed': item.isDimmed }">
|
||||
<div class="mcp-left" :class="resolveCouponTypeClass(item.couponType)">
|
||||
<div class="mcp-face-value">
|
||||
{{ resolveCouponFaceValueText(item) }}
|
||||
</div>
|
||||
<div class="mcp-face-cond">
|
||||
{{ resolveCouponFaceConditionText(item) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mcp-right">
|
||||
<div class="mcp-title-row">
|
||||
<span class="mcp-name">{{ item.name }}</span>
|
||||
<span class="mcp-type-pill">
|
||||
{{ resolveCouponTypeLabel(item.couponType) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mcp-validity">
|
||||
<IconifyIcon icon="lucide:calendar" />
|
||||
<span>{{ resolveCouponValidityText(item) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mcp-rules">{{ resolveCouponRuleText(item) }}</div>
|
||||
|
||||
<div class="mcp-progress-row">
|
||||
<span>{{ resolveClaimedText(item) }}</span>
|
||||
<div class="mcp-progress-wrap">
|
||||
<div class="mcp-progress-bar">
|
||||
<span
|
||||
class="mcp-progress-fill"
|
||||
:class="resolveProgressClass(item.couponType)"
|
||||
:style="{
|
||||
width: `${resolveClaimedProgressPercent(item.claimedQuantity, item.totalQuantity)}%`,
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<span>已用 {{ formatInteger(item.redeemedQuantity) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mcp-bottom">
|
||||
<span
|
||||
class="mcp-status-tag"
|
||||
:class="COUPON_STATUS_TAG_CLASS_MAP[item.displayStatus]"
|
||||
>
|
||||
{{ COUPON_STATUS_TEXT_MAP[item.displayStatus] }}
|
||||
</span>
|
||||
|
||||
<div class="mcp-actions">
|
||||
<template v-if="item.displayStatus === 'ended'">
|
||||
<button type="button" class="g-action" @click="emit('view', item)">
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="g-action g-action-danger"
|
||||
@click="emit('remove', item)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="item.displayStatus === 'disabled'">
|
||||
<button
|
||||
type="button"
|
||||
class="g-action"
|
||||
@click="emit('enable', item)"
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="g-action g-action-danger"
|
||||
@click="emit('remove', item)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button type="button" class="g-action" @click="emit('edit', item)">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="g-action"
|
||||
@click="emit('disable', item)"
|
||||
>
|
||||
停用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="g-action g-action-danger"
|
||||
@click="emit('remove', item)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { CouponCardViewModel } from '#/views/marketing/coupon/types';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券卡片行操作。
|
||||
* 1. 封装启停与删除动作。
|
||||
* 2. 统一确认弹窗与成功提示。
|
||||
*/
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeMarketingCouponStatusApi,
|
||||
deleteMarketingCouponApi,
|
||||
} from '#/api/marketing';
|
||||
|
||||
interface CreateCardActionsOptions {
|
||||
loadCoupons: () => Promise<void>;
|
||||
selectedStoreId: Ref<string>;
|
||||
}
|
||||
|
||||
export function createCardActions(options: CreateCardActionsOptions) {
|
||||
async function enableCoupon(item: CouponCardViewModel) {
|
||||
await updateStatus(item, 'enabled', '优惠券已启用');
|
||||
}
|
||||
|
||||
async function disableCoupon(item: CouponCardViewModel) {
|
||||
await updateStatus(item, 'disabled', '优惠券已停用');
|
||||
}
|
||||
|
||||
async function updateStatus(
|
||||
item: CouponCardViewModel,
|
||||
status: 'disabled' | 'enabled',
|
||||
successMessage: string,
|
||||
) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
try {
|
||||
await changeMarketingCouponStatusApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
couponId: item.id,
|
||||
status,
|
||||
});
|
||||
message.success(successMessage);
|
||||
await options.loadCoupons();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCoupon(item: CouponCardViewModel) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认删除优惠券「${item.name}」吗?`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteMarketingCouponApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
couponId: item.id,
|
||||
});
|
||||
message.success('优惠券已删除');
|
||||
await options.loadCoupons();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
disableCoupon,
|
||||
enableCoupon,
|
||||
removeCoupon,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type {
|
||||
MarketingCouponChannel,
|
||||
MarketingCouponDisplayStatus,
|
||||
MarketingCouponStoreScopeMode,
|
||||
MarketingCouponType,
|
||||
} from '#/api/marketing';
|
||||
import type {
|
||||
CouponEditorForm,
|
||||
CouponFilterForm,
|
||||
} from '#/views/marketing/coupon/types';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券页面常量定义与默认表单构造。
|
||||
*/
|
||||
|
||||
/** 列表状态筛选项。 */
|
||||
export const COUPON_STATUS_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: '' | MarketingCouponDisplayStatus;
|
||||
}> = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '进行中', value: 'ongoing' },
|
||||
{ label: '未开始', value: 'upcoming' },
|
||||
{ label: '已结束', value: 'ended' },
|
||||
{ label: '已停用', value: 'disabled' },
|
||||
];
|
||||
|
||||
/** 列表类型筛选项。 */
|
||||
export const COUPON_TYPE_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: '' | MarketingCouponType;
|
||||
}> = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '满减券', value: 'amount_off' },
|
||||
{ label: '折扣券', value: 'discount' },
|
||||
{ label: '免配送费券', value: 'free_delivery' },
|
||||
];
|
||||
|
||||
/** 编辑态券类型项。 */
|
||||
export const COUPON_EDITOR_TYPE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingCouponType;
|
||||
}> = [
|
||||
{ label: '满减券', value: 'amount_off' },
|
||||
{ label: '折扣券', value: 'discount' },
|
||||
{ label: '免配送费券', value: 'free_delivery' },
|
||||
];
|
||||
|
||||
/** 有效期类型项。 */
|
||||
export const COUPON_VALIDITY_OPTIONS = [
|
||||
{ label: '固定时间', value: 'fixed' },
|
||||
{ label: '领取后N天', value: 'days' },
|
||||
] as const;
|
||||
|
||||
/** 渠道选项。 */
|
||||
export const COUPON_CHANNEL_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingCouponChannel;
|
||||
}> = [
|
||||
{ label: '外卖', value: 'delivery' },
|
||||
{ label: '自提', value: 'pickup' },
|
||||
{ label: '堂食', value: 'dine_in' },
|
||||
];
|
||||
|
||||
/** 门店范围选项。 */
|
||||
export const COUPON_STORE_SCOPE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketingCouponStoreScopeMode;
|
||||
}> = [
|
||||
{ label: '全部门店', value: 'all' },
|
||||
{ label: '指定门店', value: 'stores' },
|
||||
];
|
||||
|
||||
/** 列表状态文本。 */
|
||||
export const COUPON_STATUS_TEXT_MAP: Record<
|
||||
MarketingCouponDisplayStatus,
|
||||
string
|
||||
> = {
|
||||
ongoing: '进行中',
|
||||
upcoming: '未开始',
|
||||
ended: '已结束',
|
||||
disabled: '已停用',
|
||||
};
|
||||
|
||||
/** 列表状态徽标样式。 */
|
||||
export const COUPON_STATUS_TAG_CLASS_MAP: Record<
|
||||
MarketingCouponDisplayStatus,
|
||||
string
|
||||
> = {
|
||||
ongoing: 'mcp-status-ongoing',
|
||||
upcoming: 'mcp-status-upcoming',
|
||||
ended: 'mcp-status-ended',
|
||||
disabled: 'mcp-status-disabled',
|
||||
};
|
||||
|
||||
/** 渠道文本映射。 */
|
||||
export const COUPON_CHANNEL_TEXT_MAP: Record<MarketingCouponChannel, string> = {
|
||||
delivery: '外卖',
|
||||
pickup: '自提',
|
||||
dine_in: '堂食',
|
||||
};
|
||||
|
||||
/** 构建默认筛选表单。 */
|
||||
export function createDefaultCouponFilterForm(): CouponFilterForm {
|
||||
return {
|
||||
status: '',
|
||||
couponType: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建默认编辑表单。 */
|
||||
export function createDefaultCouponEditorForm(): CouponEditorForm {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
couponType: 'amount_off',
|
||||
value: null,
|
||||
minimumSpend: null,
|
||||
totalQuantity: 1000,
|
||||
perUserLimit: 1,
|
||||
validityType: 'fixed',
|
||||
validDateRange: null,
|
||||
relativeValidDays: 7,
|
||||
channels: ['delivery', 'pickup', 'dine_in'],
|
||||
storeScopeMode: 'all',
|
||||
storeIds: [],
|
||||
status: 'enabled',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { MarketingCouponStatsDto } from '#/api/marketing';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
CouponCardViewModel,
|
||||
CouponFilterForm,
|
||||
} from '#/views/marketing/coupon/types';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券页面数据读取动作。
|
||||
* 1. 加载门店列表与优惠券分页列表。
|
||||
* 2. 维护分页、统计和加载态。
|
||||
*/
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getMarketingCouponListApi } from '#/api/marketing';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
interface CreateDataActionsOptions {
|
||||
filterForm: CouponFilterForm;
|
||||
isLoading: Ref<boolean>;
|
||||
isStoreLoading: Ref<boolean>;
|
||||
keyword: Ref<string>;
|
||||
page: Ref<number>;
|
||||
pageSize: Ref<number>;
|
||||
rows: Ref<CouponCardViewModel[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
stats: Ref<MarketingCouponStatsDto>;
|
||||
stores: Ref<StoreListItemDto[]>;
|
||||
total: Ref<number>;
|
||||
}
|
||||
|
||||
export function createDataActions(options: CreateDataActionsOptions) {
|
||||
async function loadStores() {
|
||||
options.isStoreLoading.value = true;
|
||||
try {
|
||||
const result = await getStoreListApi({
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
});
|
||||
options.stores.value = result.items ?? [];
|
||||
|
||||
if (options.stores.value.length === 0) {
|
||||
options.selectedStoreId.value = '';
|
||||
options.rows.value = [];
|
||||
options.total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelected = options.stores.value.some(
|
||||
(item) => item.id === options.selectedStoreId.value,
|
||||
);
|
||||
if (!hasSelected) {
|
||||
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('加载门店失败');
|
||||
} finally {
|
||||
options.isStoreLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoupons() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
options.rows.value = [];
|
||||
options.total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
options.isLoading.value = true;
|
||||
try {
|
||||
const result = await getMarketingCouponListApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
status: options.filterForm.status,
|
||||
couponType: options.filterForm.couponType,
|
||||
keyword: options.keyword.value.trim() || undefined,
|
||||
page: options.page.value,
|
||||
pageSize: options.pageSize.value,
|
||||
});
|
||||
|
||||
options.rows.value = result.items ?? [];
|
||||
options.total.value = result.total;
|
||||
options.page.value = result.page;
|
||||
options.pageSize.value = result.pageSize;
|
||||
options.stats.value = result.stats;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.rows.value = [];
|
||||
options.total.value = 0;
|
||||
options.stats.value = createEmptyStats();
|
||||
message.error('加载优惠券失败');
|
||||
} finally {
|
||||
options.isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadCoupons,
|
||||
loadStores,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyStats(): MarketingCouponStatsDto {
|
||||
return {
|
||||
totalCount: 0,
|
||||
ongoingCount: 0,
|
||||
claimedCount: 0,
|
||||
redeemedCount: 0,
|
||||
redeemRate: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
MarketingCouponChannel,
|
||||
MarketingCouponStoreScopeMode,
|
||||
MarketingCouponType,
|
||||
MarketingCouponValidityType,
|
||||
} from '#/api/marketing';
|
||||
import type { CouponEditorForm } from '#/views/marketing/coupon/types';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券编辑抽屉动作。
|
||||
* 1. 管理新增/编辑抽屉与字段更新。
|
||||
* 2. 负责详情加载、表单校验与保存提交。
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getMarketingCouponDetailApi,
|
||||
saveMarketingCouponApi,
|
||||
} from '#/api/marketing';
|
||||
|
||||
import { createDefaultCouponEditorForm } from './constants';
|
||||
import { buildSaveCouponPayload, mapDetailToEditorForm } from './helpers';
|
||||
|
||||
interface CreateDrawerActionsOptions {
|
||||
form: CouponEditorForm;
|
||||
isDrawerLoading: Ref<boolean>;
|
||||
isDrawerOpen: Ref<boolean>;
|
||||
isDrawerSubmitting: Ref<boolean>;
|
||||
loadCoupons: () => Promise<void>;
|
||||
selectedStoreId: Ref<string>;
|
||||
}
|
||||
|
||||
export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
const drawerMode = ref<'create' | 'edit'>('create');
|
||||
|
||||
function setDrawerOpen(value: boolean) {
|
||||
options.isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
function applyForm(next: CouponEditorForm) {
|
||||
options.form.id = next.id;
|
||||
options.form.name = next.name;
|
||||
options.form.couponType = next.couponType;
|
||||
options.form.value = next.value;
|
||||
options.form.minimumSpend = next.minimumSpend;
|
||||
options.form.totalQuantity = next.totalQuantity;
|
||||
options.form.perUserLimit = next.perUserLimit;
|
||||
options.form.validityType = next.validityType;
|
||||
options.form.validDateRange = next.validDateRange;
|
||||
options.form.relativeValidDays = next.relativeValidDays;
|
||||
options.form.channels = [...next.channels];
|
||||
options.form.storeScopeMode = next.storeScopeMode;
|
||||
options.form.storeIds = [...next.storeIds];
|
||||
options.form.status = next.status;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
applyForm(createDefaultCouponEditorForm());
|
||||
}
|
||||
|
||||
function setFormName(value: string) {
|
||||
options.form.name = value;
|
||||
}
|
||||
|
||||
function setFormCouponType(value: MarketingCouponType) {
|
||||
options.form.couponType = value;
|
||||
if (value === 'free_delivery') {
|
||||
options.form.value = null;
|
||||
options.form.minimumSpend = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setFormValue(value: null | number) {
|
||||
options.form.value = value;
|
||||
}
|
||||
|
||||
function setFormMinimumSpend(value: null | number) {
|
||||
options.form.minimumSpend = value;
|
||||
}
|
||||
|
||||
function setFormTotalQuantity(value: null | number) {
|
||||
options.form.totalQuantity = value ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
function setFormPerUserLimit(value: null | number) {
|
||||
if (value === null) {
|
||||
options.form.perUserLimit = null;
|
||||
return;
|
||||
}
|
||||
|
||||
options.form.perUserLimit = Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function setFormValidityType(value: MarketingCouponValidityType) {
|
||||
options.form.validityType = value;
|
||||
if (value === 'fixed') {
|
||||
options.form.relativeValidDays = options.form.relativeValidDays || 7;
|
||||
return;
|
||||
}
|
||||
|
||||
options.form.validDateRange = null;
|
||||
}
|
||||
|
||||
function setFormValidDateRange(value: CouponEditorForm['validDateRange']) {
|
||||
options.form.validDateRange = value;
|
||||
}
|
||||
|
||||
function setFormRelativeValidDays(value: null | number) {
|
||||
options.form.relativeValidDays = value ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
function setFormChannels(value: MarketingCouponChannel[]) {
|
||||
options.form.channels = [...value];
|
||||
}
|
||||
|
||||
function setFormStoreScopeMode(value: MarketingCouponStoreScopeMode) {
|
||||
options.form.storeScopeMode = value;
|
||||
if (value === 'all') {
|
||||
options.form.storeIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
function setFormStoreIds(value: string[]) {
|
||||
options.form.storeIds = [...value];
|
||||
}
|
||||
|
||||
function setFormStatus(checked: boolean) {
|
||||
options.form.status = checked ? 'enabled' : 'disabled';
|
||||
}
|
||||
|
||||
async function openCreateDrawer() {
|
||||
resetForm();
|
||||
drawerMode.value = 'create';
|
||||
options.isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function openEditDrawer(couponId: string) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
options.isDrawerLoading.value = true;
|
||||
try {
|
||||
const detail = await getMarketingCouponDetailApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
couponId,
|
||||
});
|
||||
|
||||
applyForm(mapDetailToEditorForm(detail));
|
||||
drawerMode.value = 'edit';
|
||||
options.isDrawerOpen.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isDrawerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDrawer() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
if (!validateBeforeSubmit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.isDrawerSubmitting.value = true;
|
||||
try {
|
||||
await saveMarketingCouponApi(
|
||||
buildSaveCouponPayload(options.form, options.selectedStoreId.value),
|
||||
);
|
||||
message.success(
|
||||
drawerMode.value === 'create' ? '优惠券已创建' : '优惠券已更新',
|
||||
);
|
||||
options.isDrawerOpen.value = false;
|
||||
await options.loadCoupons();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBeforeSubmit() {
|
||||
if (!options.form.name.trim()) {
|
||||
message.warning('请输入券名称');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.couponType !== 'free_delivery' &&
|
||||
(options.form.value === null || options.form.value <= 0)
|
||||
) {
|
||||
message.warning('请设置正确的优惠额度');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.couponType === 'discount' &&
|
||||
options.form.value !== null &&
|
||||
options.form.value >= 10
|
||||
) {
|
||||
message.warning('折扣券面额必须小于 10');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.couponType === 'amount_off' &&
|
||||
(options.form.minimumSpend === null || options.form.minimumSpend <= 0)
|
||||
) {
|
||||
message.warning('满减券必须设置使用门槛');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.totalQuantity <= 0) {
|
||||
message.warning('发放总量必须大于 0');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.perUserLimit !== null && options.form.perUserLimit <= 0) {
|
||||
message.warning('每人限领必须大于 0');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.validityType === 'fixed' && !options.form.validDateRange) {
|
||||
message.warning('请选择固定有效期');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.validityType === 'days' &&
|
||||
(!options.form.relativeValidDays || options.form.relativeValidDays <= 0)
|
||||
) {
|
||||
message.warning('领取后有效天数必须大于 0');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.form.channels.length === 0) {
|
||||
message.warning('请至少选择一个适用渠道');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.form.storeScopeMode === 'stores' &&
|
||||
options.form.storeIds.length === 0
|
||||
) {
|
||||
message.warning('请选择至少一个适用门店');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
drawerMode,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
setDrawerOpen,
|
||||
setFormChannels,
|
||||
setFormCouponType,
|
||||
setFormMinimumSpend,
|
||||
setFormName,
|
||||
setFormPerUserLimit,
|
||||
setFormRelativeValidDays,
|
||||
setFormStatus,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormTotalQuantity,
|
||||
setFormValidDateRange,
|
||||
setFormValidityType,
|
||||
setFormValue,
|
||||
submitDrawer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
MarketingCouponChannel,
|
||||
MarketingCouponDetailDto,
|
||||
MarketingCouponType,
|
||||
SaveMarketingCouponDto,
|
||||
} from '#/api/marketing';
|
||||
import type {
|
||||
CouponCardViewModel,
|
||||
CouponEditorForm,
|
||||
} from '#/views/marketing/coupon/types';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券页面纯函数。
|
||||
* 1. 负责列表文案、视觉样式映射。
|
||||
* 2. 负责表单与 API DTO 的互转。
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
COUPON_CHANNEL_TEXT_MAP,
|
||||
COUPON_TYPE_FILTER_OPTIONS,
|
||||
createDefaultCouponEditorForm,
|
||||
} from './constants';
|
||||
|
||||
/** 领取进度百分比。 */
|
||||
export function resolveClaimedProgressPercent(
|
||||
claimedQuantity: number,
|
||||
totalQuantity: number,
|
||||
) {
|
||||
if (totalQuantity <= 0) return 0;
|
||||
const percent = Math.round((claimedQuantity * 100) / totalQuantity);
|
||||
return Math.max(0, Math.min(100, percent));
|
||||
}
|
||||
|
||||
/** 千分位格式化。 */
|
||||
export function formatInteger(value: number) {
|
||||
return Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** 优惠券左侧色板样式类。 */
|
||||
export function resolveCouponTypeClass(couponType: MarketingCouponType) {
|
||||
if (couponType === 'amount_off') {
|
||||
return 'mcp-left-red';
|
||||
}
|
||||
if (couponType === 'discount') {
|
||||
return 'mcp-left-blue';
|
||||
}
|
||||
return 'mcp-left-green';
|
||||
}
|
||||
|
||||
/** 进度条颜色类。 */
|
||||
export function resolveProgressClass(couponType: MarketingCouponType) {
|
||||
if (couponType === 'amount_off') {
|
||||
return 'mcp-progress-red';
|
||||
}
|
||||
if (couponType === 'discount') {
|
||||
return 'mcp-progress-blue';
|
||||
}
|
||||
return 'mcp-progress-green';
|
||||
}
|
||||
|
||||
/** 列表类型文案。 */
|
||||
export function resolveCouponTypeLabel(couponType: MarketingCouponType) {
|
||||
return (
|
||||
COUPON_TYPE_FILTER_OPTIONS.find((item) => item.value === couponType)
|
||||
?.label ?? '优惠券'
|
||||
);
|
||||
}
|
||||
|
||||
/** 列表券面主文案。 */
|
||||
export function resolveCouponFaceValueText(item: CouponCardViewModel) {
|
||||
if (item.couponType === 'amount_off') {
|
||||
return `¥${trimDecimal(item.value)}`;
|
||||
}
|
||||
if (item.couponType === 'discount') {
|
||||
return `${trimDecimal(item.value)}折`;
|
||||
}
|
||||
return '免配送';
|
||||
}
|
||||
|
||||
/** 列表券面副文案。 */
|
||||
export function resolveCouponFaceConditionText(item: CouponCardViewModel) {
|
||||
if (item.couponType === 'amount_off' || item.couponType === 'discount') {
|
||||
if (item.minimumSpend && item.minimumSpend > 0) {
|
||||
return `满${trimDecimal(item.minimumSpend)}可用`;
|
||||
}
|
||||
return item.couponType === 'discount' ? '全场通用' : '无门槛';
|
||||
}
|
||||
return '无门槛';
|
||||
}
|
||||
|
||||
/** 列表有效期文案。 */
|
||||
export function resolveCouponValidityText(item: CouponCardViewModel) {
|
||||
if (item.validFrom && item.validTo) {
|
||||
return `${item.validFrom.replaceAll('-', '.')} - ${item.validTo.replaceAll('-', '.')}`;
|
||||
}
|
||||
if (item.relativeValidDays && item.relativeValidDays > 0) {
|
||||
return `领取后 ${item.relativeValidDays} 天内有效`;
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
|
||||
/** 列表规则文案。 */
|
||||
export function resolveCouponRuleText(item: CouponCardViewModel) {
|
||||
const limitText =
|
||||
item.perUserLimit && item.perUserLimit > 0
|
||||
? `每人限领${item.perUserLimit}张`
|
||||
: '不限领';
|
||||
|
||||
const channelText = resolveChannelsText(item.channels);
|
||||
return `${limitText} | ${channelText}`;
|
||||
}
|
||||
|
||||
/** 渠道文本。 */
|
||||
export function resolveChannelsText(channels: MarketingCouponChannel[]) {
|
||||
const normalized = channels.toSorted();
|
||||
const allChannels: MarketingCouponChannel[] = [
|
||||
'delivery',
|
||||
'pickup',
|
||||
'dine_in',
|
||||
];
|
||||
const isAll = allChannels.every((channel) => normalized.includes(channel));
|
||||
if (isAll) {
|
||||
return '全渠道可用';
|
||||
}
|
||||
|
||||
const channelLabels = normalized.map(
|
||||
(channel) => COUPON_CHANNEL_TEXT_MAP[channel],
|
||||
);
|
||||
if (channelLabels.length === 1) {
|
||||
return `仅${channelLabels[0]}可用`;
|
||||
}
|
||||
return `${channelLabels.join('/')}可用`;
|
||||
}
|
||||
|
||||
/** 详情映射为编辑表单。 */
|
||||
export function mapDetailToEditorForm(
|
||||
detail: MarketingCouponDetailDto,
|
||||
): CouponEditorForm {
|
||||
const form = createDefaultCouponEditorForm();
|
||||
form.id = detail.id;
|
||||
form.name = detail.name;
|
||||
form.couponType = detail.couponType;
|
||||
form.value = detail.couponType === 'free_delivery' ? null : detail.value;
|
||||
form.minimumSpend = detail.minimumSpend;
|
||||
form.totalQuantity = detail.totalQuantity;
|
||||
form.perUserLimit = detail.perUserLimit;
|
||||
form.validityType = detail.validityType;
|
||||
form.validDateRange =
|
||||
detail.validityType === 'fixed' && detail.validFrom && detail.validTo
|
||||
? [dayjs(detail.validFrom), dayjs(detail.validTo)]
|
||||
: null;
|
||||
form.relativeValidDays = detail.relativeValidDays;
|
||||
form.channels = [...detail.channels];
|
||||
form.storeScopeMode = detail.storeScopeMode;
|
||||
form.storeIds = [...detail.storeIds];
|
||||
form.status = detail.status;
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 编辑表单构建保存请求。 */
|
||||
export function buildSaveCouponPayload(
|
||||
form: CouponEditorForm,
|
||||
storeId: string,
|
||||
): SaveMarketingCouponDto {
|
||||
const validFrom =
|
||||
form.validityType === 'fixed' && form.validDateRange
|
||||
? form.validDateRange[0].format('YYYY-MM-DD')
|
||||
: null;
|
||||
const validTo =
|
||||
form.validityType === 'fixed' && form.validDateRange
|
||||
? form.validDateRange[1].format('YYYY-MM-DD')
|
||||
: null;
|
||||
|
||||
const value =
|
||||
form.couponType === 'free_delivery' ? 0 : Number(form.value ?? 0);
|
||||
const minimumSpend =
|
||||
form.couponType === 'free_delivery'
|
||||
? null
|
||||
: normalizeNullableNumber(form.minimumSpend);
|
||||
|
||||
return {
|
||||
id: form.id || undefined,
|
||||
storeId,
|
||||
name: form.name.trim(),
|
||||
couponType: form.couponType,
|
||||
value,
|
||||
minimumSpend,
|
||||
totalQuantity: Math.floor(Number(form.totalQuantity || 0)),
|
||||
perUserLimit: normalizeNullableNumber(form.perUserLimit),
|
||||
validityType: form.validityType,
|
||||
validFrom,
|
||||
validTo,
|
||||
relativeValidDays:
|
||||
form.validityType === 'days'
|
||||
? Math.floor(Number(form.relativeValidDays || 0))
|
||||
: null,
|
||||
channels: [...form.channels],
|
||||
storeScopeMode: form.storeScopeMode,
|
||||
storeIds: form.storeScopeMode === 'stores' ? [...form.storeIds] : undefined,
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNullableNumber(value: null | number) {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function trimDecimal(value: number) {
|
||||
const fixed = value.toFixed(2);
|
||||
return fixed.replace(/\.?0+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type { CouponCardViewModel } from '#/views/marketing/coupon/types';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券页面状态与行为编排。
|
||||
* 1. 管理门店、筛选、分页、统计与加载状态。
|
||||
* 2. 编排抽屉编辑动作与卡片启停删除动作。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { createCardActions } from './coupon-page/card-actions';
|
||||
import {
|
||||
COUPON_STATUS_FILTER_OPTIONS,
|
||||
COUPON_TYPE_FILTER_OPTIONS,
|
||||
createDefaultCouponEditorForm,
|
||||
createDefaultCouponFilterForm,
|
||||
} from './coupon-page/constants';
|
||||
import {
|
||||
createDataActions,
|
||||
createEmptyStats,
|
||||
} from './coupon-page/data-actions';
|
||||
import { createDrawerActions } from './coupon-page/drawer-actions';
|
||||
|
||||
export function useMarketingCouponPage() {
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const filterForm = reactive(createDefaultCouponFilterForm());
|
||||
const keyword = ref('');
|
||||
|
||||
const rows = ref<CouponCardViewModel[]>([]);
|
||||
const stats = ref(createEmptyStats());
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const isDrawerOpen = ref(false);
|
||||
const isDrawerLoading = ref(false);
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const form = reactive(createDefaultCouponEditorForm());
|
||||
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasStore = computed(() => !!selectedStoreId.value);
|
||||
|
||||
const { loadCoupons, loadStores } = createDataActions({
|
||||
stores,
|
||||
selectedStoreId,
|
||||
isStoreLoading,
|
||||
filterForm,
|
||||
keyword,
|
||||
rows,
|
||||
stats,
|
||||
isLoading,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
});
|
||||
|
||||
function setSelectedStoreId(value: string) {
|
||||
selectedStoreId.value = value;
|
||||
}
|
||||
|
||||
function setKeyword(value: string) {
|
||||
keyword.value = value;
|
||||
}
|
||||
|
||||
function setStatusFilter(value: '' | CouponCardViewModel['displayStatus']) {
|
||||
filterForm.status = value;
|
||||
}
|
||||
|
||||
function setTypeFilter(value: '' | CouponCardViewModel['couponType']) {
|
||||
filterForm.couponType = value;
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
page.value = 1;
|
||||
await loadCoupons();
|
||||
}
|
||||
|
||||
async function resetFilters() {
|
||||
filterForm.status = '';
|
||||
filterForm.couponType = '';
|
||||
keyword.value = '';
|
||||
page.value = 1;
|
||||
await loadCoupons();
|
||||
}
|
||||
|
||||
async function handlePageChange(nextPage: number, nextPageSize: number) {
|
||||
page.value = nextPage;
|
||||
pageSize.value = nextPageSize;
|
||||
await loadCoupons();
|
||||
}
|
||||
|
||||
const {
|
||||
drawerMode,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
setDrawerOpen,
|
||||
setFormChannels,
|
||||
setFormCouponType,
|
||||
setFormMinimumSpend,
|
||||
setFormName,
|
||||
setFormPerUserLimit,
|
||||
setFormRelativeValidDays,
|
||||
setFormStatus,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormTotalQuantity,
|
||||
setFormValidDateRange,
|
||||
setFormValidityType,
|
||||
setFormValue,
|
||||
submitDrawer,
|
||||
} = createDrawerActions({
|
||||
form,
|
||||
isDrawerLoading,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
selectedStoreId,
|
||||
loadCoupons,
|
||||
});
|
||||
|
||||
const { disableCoupon, enableCoupon, removeCoupon } = createCardActions({
|
||||
selectedStoreId,
|
||||
loadCoupons,
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
drawerMode.value === 'create' ? '创建优惠券' : '编辑优惠券',
|
||||
);
|
||||
const drawerSubmitText = computed(() => '保存');
|
||||
|
||||
watch(selectedStoreId, () => {
|
||||
page.value = 1;
|
||||
keyword.value = '';
|
||||
filterForm.status = '';
|
||||
filterForm.couponType = '';
|
||||
void loadCoupons();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStores();
|
||||
});
|
||||
|
||||
return {
|
||||
applyFilters,
|
||||
COUPON_STATUS_FILTER_OPTIONS,
|
||||
COUPON_TYPE_FILTER_OPTIONS,
|
||||
disableCoupon,
|
||||
drawerSubmitText,
|
||||
drawerTitle,
|
||||
enableCoupon,
|
||||
filterForm,
|
||||
form,
|
||||
handlePageChange,
|
||||
hasStore,
|
||||
isDrawerLoading,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
isLoading,
|
||||
isStoreLoading,
|
||||
keyword,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
page,
|
||||
pageSize,
|
||||
removeCoupon,
|
||||
resetFilters,
|
||||
rows,
|
||||
selectedStoreId,
|
||||
setDrawerOpen,
|
||||
setFormChannels,
|
||||
setFormCouponType,
|
||||
setFormMinimumSpend,
|
||||
setFormName,
|
||||
setFormPerUserLimit,
|
||||
setFormRelativeValidDays,
|
||||
setFormStatus,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormTotalQuantity,
|
||||
setFormValidDateRange,
|
||||
setFormValidityType,
|
||||
setFormValue,
|
||||
setKeyword,
|
||||
setSelectedStoreId,
|
||||
setStatusFilter,
|
||||
setTypeFilter,
|
||||
stats,
|
||||
storeOptions,
|
||||
stores,
|
||||
submitDrawer,
|
||||
total,
|
||||
};
|
||||
}
|
||||
198
apps/web-antd/src/views/marketing/coupon/index.vue
Normal file
198
apps/web-antd/src/views/marketing/coupon/index.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:营销中心-优惠券页面主视图。
|
||||
* 1. 还原原型工具栏、统计、卡片列表和分页。
|
||||
* 2. 编排优惠券新增编辑抽屉与卡片操作。
|
||||
*/
|
||||
import type {
|
||||
MarketingCouponDisplayStatus,
|
||||
MarketingCouponType,
|
||||
} from '#/api/marketing';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Empty, Input, Pagination, Select, Spin } from 'ant-design-vue';
|
||||
|
||||
import CouponEditorDrawer from './components/CouponEditorDrawer.vue';
|
||||
import CouponStatsCards from './components/CouponStatsCards.vue';
|
||||
import CouponTemplateCard from './components/CouponTemplateCard.vue';
|
||||
import { useMarketingCouponPage } from './composables/useMarketingCouponPage';
|
||||
|
||||
const {
|
||||
applyFilters,
|
||||
COUPON_STATUS_FILTER_OPTIONS,
|
||||
COUPON_TYPE_FILTER_OPTIONS,
|
||||
disableCoupon,
|
||||
drawerSubmitText,
|
||||
drawerTitle,
|
||||
enableCoupon,
|
||||
filterForm,
|
||||
form,
|
||||
handlePageChange,
|
||||
hasStore,
|
||||
isDrawerLoading,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
isLoading,
|
||||
isStoreLoading,
|
||||
keyword,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
page,
|
||||
pageSize,
|
||||
removeCoupon,
|
||||
resetFilters,
|
||||
rows,
|
||||
selectedStoreId,
|
||||
setDrawerOpen,
|
||||
setFormChannels,
|
||||
setFormCouponType,
|
||||
setFormMinimumSpend,
|
||||
setFormName,
|
||||
setFormPerUserLimit,
|
||||
setFormRelativeValidDays,
|
||||
setFormStatus,
|
||||
setFormStoreIds,
|
||||
setFormStoreScopeMode,
|
||||
setFormTotalQuantity,
|
||||
setFormValidDateRange,
|
||||
setFormValidityType,
|
||||
setFormValue,
|
||||
setKeyword,
|
||||
setSelectedStoreId,
|
||||
setStatusFilter,
|
||||
setTypeFilter,
|
||||
stats,
|
||||
storeOptions,
|
||||
submitDrawer,
|
||||
total,
|
||||
} = useMarketingCouponPage();
|
||||
|
||||
function onStatusFilterChange(value: unknown) {
|
||||
const next =
|
||||
typeof value === 'string' && value
|
||||
? (value as MarketingCouponDisplayStatus)
|
||||
: '';
|
||||
setStatusFilter(next);
|
||||
void applyFilters();
|
||||
}
|
||||
|
||||
function onTypeFilterChange(value: unknown) {
|
||||
const next =
|
||||
typeof value === 'string' && value ? (value as MarketingCouponType) : '';
|
||||
setTypeFilter(next);
|
||||
void applyFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="优惠券" content-class="page-marketing-coupon">
|
||||
<div class="mcp-page">
|
||||
<div class="mcp-toolbar">
|
||||
<Select
|
||||
class="mcp-store-select"
|
||||
:value="selectedStoreId"
|
||||
:options="storeOptions"
|
||||
:loading="isStoreLoading"
|
||||
placeholder="请选择门店"
|
||||
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
|
||||
/>
|
||||
|
||||
<Select
|
||||
class="mcp-filter-select"
|
||||
:value="filterForm.status"
|
||||
placeholder="全部状态"
|
||||
:options="COUPON_STATUS_FILTER_OPTIONS"
|
||||
@update:value="onStatusFilterChange"
|
||||
/>
|
||||
|
||||
<Select
|
||||
class="mcp-filter-select"
|
||||
:value="filterForm.couponType"
|
||||
placeholder="全部类型"
|
||||
:options="COUPON_TYPE_FILTER_OPTIONS"
|
||||
@update:value="onTypeFilterChange"
|
||||
/>
|
||||
|
||||
<Input
|
||||
class="mcp-search"
|
||||
:value="keyword"
|
||||
placeholder="搜索券名称"
|
||||
allow-clear
|
||||
@update:value="(value) => setKeyword(String(value ?? ''))"
|
||||
@press-enter="applyFilters"
|
||||
/>
|
||||
|
||||
<Button @click="applyFilters">搜索</Button>
|
||||
<Button @click="resetFilters">重置</Button>
|
||||
|
||||
<span class="mcp-spacer"></span>
|
||||
<Button type="primary" @click="openCreateDrawer">创建优惠券</Button>
|
||||
</div>
|
||||
|
||||
<CouponStatsCards v-if="hasStore" :stats="stats" />
|
||||
|
||||
<div v-if="!hasStore" class="mcp-empty">暂无门店,请先创建门店</div>
|
||||
|
||||
<Spin v-else :spinning="isLoading">
|
||||
<div v-if="rows.length > 0" class="mcp-list">
|
||||
<CouponTemplateCard
|
||||
v-for="item in rows"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@edit="(row) => openEditDrawer(row.id)"
|
||||
@view="(row) => openEditDrawer(row.id)"
|
||||
@enable="enableCoupon"
|
||||
@disable="disableCoupon"
|
||||
@remove="removeCoupon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mcp-empty">
|
||||
<Empty description="暂无优惠券" />
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length > 0" class="mcp-pagination">
|
||||
<Pagination
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50']"
|
||||
:show-total="(value) => `共 ${value} 条`"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<CouponEditorDrawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
:submit-text="drawerSubmitText"
|
||||
:submitting="isDrawerSubmitting"
|
||||
:loading="isDrawerLoading"
|
||||
:form="form"
|
||||
:store-options="storeOptions"
|
||||
@close="setDrawerOpen(false)"
|
||||
@set-name="setFormName"
|
||||
@set-coupon-type="setFormCouponType"
|
||||
@set-value="setFormValue"
|
||||
@set-minimum-spend="setFormMinimumSpend"
|
||||
@set-total-quantity="setFormTotalQuantity"
|
||||
@set-per-user-limit="setFormPerUserLimit"
|
||||
@set-validity-type="setFormValidityType"
|
||||
@set-valid-date-range="setFormValidDateRange"
|
||||
@set-relative-valid-days="setFormRelativeValidDays"
|
||||
@set-channels="setFormChannels"
|
||||
@set-store-scope-mode="setFormStoreScopeMode"
|
||||
@set-store-ids="setFormStoreIds"
|
||||
@set-status="setFormStatus"
|
||||
@submit="submitDrawer"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
30
apps/web-antd/src/views/marketing/coupon/styles/base.less
Normal file
30
apps/web-antd/src/views/marketing/coupon/styles/base.less
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 文件职责:优惠券页面基础样式。
|
||||
* 1. 定义页面级变量与共用动作按钮。
|
||||
*/
|
||||
.page-marketing-coupon {
|
||||
--mcp-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--mcp-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
--mcp-shadow-md: 0 6px 16px rgb(0 0 0 / 8%), 0 1px 3px rgb(0 0 0 / 6%);
|
||||
--mcp-border: #e7eaf0;
|
||||
--mcp-text: #1f2937;
|
||||
--mcp-subtext: #6b7280;
|
||||
--mcp-muted: #9ca3af;
|
||||
|
||||
.g-action {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.g-action + .g-action {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.g-action-danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
204
apps/web-antd/src/views/marketing/coupon/styles/card.less
Normal file
204
apps/web-antd/src/views/marketing/coupon/styles/card.less
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 文件职责:优惠券卡片样式。
|
||||
* 1. 还原券面左区、详情右区、进度与状态条。
|
||||
*/
|
||||
.page-marketing-coupon {
|
||||
.mcp-coupon {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mcp-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--mcp-shadow-sm);
|
||||
transition:
|
||||
box-shadow var(--mcp-transition),
|
||||
transform var(--mcp-transition);
|
||||
}
|
||||
|
||||
.mcp-coupon:hover {
|
||||
box-shadow: var(--mcp-shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mcp-coupon.mcp-dimmed {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.mcp-left {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 160px;
|
||||
min-height: 132px;
|
||||
padding: 16px 10px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mcp-left::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
content: '';
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.mcp-left-red {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ef5350);
|
||||
}
|
||||
|
||||
.mcp-left-blue {
|
||||
background: linear-gradient(135deg, #4facfe, #1677ff);
|
||||
}
|
||||
|
||||
.mcp-left-green {
|
||||
color: #1a5c3a;
|
||||
background: linear-gradient(135deg, #6ee7b7, #34d399);
|
||||
}
|
||||
|
||||
.mcp-face-value {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.mcp-face-cond {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.mcp-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.mcp-title-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mcp-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mcp-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcp-type-pill {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mcp-validity {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mcp-muted);
|
||||
}
|
||||
|
||||
.mcp-validity .iconify {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.mcp-rules {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--mcp-subtext);
|
||||
}
|
||||
|
||||
.mcp-progress-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mcp-subtext);
|
||||
}
|
||||
|
||||
.mcp-progress-wrap {
|
||||
flex: 1;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.mcp-progress-bar {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
background: #edf0f5;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mcp-progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mcp-progress-red {
|
||||
background: linear-gradient(90deg, #ff6b6b, #ef5350);
|
||||
}
|
||||
|
||||
.mcp-progress-blue {
|
||||
background: linear-gradient(90deg, #4facfe, #1677ff);
|
||||
}
|
||||
|
||||
.mcp-progress-green {
|
||||
background: linear-gradient(90deg, #6ee7b7, #10b981);
|
||||
}
|
||||
|
||||
.mcp-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mcp-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mcp-status-ongoing {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.mcp-status-upcoming {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.mcp-status-ended {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.mcp-status-disabled {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
59
apps/web-antd/src/views/marketing/coupon/styles/drawer.less
Normal file
59
apps/web-antd/src/views/marketing/coupon/styles/drawer.less
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 文件职责:优惠券编辑抽屉样式。
|
||||
* 1. 规范表单行内布局与提示文案样式。
|
||||
*/
|
||||
.page-marketing-coupon {
|
||||
.mcp-editor-form {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-inline-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mcp-inline-fields .ant-input-number {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.mcp-input-wide {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mcp-field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.mcp-form-muted {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mcp-range-picker {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.mcp-store-multi {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mcp-switch-row {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mcp-drawer-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import './base.less';
|
||||
@import './layout.less';
|
||||
@import './card.less';
|
||||
@import './drawer.less';
|
||||
@import './responsive.less';
|
||||
140
apps/web-antd/src/views/marketing/coupon/styles/layout.less
Normal file
140
apps/web-antd/src/views/marketing/coupon/styles/layout.less
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 文件职责:优惠券页面布局样式。
|
||||
* 1. 工具栏、统计区、空态与分页布局。
|
||||
*/
|
||||
.page-marketing-coupon {
|
||||
.mcp-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mcp-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mcp-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--mcp-shadow-sm);
|
||||
}
|
||||
|
||||
.mcp-store-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.mcp-store-select .ant-select-selector,
|
||||
.mcp-filter-select .ant-select-selector {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.mcp-filter-select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.mcp-search {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.mcp-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mcp-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mcp-stat-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mcp-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--mcp-shadow-sm);
|
||||
transition: box-shadow var(--mcp-transition);
|
||||
}
|
||||
|
||||
.mcp-stat-card:hover {
|
||||
box-shadow: var(--mcp-shadow-md);
|
||||
}
|
||||
|
||||
.mcp-stat-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcp-stat-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mcp-stat-blue {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.mcp-stat-green {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.mcp-stat-orange {
|
||||
color: #fa8c16;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.mcp-stat-purple {
|
||||
color: #722ed1;
|
||||
background: #f9f0ff;
|
||||
}
|
||||
|
||||
.mcp-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--mcp-text);
|
||||
}
|
||||
|
||||
.mcp-stat-label {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: var(--mcp-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mcp-empty {
|
||||
padding: 28px 14px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mcp-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--mcp-shadow-sm);
|
||||
}
|
||||
|
||||
.mcp-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 4px 2px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 文件职责:优惠券页面响应式样式。
|
||||
*/
|
||||
.page-marketing-coupon {
|
||||
@media (width <= 1200px) {
|
||||
.mcp-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mcp-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcp-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.mcp-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mcp-coupon {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mcp-left {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.mcp-left::after {
|
||||
inset: auto auto -6px 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.mcp-progress-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mcp-progress-wrap {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
apps/web-antd/src/views/marketing/coupon/types.ts
Normal file
46
apps/web-antd/src/views/marketing/coupon/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type {
|
||||
MarketingCouponChannel,
|
||||
MarketingCouponDisplayStatus,
|
||||
MarketingCouponEditorStatus,
|
||||
MarketingCouponListItemDto,
|
||||
MarketingCouponStatsDto,
|
||||
MarketingCouponStoreScopeMode,
|
||||
MarketingCouponType,
|
||||
MarketingCouponValidityType,
|
||||
} from '#/api/marketing';
|
||||
|
||||
/**
|
||||
* 文件职责:优惠券页面类型定义。
|
||||
*/
|
||||
|
||||
/** 优惠券筛选表单。 */
|
||||
export interface CouponFilterForm {
|
||||
couponType: '' | MarketingCouponType;
|
||||
status: '' | MarketingCouponDisplayStatus;
|
||||
}
|
||||
|
||||
/** 优惠券编辑抽屉表单。 */
|
||||
export interface CouponEditorForm {
|
||||
channels: MarketingCouponChannel[];
|
||||
couponType: MarketingCouponType;
|
||||
id: string;
|
||||
minimumSpend: null | number;
|
||||
name: string;
|
||||
perUserLimit: null | number;
|
||||
relativeValidDays: null | number;
|
||||
status: MarketingCouponEditorStatus;
|
||||
storeIds: string[];
|
||||
storeScopeMode: MarketingCouponStoreScopeMode;
|
||||
totalQuantity: number;
|
||||
validDateRange: [Dayjs, Dayjs] | null;
|
||||
validityType: MarketingCouponValidityType;
|
||||
value: null | number;
|
||||
}
|
||||
|
||||
/** 优惠券卡片视图模型。 */
|
||||
export type CouponCardViewModel = MarketingCouponListItemDto;
|
||||
|
||||
/** 优惠券统计视图模型。 */
|
||||
export type CouponStatsViewModel = MarketingCouponStatsDto;
|
||||
Reference in New Issue
Block a user