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

View File

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

View File

@@ -20,31 +20,80 @@ interface CreateCardActionsOptions {
} }
export function createCardActions(options: CreateCardActionsOptions) { export function createCardActions(options: CreateCardActionsOptions) {
async function enableCoupon(item: CouponCardViewModel) { function enableCoupon(item: CouponCardViewModel) {
await updateStatus(item, 'enabled', '优惠券已启用'); openStatusConfirm(item, {
actionText: '开放领取',
confirmHint: '开放后,用户可按规则继续领取该优惠券。',
status: 'enabled',
successMessage: '优惠券已开放领取',
});
} }
async function disableCoupon(item: CouponCardViewModel) { function disableCoupon(item: CouponCardViewModel) {
await updateStatus(item, 'disabled', '优惠券已停用'); 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( async function updateStatus(
item: CouponCardViewModel, item: CouponCardViewModel,
status: 'disabled' | 'enabled', status: 'disabled' | 'enabled',
actionText: '开放领取' | '暂停领取',
successMessage: string, successMessage: string,
) { ) {
if (!options.selectedStoreId.value) return; if (!options.selectedStoreId.value) return;
const feedbackKey = `coupon-status-${item.id}`;
try { try {
message.loading({
content: `正在${actionText}优惠券...`,
duration: 0,
key: feedbackKey,
});
await changeMarketingCouponStatusApi({ await changeMarketingCouponStatusApi({
storeId: options.selectedStoreId.value, storeId: options.selectedStoreId.value,
couponId: item.id, couponId: item.id,
status, status,
}); });
message.success(successMessage); message.success({ content: successMessage, key: feedbackKey });
await options.loadCoupons(); await options.loadCoupons();
} catch (error) { } catch (error) {
console.error(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: 'ongoing' },
{ label: '未开始', value: 'upcoming' }, { label: '未开始', value: 'upcoming' },
{ label: '已结束', value: 'ended' }, { label: '已结束', value: 'ended' },
{ label: '已停用', value: 'disabled' }, { label: '暂停领取', value: 'disabled' },
]; ];
/** 列表类型筛选项。 */ /** 列表类型筛选项。 */
@@ -79,7 +79,7 @@ export const COUPON_STATUS_TEXT_MAP: Record<
ongoing: '进行中', ongoing: '进行中',
upcoming: '未开始', upcoming: '未开始',
ended: '已结束', ended: '已结束',
disabled: '已停用', disabled: '暂停领取',
}; };
/** 列表状态徽标样式。 */ /** 列表状态徽标样式。 */
@@ -124,6 +124,6 @@ export function createDefaultCouponEditorForm(): CouponEditorForm {
channels: ['delivery', 'pickup', 'dine_in'], channels: ['delivery', 'pickup', 'dine_in'],
storeScopeMode: 'all', storeScopeMode: 'all',
storeIds: [], storeIds: [],
status: 'enabled', status: 'disabled',
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -198,7 +198,7 @@
} }
.mcp-status-disabled { .mcp-status-disabled {
color: #b91c1c; color: #92400e;
background: #fee2e2; 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 { .mcp-editor-form {
.ant-form-item { .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 { .mcp-inline-fields {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 6px;
align-items: center; align-items: center;
font-size: 13px;
color: #4b5563;
} }
.mcp-inline-fields .ant-input-number { .mcp-inline-fields .ant-input-number {
width: 130px; width: 96px;
} }
.mcp-input-wide { .mcp-input-wide {
width: 200px; width: 180px;
} }
.mcp-field-hint { .mcp-field-hint {
@@ -30,30 +108,142 @@
color: #9ca3af; 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; font-size: 13px;
color: #6b7280; 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 { .mcp-range-picker {
width: 100%; 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 { .mcp-store-multi {
width: 100%; width: 100%;
margin-top: 10px; margin-top: 8px;
} }
.mcp-switch-row { .mcp-store-multi .ant-select-selector {
display: inline-flex; min-height: 34px !important;
gap: 10px; padding: 1px 8px !important;
align-items: center;
} }
.mcp-drawer-footer { .mcp-drawer-footer {
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: flex-end; justify-content: flex-start;
}
.mcp-drawer-footer .ant-btn {
min-width: 64px;
height: 32px;
border-radius: 6px;
} }
} }