feat(project): 对齐优惠券抽屉细节与领取状态交互
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 48s

This commit is contained in:
2026-02-28 13:07:25 +08:00
parent f000464810
commit c868268bbb
9 changed files with 301 additions and 72 deletions

View File

@@ -12,7 +12,6 @@ import type {
import {
Button,
Checkbox,
DatePicker,
Drawer,
Form,
@@ -21,7 +20,6 @@ import {
Radio,
Select,
Spin,
Switch,
} from 'ant-design-vue';
/**
* 文件职责:优惠券编辑抽屉。
@@ -55,7 +53,6 @@ const emit = defineEmits<{
(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;
@@ -65,16 +62,6 @@ const emit = defineEmits<{
(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));
}
@@ -140,19 +127,11 @@ 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;
@@ -160,6 +139,16 @@ function parseNullableNumber(value: null | number | string) {
const numeric = Number(value);
return Number.isNaN(numeric) ? null : numeric;
}
function resolveToggledChannels(
channels: MarketingCouponChannel[],
channel: MarketingCouponChannel,
) {
if (channels.includes(channel)) {
return channels.filter((item) => item !== channel);
}
return [...channels, channel];
}
</script>
<template>
@@ -168,6 +157,7 @@ function parseNullableNumber(value: null | number | string) {
:title="title"
width="560"
:destroy-on-close="false"
class="mcp-editor-drawer"
@close="emit('close')"
>
<Spin :spinning="loading">
@@ -249,6 +239,7 @@ function parseNullableNumber(value: null | number | string) {
>
免配送费券无需设置面额
</Form.Item>
<div class="mcp-section-divider"></div>
<Form.Item label="发放总量" required>
<InputNumber
@@ -273,6 +264,7 @@ function parseNullableNumber(value: null | number | string) {
/>
<div class="mcp-field-hint">不填则不限制</div>
</Form.Item>
<div class="mcp-section-divider"></div>
<Form.Item label="有效期类型" required>
<Radio.Group
@@ -317,7 +309,12 @@ function parseNullableNumber(value: null | number | string) {
</div>
</Form.Item>
<Form.Item v-if="form.couponType === 'discount'" label="使用门槛">
<div
v-if="form.couponType !== 'free_delivery'"
class="mcp-section-divider"
></div>
<Form.Item v-if="form.couponType !== 'free_delivery'" label="使用门槛">
<div class="mcp-inline-fields">
<span>订单满</span>
<InputNumber
@@ -333,11 +330,23 @@ function parseNullableNumber(value: null | number | string) {
</Form.Item>
<Form.Item label="适用渠道" required>
<Checkbox.Group
:value="form.channels"
:options="COUPON_CHANNEL_OPTIONS"
@change="onChannelsGroupChange"
/>
<div class="mcp-channel-pills">
<button
v-for="item in COUPON_CHANNEL_OPTIONS"
:key="item.value"
type="button"
class="mcp-channel-pill"
:class="{ checked: form.channels.includes(item.value) }"
@click="
emit(
'setChannels',
resolveToggledChannels(form.channels, item.value),
)
"
>
{{ item.label }}
</button>
</div>
</Form.Item>
<Form.Item label="适用门店" required>
@@ -364,16 +373,6 @@ function parseNullableNumber(value: null | number | string) {
@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>

View File

@@ -110,7 +110,7 @@ function resolveClaimedText(item: CouponCardViewModel) {
class="g-action"
@click="emit('enable', item)"
>
启用
开放领取
</button>
<button
type="button"
@@ -130,7 +130,7 @@ function resolveClaimedText(item: CouponCardViewModel) {
class="g-action"
@click="emit('disable', item)"
>
停用
暂停领取
</button>
<button
type="button"

View File

@@ -20,31 +20,80 @@ interface CreateCardActionsOptions {
}
export function createCardActions(options: CreateCardActionsOptions) {
async function enableCoupon(item: CouponCardViewModel) {
await updateStatus(item, 'enabled', '优惠券已启用');
function enableCoupon(item: CouponCardViewModel) {
openStatusConfirm(item, {
actionText: '开放领取',
confirmHint: '开放后,用户可按规则继续领取该优惠券。',
status: 'enabled',
successMessage: '优惠券已开放领取',
});
}
async function disableCoupon(item: CouponCardViewModel) {
await updateStatus(item, 'disabled', '优惠券已停用');
function disableCoupon(item: CouponCardViewModel) {
openStatusConfirm(item, {
actionText: '暂停领取',
confirmHint:
'暂停后仅停止新用户领取,已领取优惠券仍可在有效期内继续使用。',
status: 'disabled',
successMessage: '优惠券已暂停领取',
});
}
function openStatusConfirm(
item: CouponCardViewModel,
params: {
actionText: '开放领取' | '暂停领取';
confirmHint: string;
status: 'disabled' | 'enabled';
successMessage: string;
},
) {
if (!options.selectedStoreId.value) return;
Modal.confirm({
title: `确认${params.actionText}优惠券「${item.name}」吗?`,
content: params.confirmHint,
okText: `确认${params.actionText}`,
cancelText: '取消',
async onOk() {
await updateStatus(
item,
params.status,
params.actionText,
params.successMessage,
);
},
});
}
async function updateStatus(
item: CouponCardViewModel,
status: 'disabled' | 'enabled',
actionText: '开放领取' | '暂停领取',
successMessage: string,
) {
if (!options.selectedStoreId.value) return;
const feedbackKey = `coupon-status-${item.id}`;
try {
message.loading({
content: `正在${actionText}优惠券...`,
duration: 0,
key: feedbackKey,
});
await changeMarketingCouponStatusApi({
storeId: options.selectedStoreId.value,
couponId: item.id,
status,
});
message.success(successMessage);
message.success({ content: successMessage, key: feedbackKey });
await options.loadCoupons();
} catch (error) {
console.error(error);
message.error({
content: `${actionText}失败,请稍后重试`,
key: feedbackKey,
});
}
}

View File

@@ -22,7 +22,7 @@ export const COUPON_STATUS_FILTER_OPTIONS: Array<{
{ label: '进行中', value: 'ongoing' },
{ label: '未开始', value: 'upcoming' },
{ label: '已结束', value: 'ended' },
{ label: '已停用', value: 'disabled' },
{ label: '暂停领取', value: 'disabled' },
];
/** 列表类型筛选项。 */
@@ -79,7 +79,7 @@ export const COUPON_STATUS_TEXT_MAP: Record<
ongoing: '进行中',
upcoming: '未开始',
ended: '已结束',
disabled: '已停用',
disabled: '暂停领取',
};
/** 列表状态徽标样式。 */
@@ -124,6 +124,6 @@ export function createDefaultCouponEditorForm(): CouponEditorForm {
channels: ['delivery', 'pickup', 'dine_in'],
storeScopeMode: 'all',
storeIds: [],
status: 'enabled',
status: 'disabled',
};
}

