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