feat(project): 优化费用与自提模式切换交互

This commit is contained in:
2026-02-20 10:40:24 +08:00
parent ab6b7020b9
commit ddc9df38e0
10 changed files with 417 additions and 246 deletions

View File

@@ -88,6 +88,14 @@ export interface SaveStoreFeesSettingsParams {
storeId: string;
}
/** 保存包装费收取方式参数 */
export interface SaveStoreFeesModeParams {
/** 包装费模式 */
packagingFeeMode: PackagingFeeMode;
/** 门店 ID */
storeId: string;
}
/** 复制费用设置参数 */
export interface CopyStoreFeesSettingsParams {
sourceStoreId: string;
@@ -108,6 +116,11 @@ export async function saveStoreFeesSettingsApi(
return requestClient.post<StoreFeesSettingsDto>('/store/fees/save', data);
}
/** 保存包装费收取方式 */
export async function saveStoreFeesModeApi(data: SaveStoreFeesModeParams) {
return requestClient.post('/store/fees/mode/save', data);
}
/** 复制费用设置到其他门店 */
export async function copyStoreFeesSettingsApi(
data: CopyStoreFeesSettingsParams,

View File

@@ -14,6 +14,7 @@ interface Props {
formatCurrency: (value: number) => string;
formatTierRange: (tier: PackagingFeeTierDto) => string;
isSaving: boolean;
isSwitchingMode: boolean;
onSetFixedPackagingFee: (value: number) => void;
onSetPackagingMode: (value: 'item' | 'order') => void;
onSetTieredEnabled: (value: boolean) => void;
@@ -36,6 +37,20 @@ function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function getModeLabel(mode: 'item' | 'order') {
return mode === 'order' ? '按订单收取' : '按商品收取';
}
function getModeHint(mode: 'item' | 'order') {
return mode === 'order' ? '按订单金额统一计算包装费' : '按商品维度汇总包装费';
}
function getModeEffect(mode: 'item' | 'order') {
return mode === 'order'
? '固定包装费/阶梯包装费生效,商品包装费暂不生效。'
: '商品包装费生效,本页固定包装费/阶梯包装费暂不生效。';
}
</script>
<template>
@@ -44,50 +59,50 @@ function toNumber(value: null | number | string, fallback = 0) {
<span class="section-title">包装费设置</span>
</template>
<div class="packaging-mode-toggle-row">
<div class="packaging-mode-panel">
<div class="packaging-mode-header">
<div class="packaging-mode-title">包装费收取方式</div>
<div class="packaging-mode-badge">
{{
props.isSwitchingMode
? '切换中...'
: `当前:${getModeLabel(props.packagingMode)}`
}}
</div>
</div>
<div class="packaging-mode-grid">
<button
type="button"
class="mode-toggle-label"
class="packaging-mode-card"
:class="{ active: props.packagingMode === 'order' }"
:disabled="!props.canOperate"
:disabled="!props.canOperate || props.isSwitchingMode"
@click="props.onSetPackagingMode('order')"
>
按订单收取
<div class="packaging-mode-card-title">
<span class="packaging-mode-dot"></span>
{{ getModeLabel('order') }}
</div>
<div class="packaging-mode-card-hint">{{ getModeHint('order') }}</div>
</button>
<Switch
:checked="props.packagingMode === 'item'"
:disabled="!props.canOperate"
@update:checked="
(checked) => props.onSetPackagingMode(checked ? 'item' : 'order')
"
/>
<button
type="button"
class="mode-toggle-label"
class="packaging-mode-card"
:class="{ active: props.packagingMode === 'item' }"
:disabled="!props.canOperate"
:disabled="!props.canOperate || props.isSwitchingMode"
@click="props.onSetPackagingMode('item')"
>
按商品收取
<div class="packaging-mode-card-title">
<span class="packaging-mode-dot"></span>
{{ getModeLabel('item') }}
</div>
<div class="packaging-mode-card-hint">{{ getModeHint('item') }}</div>
</button>
</div>
<div
class="packaging-mode-guide"
:class="
props.packagingMode === 'item'
? 'packaging-mode-guide-item'
: 'packaging-mode-guide-order'
"
>
<div class="guide-title">当前生效规则</div>
<div v-if="props.packagingMode === 'order'" class="guide-desc">
订单将按固定/阶梯包装费计算商品包装费配置暂不生效
</div>
<div v-else class="guide-desc">
订单包装费将汇总商品维度配置本页固定/阶梯包装费暂不生效
<div class="packaging-mode-effect">
切换后效果{{ getModeEffect(props.packagingMode) }}
</div>
</div>

View File

@@ -17,6 +17,8 @@ import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { saveStoreFeesModeApi } from '#/api/store-fees';
import { PACKAGING_MODE_OPTIONS } from './fees-page/constants';
import { createCopyActions } from './fees-page/copy-actions';
import { createDataActions } from './fees-page/data-actions';
@@ -24,6 +26,7 @@ import {
cloneFeesForm,
cloneOtherFees,
cloneTiers,
createSettingsSnapshot,
formatCurrency,
formatTierRange,
normalizeMoney,
@@ -49,8 +52,6 @@ const EMPTY_FEES_SETTINGS: StoreFeesFormState = {
},
},
};
const PACKAGING_MODE_SWITCH_CONFIRM_KEY =
'store-fees-packaging-mode-switch-confirmed';
export function useStoreFeesPage() {
// 1. 页面 loading / submitting 状态。
@@ -59,6 +60,7 @@ export function useStoreFeesPage() {
const isSavingDelivery = ref(false);
const isSavingPackaging = ref(false);
const isSavingOther = ref(false);
const isSwitchingPackagingMode = ref(false);
const isCopySubmitting = ref(false);
const isConfigured = ref(false);
@@ -104,7 +106,8 @@ export function useStoreFeesPage() {
!isPageLoading.value &&
!isSavingDelivery.value &&
!isSavingPackaging.value &&
!isSavingOther.value,
!isSavingOther.value &&
!isSwitchingPackagingMode.value,
);
const copyCandidates = computed(() =>
@@ -129,29 +132,11 @@ export function useStoreFeesPage() {
const isOrderMode = computed(() => form.packagingFeeMode === 'order');
function hasConfirmedPackagingModeSwitch() {
try {
return (
window.localStorage.getItem(PACKAGING_MODE_SWITCH_CONFIRM_KEY) === '1'
);
} catch {
return false;
}
}
function markPackagingModeSwitchConfirmed() {
try {
window.localStorage.setItem(PACKAGING_MODE_SWITCH_CONFIRM_KEY, '1');
} catch {
// 忽略本地存储异常,不影响当前切换。
}
}
function getPackagingModeConfirmContent(value: PackagingFeeMode) {
if (value === 'order') {
return '切换后订单将按本页固定/阶梯包装费计算,商品包装费配置将暂不生效。';
return '切换后将按订单收取包装费,固定/阶梯包装费立即生效;商品维度包装费将暂不生效。';
}
return '切换后订单包装费将汇总商品维度配置,本页固定/阶梯包装费将暂不生效。';
return '切换后将按商品收取包装费,商品配置的包装费立即生效;本页固定/阶梯包装费将暂不生效。';
}
function clearSettings() {
@@ -237,21 +222,52 @@ export function useStoreFeesPage() {
function setPackagingMode(value: PackagingFeeMode) {
if (!canOperate.value) return;
if (value === form.packagingFeeMode) return;
if (!selectedStoreId.value) return;
const applyMode = () => setPackagingFeeMode(value);
if (hasConfirmedPackagingModeSwitch()) {
applyMode();
return;
}
const currentStoreId = selectedStoreId.value;
const previousMode = form.packagingFeeMode;
const previousOrderMode = form.orderPackagingFeeMode;
Modal.confirm({
title: '确认切换包装费收取方式?',
content: getPackagingModeConfirmContent(value),
okText: '确认切换',
cancelText: '取消',
onOk() {
applyMode();
markPackagingModeSwitchConfirmed();
async onOk() {
if (isSwitchingPackagingMode.value) return;
isSwitchingPackagingMode.value = true;
setPackagingFeeMode(value);
try {
await saveStoreFeesModeApi({
storeId: currentStoreId,
packagingFeeMode: value,
});
if (selectedStoreId.value === currentStoreId) {
isConfigured.value = true;
loadedStoreId.value = currentStoreId;
if (snapshot.value) {
snapshot.value = {
...snapshot.value,
packagingFeeMode: form.packagingFeeMode,
orderPackagingFeeMode: form.orderPackagingFeeMode,
};
} else {
snapshot.value = createSettingsSnapshot(form);
}
message.success('包装费收取方式已切换');
}
} catch (error) {
console.error(error);
if (selectedStoreId.value === currentStoreId) {
form.packagingFeeMode = previousMode;
form.orderPackagingFeeMode = previousOrderMode;
}
message.error('包装费收取方式切换失败,请稍后重试');
} finally {
isSwitchingPackagingMode.value = false;
}
},
});
}
@@ -369,6 +385,7 @@ export function useStoreFeesPage() {
/** 切换门店时同步拉取配置。 */
watch(selectedStoreId, async (storeId) => {
isSwitchingPackagingMode.value = false;
if (!storeId) {
clearSettings();
isConfigured.value = false;
@@ -407,6 +424,7 @@ export function useStoreFeesPage() {
isSavingDelivery,
isSavingOther,
isSavingPackaging,
isSwitchingPackagingMode,
isStoreLoading,
isTierDrawerOpen,
loadedStoreId,

View File

@@ -38,6 +38,7 @@ const {
isSavingDelivery,
isSavingOther,
isSavingPackaging,
isSwitchingPackagingMode,
isStoreLoading,
isTierDrawerOpen,
onDeleteTier,
@@ -126,6 +127,7 @@ function onEditTier(tier: PackagingFeeTierDto) {
:fixed-packaging-fee="form.fixedPackagingFee"
:tiers="form.packagingFeeTiers"
:is-saving="isSavingPackaging"
:is-switching-mode="isSwitchingPackagingMode"
:format-currency="formatCurrency"
:format-tier-range="formatTierRange"
:on-set-packaging-mode="setPackagingMode"

View File

@@ -1,58 +1,110 @@
/* 文件职责:包装费卡片样式。 */
.page-store-fees {
.packaging-mode-toggle-row {
display: inline-flex;
.packaging-mode-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
margin-bottom: 16px;
background: #fff;
border: 1px solid #e5e9f0;
border-radius: 12px;
}
.packaging-mode-header {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
justify-content: space-between;
}
.mode-toggle-label {
padding: 0;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: transparent;
border: none;
transition: color 0.2s ease;
}
.mode-toggle-label.active {
font-weight: 600;
color: #1677ff;
}
.mode-toggle-label:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.packaging-mode-guide {
padding: 10px 12px;
margin-bottom: 16px;
border: 1px solid #dbeafe;
border-radius: 8px;
}
.packaging-mode-guide-order {
background: #f5f9ff;
}
.packaging-mode-guide-item {
background: #f6ffed;
border-color: #ccebd2;
}
.packaging-mode-guide .guide-title {
margin-bottom: 4px;
font-size: 12px;
.packaging-mode-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.packaging-mode-guide .guide-desc {
.packaging-mode-badge {
padding: 3px 10px;
font-size: 12px;
color: #4b5563;
line-height: 1.5;
color: #155eef;
background: #eff4ff;
border: 1px solid #c7d7fe;
border-radius: 999px;
}
.packaging-mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.packaging-mode-card {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 92px;
padding: 12px;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid #d8dde6;
border-radius: 10px;
transition: all 0.18s ease;
}
.packaging-mode-card:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.packaging-mode-card:hover:not(:disabled) {
border-color: #86a6ff;
box-shadow: 0 4px 12px rgb(17 24 39 / 8%);
}
.packaging-mode-card.active {
background: linear-gradient(160deg, #ecf3ff 0%, #f8fbff 100%);
border-color: #5b8bff;
box-shadow: 0 6px 18px rgb(32 94 255 / 15%);
}
.packaging-mode-card-title {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #111827;
}
.packaging-mode-dot {
width: 8px;
height: 8px;
background: #94a3b8;
border-radius: 50%;
}
.packaging-mode-card.active .packaging-mode-dot {
background: #2563eb;
box-shadow: 0 0 0 4px rgb(37 99 235 / 16%);
}
.packaging-mode-card-hint {
font-size: 12px;
line-height: 1.55;
color: #556070;
}
.packaging-mode-effect {
padding: 8px 10px;
font-size: 12px;
line-height: 1.5;
color: #1f3a8a;
background: #f5f8ff;
border: 1px solid #dbe7ff;
border-radius: 8px;
}
.packaging-tier-block {

View File

@@ -23,14 +23,25 @@
width: 100%;
}
.packaging-mode-toggle-row {
display: flex;
justify-content: space-between;
.packaging-mode-panel {
gap: 10px;
padding: 12px;
}
.packaging-mode-header {
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.packaging-mode-grid {
grid-template-columns: 1fr;
width: 100%;
}
.mode-toggle-label {
text-align: center;
.packaging-mode-card {
min-height: 84px;
padding: 10px 11px;
}
}
}

View File

@@ -6,8 +6,6 @@
*/
import type { PickupMode } from '#/api/store-pickup';
import { Switch } from 'ant-design-vue';
interface Props {
disabled?: boolean;
isSwitching?: boolean;
@@ -24,54 +22,53 @@ const emit = defineEmits<{
function getModeLabel(mode: PickupMode) {
return props.options.find((item) => item.value === mode)?.label ?? mode;
}
function getModeHint(mode: PickupMode) {
return mode === 'big'
? '按时段统一预约,适合固定营业节奏'
: '按精细时间点预约,适合高峰分流';
}
</script>
<template>
<div class="pickup-mode-switch-wrap">
<div class="pickup-mode-switch">
<div class="pickup-mode-panel" :class="{ switching: props.isSwitching }">
<div class="pickup-mode-header">
<div class="pickup-mode-title">自提预约模式</div>
<div class="pickup-mode-badge">
{{
props.isSwitching ? '切换中...' : `当前:${getModeLabel(props.mode)}`
}}
</div>
</div>
<div class="pickup-mode-grid">
<button
type="button"
class="pickup-mode-item"
class="pickup-mode-card"
:class="{ active: props.mode === 'big' }"
:disabled="props.disabled || props.isSwitching"
@click="emit('change', 'big')"
>
<div class="pickup-mode-card-title">
<span class="pickup-mode-dot"></span>
{{ getModeLabel('big') }}
</div>
<div class="pickup-mode-card-hint">{{ getModeHint('big') }}</div>
</button>
<Switch
:checked="props.mode === 'fine'"
:loading="props.isSwitching"
:disabled="props.disabled || props.isSwitching"
@update:checked="(checked) => emit('change', checked ? 'fine' : 'big')"
/>
<button
type="button"
class="pickup-mode-item"
class="pickup-mode-card"
:class="{ active: props.mode === 'fine' }"
:disabled="props.disabled || props.isSwitching"
@click="emit('change', 'fine')"
>
<div class="pickup-mode-card-title">
<span class="pickup-mode-dot"></span>
{{ getModeLabel('fine') }}
</div>
<div class="pickup-mode-card-hint">{{ getModeHint('fine') }}</div>
</button>
</div>
<div
class="pickup-mode-guide"
:class="
props.mode === 'fine'
? 'pickup-mode-guide-fine'
: 'pickup-mode-guide-big'
"
>
<div class="guide-title">当前生效规则</div>
<div v-if="props.mode === 'big'" class="guide-desc">
顾客按大时段进行预约系统按已配置时段控制可约时间与容量
</div>
<div v-else class="guide-desc">
顾客按精细时间窗口预约系统按精细规则自动生成可约时间点
</div>
</div>
</div>
</template>

View File

@@ -18,7 +18,7 @@ import type {
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { message, Modal } from 'ant-design-vue';
import { savePickupModeApi } from '#/api/store-pickup';
@@ -243,15 +243,30 @@ export function useStorePickupPage() {
selectedStoreId.value = value;
}
async function setPickupMode(value: 'big' | 'fine') {
function getPickupModeConfirmContent(value: 'big' | 'fine') {
if (value === 'big') {
return '切换后将按大时段统一预约,精细时段规则与预览将暂不生效。';
}
return '切换后将按精细时段预约,大时段配置将暂不生效。';
}
function setPickupMode(value: 'big' | 'fine') {
if (!canOperate.value) return;
if (value === pickupMode.value) return;
if (!selectedStoreId.value) return;
const currentStoreId = selectedStoreId.value;
const previousMode = pickupMode.value;
pickupMode.value = value;
Modal.confirm({
title: '确认切换自提预约模式?',
content: getPickupModeConfirmContent(value),
okText: '确认切换',
cancelText: '取消',
async onOk() {
if (isModeSwitching.value) return;
isModeSwitching.value = true;
pickupMode.value = value;
try {
await savePickupModeApi({
@@ -262,6 +277,12 @@ export function useStorePickupPage() {
if (selectedStoreId.value === currentStoreId) {
isConfigured.value = true;
loadedStoreId.value = currentStoreId;
if (snapshot.value) {
snapshot.value = {
...snapshot.value,
mode: value,
};
} else {
snapshot.value = createSettingsSnapshot({
mode: value,
basicSettings,
@@ -269,17 +290,20 @@ export function useStorePickupPage() {
fineRule,
previewDays: previewDays.value,
});
message.success('自提模式已切换');
}
message.success('自提预约模式已切换');
}
} catch (error) {
console.error(error);
if (selectedStoreId.value === currentStoreId) {
pickupMode.value = previousMode;
message.error('自提模式切换失败,请稍后重试');
}
message.error('自提预约模式切换失败,请稍后重试');
} finally {
isModeSwitching.value = false;
}
},
});
}
function setAllowSameDayPickup(value: boolean) {

View File

@@ -1,71 +1,105 @@
/* 文件职责:自提设置页面模式切换样式。 */
.page-store-pickup {
.pickup-mode-switch-wrap {
.pickup-mode-panel {
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
padding: 14px;
margin-bottom: 16px;
background: #fff;
border: 1px solid #e5e9f0;
border-radius: 12px;
}
.pickup-mode-switch {
display: inline-flex;
.pickup-mode-header {
display: flex;
gap: 10px;
align-items: center;
padding: 3px;
background: #f8f9fb;
border-radius: 8px;
justify-content: space-between;
}
.pickup-mode-item {
min-width: 118px;
padding: 6px 18px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px;
transition: all 0.2s ease;
}
.pickup-mode-item:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.pickup-mode-item.active {
font-weight: 600;
color: #1677ff;
background: #fff;
box-shadow: 0 1px 2px rgb(15 23 42 / 10%);
}
.pickup-mode-guide {
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 8px;
}
.pickup-mode-guide-big {
background: #f5f9ff;
border-color: #bfdbfe;
}
.pickup-mode-guide-fine {
background: #effff7;
border-color: #bbf7d0;
}
.pickup-mode-guide .guide-title {
margin-bottom: 2px;
font-size: 13px;
.pickup-mode-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.pickup-mode-guide .guide-desc {
.pickup-mode-badge {
padding: 3px 10px;
font-size: 12px;
line-height: 1.5;
color: #155eef;
background: #eff4ff;
border: 1px solid #c7d7fe;
border-radius: 999px;
}
.pickup-mode-panel.switching .pickup-mode-badge {
color: #9a3412;
background: #fff7ed;
border-color: #fdba74;
}
.pickup-mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.pickup-mode-card {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 92px;
padding: 12px;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid #d8dde6;
border-radius: 10px;
transition: all 0.18s ease;
}
.pickup-mode-card:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.pickup-mode-card:hover:not(:disabled) {
border-color: #86a6ff;
box-shadow: 0 4px 12px rgb(17 24 39 / 8%);
}
.pickup-mode-card.active {
background: linear-gradient(160deg, #ecf3ff 0%, #f8fbff 100%);
border-color: #5b8bff;
box-shadow: 0 6px 18px rgb(32 94 255 / 15%);
}
.pickup-mode-card-title {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #111827;
}
.pickup-mode-dot {
width: 8px;
height: 8px;
background: #94a3b8;
border-radius: 50%;
}
.pickup-mode-card.active .pickup-mode-dot {
background: #2563eb;
box-shadow: 0 0 0 4px rgb(37 99 235 / 16%);
}
.pickup-mode-card-hint {
font-size: 12px;
line-height: 1.55;
color: #4b5563;
color: #556070;
}
}

View File

@@ -7,20 +7,25 @@
}
@media (max-width: 768px) {
.pickup-mode-switch-wrap {
gap: 8px;
.pickup-mode-panel {
gap: 10px;
padding: 12px;
}
.pickup-mode-switch {
display: flex;
justify-content: space-between;
.pickup-mode-header {
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.pickup-mode-grid {
grid-template-columns: 1fr;
width: 100%;
}
.pickup-mode-item {
flex: 1;
min-width: 0;
text-align: center;
.pickup-mode-card {
min-height: 84px;
padding: 10px 11px;
}
.pickup-form-row {