View File

@@ -128,10 +128,6 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
options.form.storeIds = [...value];
}
function setFormStatus(checked: boolean) {
options.form.status = checked ? 'enabled' : 'disabled';
}
async function openCreateDrawer() {
resetForm();
drawerMode.value = 'create';
@@ -262,7 +258,6 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
setFormName,
setFormPerUserLimit,
setFormRelativeValidDays,
setFormStatus,
setFormStoreIds,
setFormStoreScopeMode,
setFormTotalQuantity,

View File

@@ -110,7 +110,6 @@ export function useMarketingCouponPage() {
setFormName,
setFormPerUserLimit,
setFormRelativeValidDays,
setFormStatus,
setFormStoreIds,
setFormStoreScopeMode,
setFormTotalQuantity,
@@ -182,7 +181,6 @@ export function useMarketingCouponPage() {
setFormName,
setFormPerUserLimit,
setFormRelativeValidDays,
setFormStatus,
setFormStoreIds,
setFormStoreScopeMode,
setFormTotalQuantity,

View File

@@ -51,7 +51,6 @@ const {
setFormName,
setFormPerUserLimit,
setFormRelativeValidDays,
setFormStatus,
setFormStoreIds,
setFormStoreScopeMode,
setFormTotalQuantity,
@@ -187,7 +186,6 @@ function onTypeFilterChange(value: unknown) {
@set-channels="setFormChannels"
@set-store-scope-mode="setFormStoreScopeMode"
@set-store-ids="setFormStoreIds"
@set-status="setFormStatus"
@submit="submitDrawer"
/>
</Page>

View File

@@ -198,7 +198,7 @@
}
.mcp-status-disabled {
color: #b91c1c;
background: #fee2e2;
color: #92400e;
background: #fef3c7;
}
}

View File

@@ -1,27 +1,105 @@
/**
* 文件职责:优惠券编辑抽屉样式。
* 1. 规范表单行内布局与提示文案样式
* 1. 对齐原型中的抽屉头部、表单密度、分段按钮与底部操作区
* 2. 对齐渠道勾选与门店范围选择细节视觉。
*/
.page-marketing-coupon {
.mcp-editor-drawer {
.ant-drawer-header {
min-height: 54px;
padding: 0 18px;
border-bottom: 1px solid #f0f0f0;
}
.ant-drawer-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.ant-drawer-body {
padding: 14px 16px 12px;
}
.ant-drawer-footer {
padding: 10px 16px;
border-top: 1px solid #f0f0f0;
}
.mcp-editor-form {
.ant-form-item {
margin-bottom: 16px;
margin-bottom: 14px;
}
.ant-form-item-label {
padding-bottom: 6px;
}
.ant-form-item-label > label {
font-size: 13px;
font-weight: 500;
color: #374151;
}
}
.ant-input,
.ant-input-number,
.ant-picker,
.ant-select-selector {
border-color: #e5e7eb !important;
border-radius: 6px !important;
}
.ant-input,
.ant-input-number-input,
.ant-picker-input > input {
font-size: 13px;
}
.ant-input {
height: 34px;
padding: 0 10px;
}
.ant-input-number {
height: 34px;
}
.ant-input-number-input-wrap {
height: 100%;
}
.ant-input-number-input {
height: 32px;
}
.ant-picker {
height: 34px;
padding: 0 10px;
}
.ant-input-number:focus-within,
.ant-picker-focused,
.ant-select-focused .ant-select-selector,
.ant-input:focus {
border-color: #1677ff !important;
box-shadow: 0 0 0 2px rgb(22 119 255 / 10%) !important;
}
.mcp-inline-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 6px;
align-items: center;
font-size: 13px;
color: #4b5563;
}
.mcp-inline-fields .ant-input-number {
width: 130px;
width: 96px;
}
.mcp-input-wide {
width: 200px;
width: 180px;
}
.mcp-field-hint {
@@ -30,30 +108,142 @@
color: #9ca3af;
}
.mcp-form-muted {
.mcp-section-divider {
height: 1px;
margin: 18px 0 28px;
background: linear-gradient(
90deg,
rgb(229 231 235 / 0%) 0%,
rgb(229 231 235 / 100%) 18%,
rgb(229 231 235 / 100%) 82%,
rgb(229 231 235 / 0%) 100%
);
}
.mcp-form-muted .ant-form-item-control-input-content {
font-size: 13px;
color: #6b7280;
}
.ant-radio-group {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.ant-radio-button-wrapper {
height: 30px;
padding: 0 12px;
font-size: 12px;
line-height: 28px;
color: #4b5563;
border: 1px solid #d9d9d9;
border-radius: 6px !important;
transition: all 0.2s ease;
}
.ant-radio-button-wrapper::before {
display: none !important;
}
.ant-radio-button-wrapper:hover {
color: #1677ff;
border-color: #91caff;
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
color: #1677ff;
background: #e8f3ff;
border-color: #91caff;
box-shadow: none;
}
.ant-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.ant-checkbox-wrapper {
margin-inline-start: 0 !important;
font-size: 13px;
color: #374151;
}
.ant-checkbox-inner {
width: 16px;
height: 16px;
border-radius: 999px;
}
.ant-checkbox-checked .ant-checkbox-inner::after {
inset-inline-start: 24%;
width: 5px;
height: 8px;
border-width: 0 2px 2px 0;
}
.mcp-channel-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mcp-channel-pill {
height: 30px;
padding: 0 14px;
font-size: 12px;
line-height: 28px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.2s ease;
}
.mcp-channel-pill:hover {
color: #1677ff;
border-color: #91caff;
}
.mcp-channel-pill.checked {
color: #1677ff;
background: #e8f3ff;
border-color: #91caff;
}
.mcp-range-picker {
width: 100%;
max-width: 360px;
max-width: 340px;
}
.ant-radio-wrapper {
margin-inline-start: 0 !important;
margin-right: 18px;
font-size: 13px;
color: #374151;
}
.mcp-store-multi {
width: 100%;
margin-top: 10px;
margin-top: 8px;
}
.mcp-switch-row {
display: inline-flex;
gap: 10px;
align-items: center;
.mcp-store-multi .ant-select-selector {
min-height: 34px !important;
padding: 1px 8px !important;
}
.mcp-drawer-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
justify-content: flex-start;
}
.mcp-drawer-footer .ant-btn {
min-width: 64px;
height: 32px;
border-radius: 6px;
}
}