feat: 完成营销中心优惠券页面与抽屉
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 53s

This commit is contained in:
2026-02-28 11:21:15 +08:00
parent ed2f30a2a6
commit f000464810
18 changed files with 2531 additions and 0 deletions

View 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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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',
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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+$/, '');
}

View File

@@ -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,
};
}

View 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>

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

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

View 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;
}
}

View File

@@ -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;
}
}
}

View 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;