feat(project): 对齐优惠券抽屉细节与领取状态交互
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 48s
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 48s
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
}
|
||||
|
||||
.mcp-status-disabled {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user