refactor(project): remove store mock fallback flows and unify real-data states

This commit is contained in:
2026-02-20 09:56:35 +08:00
parent 11cd789f38
commit 22d1a44683
39 changed files with 887 additions and 610 deletions

View File

@@ -45,8 +45,9 @@ export interface DeliveryGeneralSettingsDto {
/** 门店配送设置聚合 */ /** 门店配送设置聚合 */
export interface StoreDeliverySettingsDto { export interface StoreDeliverySettingsDto {
generalSettings: DeliveryGeneralSettingsDto; generalSettings: DeliveryGeneralSettingsDto | null;
mode: DeliveryMode; isConfigured: boolean;
mode: DeliveryMode | null;
/** 半径配送中心点纬度 */ /** 半径配送中心点纬度 */
radiusCenterLatitude: null | number; radiusCenterLatitude: null | number;
/** 半径配送中心点经度 */ /** 半径配送中心点经度 */
@@ -57,7 +58,17 @@ export interface StoreDeliverySettingsDto {
} }
/** 保存配送设置参数 */ /** 保存配送设置参数 */
export type SaveStoreDeliverySettingsParams = StoreDeliverySettingsDto; export interface SaveStoreDeliverySettingsParams {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
/** 半径配送中心点纬度 */
radiusCenterLatitude: null | number;
/** 半径配送中心点经度 */
radiusCenterLongitude: null | number;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
storeId: string;
}
/** 复制配送设置参数 */ /** 复制配送设置参数 */
export interface CopyStoreDeliverySettingsParams { export interface CopyStoreDeliverySettingsParams {

View File

@@ -44,7 +44,8 @@ export interface DineInTableDto {
/** 门店堂食设置聚合 */ /** 门店堂食设置聚合 */
export interface StoreDineInSettingsDto { export interface StoreDineInSettingsDto {
areas: DineInAreaDto[]; areas: DineInAreaDto[];
basicSettings: DineInBasicSettingsDto; basicSettings: DineInBasicSettingsDto | null;
isConfigured: boolean;
storeId: string; storeId: string;
tables: DineInTableDto[]; tables: DineInTableDto[];
} }

View File

@@ -60,12 +60,33 @@ export interface StoreFeesSettingsDto {
packagingFeeMode: PackagingFeeMode; packagingFeeMode: PackagingFeeMode;
/** 包装费阶梯 */ /** 包装费阶梯 */
packagingFeeTiers: PackagingFeeTierDto[]; packagingFeeTiers: PackagingFeeTierDto[];
/** 是否已配置 */
isConfigured: boolean;
/** 门店 ID */ /** 门店 ID */
storeId: string; storeId: string;
} }
/** 保存费用设置参数 */ /** 保存费用设置参数 */
export type SaveStoreFeesSettingsParams = StoreFeesSettingsDto; export interface SaveStoreFeesSettingsParams {
/** 基础配送费 */
baseDeliveryFee: number;
/** 固定包装费 */
fixedPackagingFee: number;
/** 免配送费门槛,空值表示关闭 */
freeDeliveryThreshold: null | number;
/** 起送金额 */
minimumOrderAmount: number;
/** 其他费用 */
otherFees: StoreOtherFeesDto;
/** 按订单包装费模式 */
orderPackagingFeeMode: OrderPackagingFeeMode;
/** 包装费模式 */
packagingFeeMode: PackagingFeeMode;
/** 包装费阶梯 */
packagingFeeTiers: PackagingFeeTierDto[];
/** 门店 ID */
storeId: string;
}
/** 复制费用设置参数 */ /** 复制费用设置参数 */
export interface CopyStoreFeesSettingsParams { export interface CopyStoreFeesSettingsParams {

View File

@@ -71,10 +71,11 @@ export interface PickupPreviewDayDto {
/** 门店自提设置聚合 */ /** 门店自提设置聚合 */
export interface StorePickupSettingsDto { export interface StorePickupSettingsDto {
basicSettings: PickupBasicSettingsDto; basicSettings: null | PickupBasicSettingsDto;
bigSlots: PickupSlotDto[]; bigSlots: PickupSlotDto[];
fineRule: PickupFineRuleDto; fineRule: null | PickupFineRuleDto;
mode: PickupMode; isConfigured: boolean;
mode: null | PickupMode;
previewDays: PickupPreviewDayDto[]; previewDays: PickupPreviewDayDto[];
storeId: string; storeId: string;
} }

View File

@@ -43,7 +43,7 @@ const emit = defineEmits<{
(event: 'update:open', value: boolean): void; (event: 'update:open', value: boolean): void;
}>(); }>();
const DEFAULT_CENTER: LngLatTuple = [116.397_428, 39.909_23]; const DEFAULT_CENTER: LngLatTuple = [104.195_397, 35.861_66];
const MAP_MODAL_Z_INDEX = 10_000; const MAP_MODAL_Z_INDEX = 10_000;
const MAP_DEBUG_PREFIX = '[TenantUI-DeliveryMap]'; const MAP_DEBUG_PREFIX = '[TenantUI-DeliveryMap]';

View File

@@ -28,106 +28,13 @@ export const TIER_COLOR_PALETTE = [
export const DEFAULT_DELIVERY_MODE: DeliveryMode = 'radius'; export const DEFAULT_DELIVERY_MODE: DeliveryMode = 'radius';
export const DEFAULT_RADIUS_TIERS: RadiusTierDto[] = [ export const DEFAULT_RADIUS_TIERS: RadiusTierDto[] = [];
{
id: 'tier-1',
minDistance: 0,
maxDistance: 1,
deliveryFee: 3,
etaMinutes: 20,
minOrderAmount: 15,
color: '#52c41a',
},
{
id: 'tier-2',
minDistance: 1,
maxDistance: 3,
deliveryFee: 5,
etaMinutes: 35,
minOrderAmount: 20,
color: '#faad14',
},
{
id: 'tier-3',
minDistance: 3,
maxDistance: 5,
deliveryFee: 8,
etaMinutes: 50,
minOrderAmount: 25,
color: '#ff4d4f',
},
];
function createPolygonGeoJson(coordinates: Array<[number, number]>) { export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [];
return JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [coordinates],
},
properties: {},
},
],
});
}
export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
{
id: 'zone-core',
name: '核心区域',
color: '#52c41a',
deliveryFee: 3,
minOrderAmount: 15,
etaMinutes: 20,
priority: 1,
polygonGeoJson: createPolygonGeoJson([
[116.389, 39.907],
[116.397, 39.907],
[116.397, 39.913],
[116.389, 39.913],
[116.389, 39.907],
]),
},
{
id: 'zone-cbd',
name: '朝阳CBD',
color: '#1677ff',
deliveryFee: 5,
minOrderAmount: 20,
etaMinutes: 35,
priority: 2,
polygonGeoJson: createPolygonGeoJson([
[116.456, 39.914],
[116.468, 39.914],
[116.468, 39.923],
[116.456, 39.923],
[116.456, 39.914],
]),
},
{
id: 'zone-slt',
name: '三里屯片区',
color: '#faad14',
deliveryFee: 6,
minOrderAmount: 25,
etaMinutes: 40,
priority: 3,
polygonGeoJson: createPolygonGeoJson([
[116.445, 39.928],
[116.455, 39.928],
[116.455, 39.936],
[116.445, 39.936],
[116.445, 39.928],
]),
},
];
export const DEFAULT_GENERAL_SETTINGS: DeliveryGeneralSettingsDto = { export const DEFAULT_GENERAL_SETTINGS: DeliveryGeneralSettingsDto = {
freeDeliveryThreshold: 30, freeDeliveryThreshold: null,
maxDeliveryDistance: 5, maxDeliveryDistance: 0,
hourlyCapacityLimit: 50, hourlyCapacityLimit: 1,
etaAdjustmentMinutes: 10, etaAdjustmentMinutes: 0,
}; };

View File

@@ -38,11 +38,14 @@ import {
} from './helpers'; } from './helpers';
interface CreateDataActionsOptions { interface CreateDataActionsOptions {
clearSettings: () => void;
editingMode: Ref<DeliveryMode>; editingMode: Ref<DeliveryMode>;
generalSettings: DeliveryGeneralSettingsDto; generalSettings: DeliveryGeneralSettingsDto;
isConfigured: Ref<boolean>;
isSaving: Ref<boolean>; isSaving: Ref<boolean>;
isSettingsLoading: Ref<boolean>; isSettingsLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>; isStoreLoading: Ref<boolean>;
loadedStoreId: Ref<string>;
mode: Ref<DeliveryMode>; mode: Ref<DeliveryMode>;
radiusCenterLatitude: Ref<null | number>; radiusCenterLatitude: Ref<null | number>;
radiusCenterLongitude: Ref<null | number>; radiusCenterLongitude: Ref<null | number>;
@@ -85,17 +88,6 @@ export function createDataActions(options: CreateDataActionsOptions) {
}); });
} }
/** 回填默认配置,作为接口异常时的兜底展示。 */
function applyDefaultSettings() {
options.mode.value = DEFAULT_DELIVERY_MODE;
options.editingMode.value = DEFAULT_DELIVERY_MODE;
options.radiusCenterLatitude.value = null;
options.radiusCenterLongitude.value = null;
options.radiusTiers.value = sortRadiusTiers(DEFAULT_RADIUS_TIERS);
options.polygonZones.value = sortPolygonZones(DEFAULT_POLYGON_ZONES);
syncGeneralSettings(cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS));
}
/** 加载指定门店的配送设置。 */ /** 加载指定门店的配送设置。 */
async function loadStoreSettings(storeId: string) { async function loadStoreSettings(storeId: string) {
options.isSettingsLoading.value = true; options.isSettingsLoading.value = true;
@@ -104,29 +96,41 @@ export function createDataActions(options: CreateDataActionsOptions) {
const result = await getStoreDeliverySettingsApi(storeId); const result = await getStoreDeliverySettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return; if (options.selectedStoreId.value !== currentStoreId) return;
if (!result.isConfigured) {
options.clearSettings();
options.isConfigured.value = false;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot();
return;
}
options.mode.value = result.mode ?? DEFAULT_DELIVERY_MODE; options.mode.value = result.mode ?? DEFAULT_DELIVERY_MODE;
options.editingMode.value = options.mode.value; options.editingMode.value = options.mode.value;
options.radiusCenterLatitude.value = result.radiusCenterLatitude ?? null; options.radiusCenterLatitude.value = result.radiusCenterLatitude ?? null;
options.radiusCenterLongitude.value = options.radiusCenterLongitude.value =
result.radiusCenterLongitude ?? null; result.radiusCenterLongitude ?? null;
options.radiusTiers.value = sortRadiusTiers( options.radiusTiers.value = sortRadiusTiers(
result.radiusTiers?.length ? result.radiusTiers : DEFAULT_RADIUS_TIERS, result.radiusTiers ?? DEFAULT_RADIUS_TIERS,
); );
options.polygonZones.value = sortPolygonZones( options.polygonZones.value = sortPolygonZones(
result.polygonZones?.length result.polygonZones ?? clonePolygonZones(DEFAULT_POLYGON_ZONES),
? result.polygonZones
: clonePolygonZones(DEFAULT_POLYGON_ZONES),
); );
syncGeneralSettings({ syncGeneralSettings(
...DEFAULT_GENERAL_SETTINGS, result.generalSettings
...result.generalSettings, ? cloneGeneralSettings(result.generalSettings)
}); : cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
options.isConfigured.value = true;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot(); options.snapshot.value = buildCurrentSnapshot();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
applyDefaultSettings(); options.clearSettings();
options.snapshot.value = buildCurrentSnapshot(); options.isConfigured.value = false;
options.loadedStoreId.value = '';
options.snapshot.value = null;
message.error('加载配送设置失败,请稍后重试');
} finally { } finally {
options.isSettingsLoading.value = false; options.isSettingsLoading.value = false;
} }
@@ -148,8 +152,10 @@ export function createDataActions(options: CreateDataActionsOptions) {
if (options.stores.value.length === 0) { if (options.stores.value.length === 0) {
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
options.loadedStoreId.value = '';
options.isConfigured.value = false;
options.snapshot.value = null; options.snapshot.value = null;
applyDefaultSettings(); options.clearSettings();
return; return;
} }
@@ -172,8 +178,11 @@ export function createDataActions(options: CreateDataActionsOptions) {
console.error(error); console.error(error);
options.stores.value = []; options.stores.value = [];
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
options.loadedStoreId.value = '';
options.isConfigured.value = false;
options.snapshot.value = null; options.snapshot.value = null;
applyDefaultSettings(); options.clearSettings();
message.error('加载门店失败,请稍后重试');
} finally { } finally {
options.isStoreLoading.value = false; options.isStoreLoading.value = false;
} }
@@ -209,7 +218,7 @@ export function createDataActions(options: CreateDataActionsOptions) {
/** 重置到最近一次加载/保存后的快照。 */ /** 重置到最近一次加载/保存后的快照。 */
function resetFromSnapshot() { function resetFromSnapshot() {
if (!options.snapshot.value) { if (!options.snapshot.value) {
applyDefaultSettings(); options.clearSettings();
return; return;
} }
applySnapshot(options.snapshot.value); applySnapshot(options.snapshot.value);

View File

@@ -95,7 +95,7 @@ export function createTierActions(options: CreateTierActionsOptions) {
// 1. 校验区间与字段合法性。 // 1. 校验区间与字段合法性。
if (options.tierForm.maxDistance <= options.tierForm.minDistance) { if (options.tierForm.maxDistance <= options.tierForm.minDistance) {
message.error('结束距离必须大于起始距离'); message.error('结束距离必须大于起始距离');
return; return false;
} }
if ( if (
@@ -103,7 +103,7 @@ export function createTierActions(options: CreateTierActionsOptions) {
options.tierForm.minOrderAmount < 0 options.tierForm.minOrderAmount < 0
) { ) {
message.error('金额字段不能小于 0'); message.error('金额字段不能小于 0');
return; return false;
} }
// 2. 校验与现有梯度区间冲突。 // 2. 校验与现有梯度区间冲突。
@@ -116,7 +116,7 @@ export function createTierActions(options: CreateTierActionsOptions) {
}); });
if (hasOverlap) { if (hasOverlap) {
message.error('距离区间与已有梯度重叠,请调整后重试'); message.error('距离区间与已有梯度重叠,请调整后重试');
return; return false;
} }
// 3. 组装记录并写回列表。 // 3. 组装记录并写回列表。
@@ -143,18 +143,20 @@ export function createTierActions(options: CreateTierActionsOptions) {
message.success( message.success(
options.tierDrawerMode.value === 'edit' ? '梯度已更新' : '梯度已添加', options.tierDrawerMode.value === 'edit' ? '梯度已更新' : '梯度已添加',
); );
return true;
} }
/** 删除指定梯度。 */ /** 删除指定梯度。 */
function handleDeleteTier(tierId: string) { function handleDeleteTier(tierId: string) {
if (options.radiusTiers.value.length <= 1) { if (options.radiusTiers.value.length <= 1) {
message.warning('至少保留一个梯度'); message.warning('至少保留一个梯度');
return; return false;
} }
options.radiusTiers.value = options.radiusTiers.value.filter( options.radiusTiers.value = options.radiusTiers.value.filter(
(item) => item.id !== tierId, (item) => item.id !== tierId,
); );
message.success('梯度已删除'); message.success('梯度已删除');
return true;
} }
return { return {

View File

@@ -101,12 +101,12 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
const normalizedName = options.zoneForm.name.trim(); const normalizedName = options.zoneForm.name.trim();
if (!normalizedName) { if (!normalizedName) {
message.error('请输入区域名称'); message.error('请输入区域名称');
return; return false;
} }
if (countPolygonsInGeoJson(options.zoneForm.polygonGeoJson) <= 0) { if (countPolygonsInGeoJson(options.zoneForm.polygonGeoJson) <= 0) {
message.error('请先绘制配送区域'); message.error('请先绘制配送区域');
return; return false;
} }
// 2. 优先级冲突校验。 // 2. 优先级冲突校验。
@@ -116,7 +116,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
}); });
if (hasPriorityConflict) { if (hasPriorityConflict) {
message.error('优先级已存在,请调整后重试'); message.error('优先级已存在,请调整后重试');
return; return false;
} }
// 3. 写回列表。 // 3. 写回列表。
@@ -144,6 +144,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
message.success( message.success(
options.zoneDrawerMode.value === 'edit' ? '区域已更新' : '区域已添加', options.zoneDrawerMode.value === 'edit' ? '区域已更新' : '区域已添加',
); );
return true;
} }
/** 删除指定区域。 */ /** 删除指定区域。 */
@@ -152,6 +153,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
(item) => item.id !== zoneId, (item) => item.id !== zoneId,
); );
message.success('区域已删除'); message.success('区域已删除');
return true;
} }
return { return {

View File

@@ -67,6 +67,8 @@ export function useStoreDeliveryPage() {
const isSettingsLoading = ref(false); const isSettingsLoading = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const isCopySubmitting = ref(false); const isCopySubmitting = ref(false);
const isConfigured = ref(false);
const loadedStoreId = ref('');
// 2. 页面主业务数据。 // 2. 页面主业务数据。
const stores = ref<StoreListItemDto[]>([]); const stores = ref<StoreListItemDto[]>([]);
@@ -180,6 +182,19 @@ export function useStoreDeliveryPage() {
const isRadiusMode = computed(() => editingMode.value === 'radius'); const isRadiusMode = computed(() => editingMode.value === 'radius');
const isPageLoading = computed(() => isSettingsLoading.value); const isPageLoading = computed(() => isSettingsLoading.value);
const hasSelectedStore = computed(() => Boolean(selectedStoreId.value));
const hasLoadedStoreSettings = computed(
() =>
loadedStoreId.value === selectedStoreId.value && snapshot.value !== null,
);
const canOperate = computed(
() =>
hasSelectedStore.value &&
loadedStoreId.value === selectedStoreId.value &&
!isStoreLoading.value &&
!isSettingsLoading.value &&
!isSaving.value,
);
const tierDrawerTitle = computed(() => const tierDrawerTitle = computed(() =>
tierDrawerMode.value === 'edit' ? '编辑梯度' : '添加梯度', tierDrawerMode.value === 'edit' ? '编辑梯度' : '添加梯度',
@@ -188,6 +203,19 @@ export function useStoreDeliveryPage() {
zoneDrawerMode.value === 'edit' ? '编辑区域' : '新增区域', zoneDrawerMode.value === 'edit' ? '编辑区域' : '新增区域',
); );
function clearSettings() {
deliveryMode.value = DEFAULT_DELIVERY_MODE;
editingMode.value = DEFAULT_DELIVERY_MODE;
radiusCenterLatitude.value = null;
radiusCenterLongitude.value = null;
radiusTiers.value = cloneRadiusTiers(DEFAULT_RADIUS_TIERS);
polygonZones.value = clonePolygonZones(DEFAULT_POLYGON_ZONES);
Object.assign(
generalSettings,
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
}
// 5. 数据域动作装配。 // 5. 数据域动作装配。
const { const {
loadStoreSettings, loadStoreSettings,
@@ -195,11 +223,14 @@ export function useStoreDeliveryPage() {
resetFromSnapshot, resetFromSnapshot,
saveCurrentSettings, saveCurrentSettings,
} = createDataActions({ } = createDataActions({
clearSettings,
editingMode, editingMode,
generalSettings, generalSettings,
isConfigured,
isSaving, isSaving,
isSettingsLoading, isSettingsLoading,
isStoreLoading, isStoreLoading,
loadedStoreId,
mode: deliveryMode, mode: deliveryMode,
radiusCenterLatitude, radiusCenterLatitude,
radiusCenterLongitude, radiusCenterLongitude,
@@ -224,8 +255,8 @@ export function useStoreDeliveryPage() {
}); });
const { const {
handleDeleteTier, handleDeleteTier: handleDeleteTierLocal,
handleTierSubmit, handleTierSubmit: handleTierSubmitLocal,
openTierDrawer, openTierDrawer,
setTierColor, setTierColor,
setTierDeliveryFee, setTierDeliveryFee,
@@ -245,8 +276,8 @@ export function useStoreDeliveryPage() {
}); });
const { const {
handleDeleteZone, handleDeleteZone: handleDeleteZoneLocal,
handleZoneSubmit, handleZoneSubmit: handleZoneSubmitLocal,
openZoneDrawer, openZoneDrawer,
setZoneColor, setZoneColor,
setZoneDeliveryFee, setZoneDeliveryFee,
@@ -294,6 +325,7 @@ export function useStoreDeliveryPage() {
// 切换“当前生效模式”,二次确认后保存,防止误操作。 // 切换“当前生效模式”,二次确认后保存,防止误操作。
function setDeliveryMode(value: DeliveryMode) { function setDeliveryMode(value: DeliveryMode) {
if (!canOperate.value) return;
if (value === deliveryMode.value) return; if (value === deliveryMode.value) return;
Modal.confirm({ Modal.confirm({
title: '确认切换当前启用的配送模式?', title: '确认切换当前启用的配送模式?',
@@ -362,22 +394,47 @@ export function useStoreDeliveryPage() {
} }
} }
async function handleTierSubmit() {
if (!canOperate.value) return;
const changed = handleTierSubmitLocal();
if (!changed) return;
await saveCurrentSettings();
}
async function handleDeleteTier(tierId: string) {
if (!canOperate.value) return;
const changed = handleDeleteTierLocal(tierId);
if (!changed) return;
await saveCurrentSettings();
}
async function handleZoneSubmit() {
if (!canOperate.value) return;
const changed = handleZoneSubmitLocal();
if (!changed) return;
await saveCurrentSettings();
}
async function handleDeleteZone(zoneId: string) {
if (!canOperate.value) return;
const changed = handleDeleteZoneLocal(zoneId);
if (!changed) return;
await saveCurrentSettings();
}
// 7. 门店切换时自动刷新配置。 // 7. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => { watch(selectedStoreId, async (storeId) => {
if (!storeId) { if (!storeId) {
deliveryMode.value = DEFAULT_DELIVERY_MODE; loadedStoreId.value = '';
editingMode.value = DEFAULT_DELIVERY_MODE; isConfigured.value = false;
radiusCenterLatitude.value = null; clearSettings();
radiusCenterLongitude.value = null;
radiusTiers.value = cloneRadiusTiers(DEFAULT_RADIUS_TIERS);
polygonZones.value = clonePolygonZones(DEFAULT_POLYGON_ZONES);
Object.assign(
generalSettings,
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
snapshot.value = null; snapshot.value = null;
return; return;
} }
loadedStoreId.value = '';
isConfigured.value = false;
snapshot.value = null;
clearSettings();
await loadStoreSettings(storeId); await loadStoreSettings(storeId);
}); });
@@ -392,6 +449,7 @@ export function useStoreDeliveryPage() {
return { return {
DELIVERY_MODE_OPTIONS, DELIVERY_MODE_OPTIONS,
canOperate,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
deliveryMode, deliveryMode,
@@ -409,6 +467,9 @@ export function useStoreDeliveryPage() {
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
hasLoadedStoreSettings,
hasSelectedStore,
isPageLoading, isPageLoading,
isRadiusMode, isRadiusMode,
isSaving, isSaving,
@@ -422,6 +483,7 @@ export function useStoreDeliveryPage() {
openCopyModal, openCopyModal,
openTierDrawer, openTierDrawer,
openZoneDrawer, openZoneDrawer,
loadedStoreId,
polygonZones, polygonZones,
radiusCenterLatitude, radiusCenterLatitude,
radiusCenterLongitude, radiusCenterLongitude,

View File

@@ -20,6 +20,7 @@ import { useStoreDeliveryPage } from './composables/useStoreDeliveryPage';
const { const {
DELIVERY_MODE_OPTIONS, DELIVERY_MODE_OPTIONS,
canOperate,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
deliveryMode, deliveryMode,
@@ -37,6 +38,8 @@ const {
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
hasLoadedStoreSettings,
isPageLoading, isPageLoading,
isRadiusMode, isRadiusMode,
isSaving, isSaving,
@@ -98,7 +101,9 @@ const {
:selected-store-id="selectedStoreId" :selected-store-id="selectedStoreId"
:store-options="storeOptions" :store-options="storeOptions"
:is-store-loading="isStoreLoading" :is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0" :copy-disabled="
!canOperate || !isConfigured || copyCandidates.length === 0
"
@update:selected-store-id="setSelectedStoreId" @update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal" @copy="openCopyModal"
/> />
@@ -111,6 +116,12 @@ const {
<template v-else> <template v-else>
<Spin :spinning="isPageLoading"> <Spin :spinning="isPageLoading">
<Card v-if="hasLoadedStoreSettings && !isConfigured" :bordered="false">
<Empty
description="当前门店尚未配置配送规则。请先填写规则并保存,之后可执行复制。"
/>
</Card>
<div class="delivery-cards-stack"> <div class="delivery-cards-stack">
<DeliveryModeCard <DeliveryModeCard
:active-mode="deliveryMode" :active-mode="deliveryMode"

View File

@@ -9,6 +9,7 @@ import type { DineInAreaDto } from '#/api/store-dinein';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue'; import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
interface Props { interface Props {
canOperate: boolean;
getAreaTableCount: (areaId: string) => number; getAreaTableCount: (areaId: string) => number;
isSaving: boolean; isSaving: boolean;
selectedArea?: DineInAreaDto; selectedArea?: DineInAreaDto;
@@ -32,7 +33,14 @@ const emit = defineEmits<{
<span class="section-title">区域管理</span> <span class="section-title">区域管理</span>
</template> </template>
<template #extra> <template #extra>
<Button type="primary" size="small" @click="emit('add')">添加区域</Button> <Button
type="primary"
size="small"
:disabled="!props.canOperate"
@click="emit('add')"
>
添加区域
</Button>
</template> </template>
<template v-if="props.areas.length > 0"> <template v-if="props.areas.length > 0">
@@ -43,6 +51,7 @@ const emit = defineEmits<{
type="button" type="button"
class="dinein-area-pill" class="dinein-area-pill"
:class="{ active: props.selectedAreaId === area.id }" :class="{ active: props.selectedAreaId === area.id }"
:disabled="!props.canOperate"
@click="emit('selectArea', area.id)" @click="emit('selectArea', area.id)"
> >
{{ area.name }} ({{ props.getAreaTableCount(area.id) }}) {{ area.name }} ({{ props.getAreaTableCount(area.id) }})
@@ -58,6 +67,7 @@ const emit = defineEmits<{
<Button <Button
type="link" type="link"
size="small" size="small"
:disabled="!props.canOperate"
@click="emit('edit', props.selectedArea)" @click="emit('edit', props.selectedArea)"
> >
编辑 编辑
@@ -66,9 +76,16 @@ const emit = defineEmits<{
title="确认删除该区域吗?" title="确认删除该区域吗?"
ok-text="确认" ok-text="确认"
cancel-text="取消" cancel-text="取消"
:disabled="!props.canOperate"
@confirm="emit('delete', props.selectedArea)" @confirm="emit('delete', props.selectedArea)"
> >
<Button type="link" danger size="small" :loading="props.isSaving"> <Button
type="link"
danger
size="small"
:disabled="!props.canOperate"
:loading="props.isSaving"
>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>

View File

@@ -9,6 +9,7 @@ import type { DineInBasicSettingsDto } from '#/api/store-dinein';
import { Button, Card, InputNumber, Switch } from 'ant-design-vue'; import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
interface Props { interface Props {
canOperate: boolean;
isSaving: boolean; isSaving: boolean;
onSetDefaultDiningMinutes: (value: number) => void; onSetDefaultDiningMinutes: (value: number) => void;
onSetEnabled: (value: boolean) => void; onSetEnabled: (value: boolean) => void;
@@ -40,6 +41,7 @@ function toNumber(value: null | number | string, fallback = 0) {
<div class="dinein-form-control"> <div class="dinein-form-control">
<Switch <Switch
:checked="props.settings.enabled" :checked="props.settings.enabled"
:disabled="!props.canOperate"
@update:checked="(value) => props.onSetEnabled(Boolean(value))" @update:checked="(value) => props.onSetEnabled(Boolean(value))"
/> />
</div> </div>
@@ -53,6 +55,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:min="1" :min="1"
:precision="0" :precision="0"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="dinein-number-input" class="dinein-number-input"
@update:value=" @update:value="
(value) => props.onSetDefaultDiningMinutes(toNumber(value, 90)) (value) => props.onSetDefaultDiningMinutes(toNumber(value, 90))
@@ -70,6 +73,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:min="0" :min="0"
:precision="0" :precision="0"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="dinein-number-input" class="dinein-number-input"
@update:value=" @update:value="
(value) => props.onSetOvertimeReminderMinutes(toNumber(value, 10)) (value) => props.onSetOvertimeReminderMinutes(toNumber(value, 10))
@@ -81,8 +85,18 @@ function toNumber(value: null | number | string, fallback = 0) {
</div> </div>
<div class="dinein-form-actions"> <div class="dinein-form-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button> <Button
<Button type="primary" :loading="props.isSaving" @click="emit('save')"> :disabled="props.isSaving || !props.canOperate"
@click="emit('reset')"
>
重置
</Button>
<Button
type="primary"
:loading="props.isSaving"
:disabled="!props.canOperate"
@click="emit('save')"
>
保存设置 保存设置
</Button> </Button>
</div> </div>

View File

@@ -10,6 +10,8 @@ import type { DineInStatusOption } from '#/views/store/dine-in/types';
import { Button, Card, Empty, Popconfirm, Tag } from 'ant-design-vue'; import { Button, Card, Empty, Popconfirm, Tag } from 'ant-design-vue';
interface Props { interface Props {
canOperate: boolean;
hasSelectedArea: boolean;
isSaving: boolean; isSaving: boolean;
resolveStatusClassName: (status: DineInTableDto['status']) => string; resolveStatusClassName: (status: DineInTableDto['status']) => string;
statusMap: Record<DineInTableDto['status'], DineInStatusOption>; statusMap: Record<DineInTableDto['status'], DineInStatusOption>;
@@ -34,8 +36,19 @@ const emit = defineEmits<{
</template> </template>
<template #extra> <template #extra>
<div class="dinein-table-header-actions"> <div class="dinein-table-header-actions">
<Button size="small" @click="emit('batch')">批量生成</Button> <Button
<Button type="primary" size="small" @click="emit('add')"> size="small"
:disabled="!props.canOperate || !props.hasSelectedArea"
@click="emit('batch')"
>
批量生成
</Button>
<Button
type="primary"
size="small"
:disabled="!props.canOperate || !props.hasSelectedArea"
@click="emit('add')"
>
添加桌位 添加桌位
</Button> </Button>
</div> </div>
@@ -68,19 +81,36 @@ const emit = defineEmits<{
</div> </div>
<div class="dinein-table-footer"> <div class="dinein-table-footer">
<Button size="small" type="text" @click="emit('qrcode', table)"> <Button
size="small"
type="text"
:disabled="!props.canOperate"
@click="emit('qrcode', table)"
>
二维码 二维码
</Button> </Button>
<Button size="small" type="text" @click="emit('edit', table)"> <Button
size="small"
type="text"
:disabled="!props.canOperate"
@click="emit('edit', table)"
>
编辑 编辑
</Button> </Button>
<Popconfirm <Popconfirm
title="确认删除该桌位吗?" title="确认删除该桌位吗?"
ok-text="确认" ok-text="确认"
cancel-text="取消" cancel-text="取消"
:disabled="!props.canOperate"
@confirm="emit('delete', table.id)" @confirm="emit('delete', table.id)"
> >
<Button size="small" type="text" danger :loading="props.isSaving"> <Button
size="small"
type="text"
danger
:disabled="!props.canOperate"
:loading="props.isSaving"
>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -89,6 +119,11 @@ const emit = defineEmits<{
</div> </div>
</template> </template>
<Empty v-else description="当前区域暂无桌位" /> <Empty
v-else
:description="
props.hasSelectedArea ? '当前区域暂无桌位' : '请先添加并选择区域'
"
/>
</Card> </Card>
</template> </template>

View File

@@ -15,12 +15,7 @@ import { message } from 'ant-design-vue';
import { deleteDineInAreaApi, saveDineInAreaApi } from '#/api/store-dinein'; import { deleteDineInAreaApi, saveDineInAreaApi } from '#/api/store-dinein';
import { import { countAreaTables, sortAreas, validateAreaForm } from './helpers';
countAreaTables,
createDineInId,
sortAreas,
validateAreaForm,
} from './helpers';
interface CreateAreaActionsOptions { interface CreateAreaActionsOptions {
areaDrawerMode: Ref<DineInAreaDrawerMode>; areaDrawerMode: Ref<DineInAreaDrawerMode>;
@@ -91,30 +86,27 @@ export function createAreaActions(options: CreateAreaActionsOptions) {
options.isSavingArea.value = true; options.isSavingArea.value = true;
try { try {
const areaId = options.areaForm.id || createDineInId('area'); const savedArea = await saveDineInAreaApi({
const areaPayload: DineInAreaDto = { storeId: options.selectedStoreId.value,
id: areaId, area: {
id: options.areaForm.id || undefined,
name: options.areaForm.name.trim(), name: options.areaForm.name.trim(),
description: options.areaForm.description.trim(), description: options.areaForm.description.trim(),
sort: Math.max(1, Math.floor(options.areaForm.sort)), sort: Math.max(1, Math.floor(options.areaForm.sort)),
}; },
await saveDineInAreaApi({
storeId: options.selectedStoreId.value,
area: areaPayload,
}); });
options.areas.value = options.areas.value =
options.areaDrawerMode.value === 'edit' && options.areaForm.id options.areaDrawerMode.value === 'edit' && options.areaForm.id
? sortAreas( ? sortAreas(
options.areas.value.map((item) => options.areas.value.map((item) =>
item.id === options.areaForm.id ? areaPayload : item, item.id === options.areaForm.id ? savedArea : item,
), ),
) )
: sortAreas([...options.areas.value, areaPayload]); : sortAreas([...options.areas.value, savedArea]);
if (!options.selectedAreaId.value) { if (!options.selectedAreaId.value) {
options.selectedAreaId.value = areaPayload.id; options.selectedAreaId.value = savedArea.id;
} }
options.fixSelectedArea(); options.fixSelectedArea();
options.updateSnapshot(); options.updateSnapshot();

View File

@@ -1,13 +1,10 @@
/** /**
* 文件职责:堂食管理页面静态常量。 * 文件职责:堂食管理页面静态常量。
* 1. 维护默认区域、桌位、基础设置 * 1. 维护状态、座位数、标签建议等固定枚举
* 2. 提供状态、座位数等选项映射 * 2. 不包含任何业务数据兜底
*/ */
import type { import type {
DineInAreaDto,
DineInBasicSettingsDto,
DineInEditableStatus, DineInEditableStatus,
DineInTableDto,
DineInTableStatus, DineInTableStatus,
} from '#/api/store-dinein'; } from '#/api/store-dinein';
import type { import type {
@@ -55,100 +52,6 @@ export const DINE_IN_EDITABLE_STATUS_OPTIONS: Array<{
{ label: '停用', value: 'disabled' }, { label: '停用', value: 'disabled' },
]; ];
export const DEFAULT_DINE_IN_BASIC_SETTINGS: DineInBasicSettingsDto = {
enabled: true,
defaultDiningMinutes: 90,
overtimeReminderMinutes: 10,
};
export const DEFAULT_DINE_IN_AREAS: DineInAreaDto[] = [
{
id: 'dinein-area-hall',
name: '大厅',
description: '主要用餐区域共12张桌位可容纳约48人同时用餐',
sort: 1,
},
{
id: 'dinein-area-private-room',
name: '包间',
description: '安静独立区域,适合聚餐与商务接待',
sort: 2,
},
{
id: 'dinein-area-terrace',
name: '露台',
description: '开放式外摆区域,适合休闲场景',
sort: 3,
},
];
export const DEFAULT_DINE_IN_TABLES: DineInTableDto[] = [
{
id: 'dinein-table-a01',
code: 'A01',
areaId: 'dinein-area-hall',
seats: 4,
status: 'free',
tags: ['靠窗'],
},
{
id: 'dinein-table-a02',
code: 'A02',
areaId: 'dinein-area-hall',
seats: 2,
status: 'dining',
tags: [],
},
{
id: 'dinein-table-a03',
code: 'A03',
areaId: 'dinein-area-hall',
seats: 6,
status: 'free',
tags: ['VIP', '靠窗'],
},
{
id: 'dinein-table-a04',
code: 'A04',
areaId: 'dinein-area-hall',
seats: 4,
status: 'reserved',
tags: [],
},
{
id: 'dinein-table-a07',
code: 'A07',
areaId: 'dinein-area-hall',
seats: 4,
status: 'disabled',
tags: [],
},
{
id: 'dinein-table-v01',
code: 'V01',
areaId: 'dinein-area-private-room',
seats: 8,
status: 'dining',
tags: ['包厢'],
},
{
id: 'dinein-table-v02',
code: 'V02',
areaId: 'dinein-area-private-room',
seats: 6,
status: 'free',
tags: ['VIP'],
},
{
id: 'dinein-table-t01',
code: 'T01',
areaId: 'dinein-area-terrace',
seats: 4,
status: 'free',
tags: ['露台'],
},
];
export const TABLE_TAG_SUGGESTIONS = [ export const TABLE_TAG_SUGGESTIONS = [
'VIP', 'VIP',
'包厢', '包厢',

View File

@@ -22,14 +22,7 @@ import {
} from '#/api/store-dinein'; } from '#/api/store-dinein';
import { import {
DEFAULT_DINE_IN_AREAS,
DEFAULT_DINE_IN_BASIC_SETTINGS,
DEFAULT_DINE_IN_TABLES,
} from './constants';
import {
cloneAreas,
cloneBasicSettings, cloneBasicSettings,
cloneTables,
createSettingsSnapshot, createSettingsSnapshot,
sortAreas, sortAreas,
sortTables, sortTables,
@@ -38,9 +31,12 @@ import {
interface CreateDataActionsOptions { interface CreateDataActionsOptions {
areas: Ref<DineInAreaDto[]>; areas: Ref<DineInAreaDto[]>;
basicSettings: DineInBasicSettingsDto; basicSettings: DineInBasicSettingsDto;
clearSettings: () => void;
isConfigured: Ref<boolean>;
isPageLoading: Ref<boolean>; isPageLoading: Ref<boolean>;
isSavingBasic: Ref<boolean>; isSavingBasic: Ref<boolean>;
isStoreLoading: Ref<boolean>; isStoreLoading: Ref<boolean>;
loadedStoreId: Ref<string>;
selectedAreaId: Ref<string>; selectedAreaId: Ref<string>;
selectedStoreId: Ref<string>; selectedStoreId: Ref<string>;
snapshot: Ref<DineInSettingsSnapshot | null>; snapshot: Ref<DineInSettingsSnapshot | null>;
@@ -57,14 +53,6 @@ export function createDataActions(options: CreateDataActionsOptions) {
next.overtimeReminderMinutes; next.overtimeReminderMinutes;
} }
/** 应用默认配置(接口异常兜底)。 */
function applyDefaultSettings() {
options.areas.value = sortAreas(cloneAreas(DEFAULT_DINE_IN_AREAS));
options.tables.value = sortTables(cloneTables(DEFAULT_DINE_IN_TABLES));
syncBasicSettings(cloneBasicSettings(DEFAULT_DINE_IN_BASIC_SETTINGS));
options.selectedAreaId.value = options.areas.value[0]?.id ?? '';
}
/** 构建当前快照。 */ /** 构建当前快照。 */
function buildCurrentSnapshot() { function buildCurrentSnapshot() {
return createSettingsSnapshot({ return createSettingsSnapshot({
@@ -96,27 +84,31 @@ export function createDataActions(options: CreateDataActionsOptions) {
const result = await getStoreDineInSettingsApi(storeId); const result = await getStoreDineInSettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return; if (options.selectedStoreId.value !== currentStoreId) return;
options.areas.value = sortAreas( if (!result.isConfigured) {
result.areas?.length > 0 options.clearSettings();
? result.areas options.isConfigured.value = false;
: cloneAreas(DEFAULT_DINE_IN_AREAS), options.loadedStoreId.value = currentStoreId;
); options.snapshot.value = buildCurrentSnapshot();
options.tables.value = sortTables( return;
result.tables?.length > 0 }
? result.tables
: cloneTables(DEFAULT_DINE_IN_TABLES), options.areas.value = sortAreas(result.areas ?? []);
); options.tables.value = sortTables(result.tables ?? []);
syncBasicSettings({ if (result.basicSettings) {
...DEFAULT_DINE_IN_BASIC_SETTINGS, syncBasicSettings(cloneBasicSettings(result.basicSettings));
...result.basicSettings, }
});
fixSelectedArea(); fixSelectedArea();
options.isConfigured.value = true;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot(); options.snapshot.value = buildCurrentSnapshot();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
applyDefaultSettings(); options.isConfigured.value = false;
options.snapshot.value = buildCurrentSnapshot(); options.loadedStoreId.value = '';
options.snapshot.value = null;
options.clearSettings();
message.error('加载堂食设置失败,请稍后重试');
} finally { } finally {
options.isPageLoading.value = false; options.isPageLoading.value = false;
} }
@@ -138,7 +130,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
if (options.stores.value.length === 0) { if (options.stores.value.length === 0) {
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
applyDefaultSettings(); options.isConfigured.value = false;
options.clearSettings();
options.snapshot.value = null; options.snapshot.value = null;
return; return;
} }
@@ -156,9 +149,11 @@ export function createDataActions(options: CreateDataActionsOptions) {
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error('加载门店失败,请稍后重试');
options.stores.value = []; options.stores.value = [];
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
applyDefaultSettings(); options.isConfigured.value = false;
options.clearSettings();
options.snapshot.value = null; options.snapshot.value = null;
} finally { } finally {
options.isStoreLoading.value = false; options.isStoreLoading.value = false;
@@ -174,6 +169,7 @@ export function createDataActions(options: CreateDataActionsOptions) {
storeId: options.selectedStoreId.value, storeId: options.selectedStoreId.value,
basicSettings: cloneBasicSettings(options.basicSettings), basicSettings: cloneBasicSettings(options.basicSettings),
}); });
options.isConfigured.value = true;
options.snapshot.value = buildCurrentSnapshot(); options.snapshot.value = buildCurrentSnapshot();
message.success('堂食设置已保存'); message.success('堂食设置已保存');
} catch (error) { } catch (error) {
@@ -185,10 +181,11 @@ export function createDataActions(options: CreateDataActionsOptions) {
/** 重置基础设置到最近快照。 */ /** 重置基础设置到最近快照。 */
function resetBasicSettings() { function resetBasicSettings() {
const source = if (!options.snapshot.value) {
options.snapshot.value?.basicSettings ?? message.warning('暂无可恢复的已保存配置');
cloneBasicSettings(DEFAULT_DINE_IN_BASIC_SETTINGS); return;
syncBasicSettings(source); }
syncBasicSettings(options.snapshot.value.basicSettings);
message.success('已恢复到最近一次保存状态'); message.success('已恢复到最近一次保存状态');
} }

View File

@@ -58,11 +58,6 @@ export function sortTables(source: DineInTableDto[]) {
return cloneTables(source).toSorted((a, b) => a.code.localeCompare(b.code)); return cloneTables(source).toSorted((a, b) => a.code.localeCompare(b.code));
} }
/** 生成唯一 ID。 */
export function createDineInId(prefix: 'area' | 'table') {
return `dinein-${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 规范化桌位编号(大写 + 去空格)。 */ /** 规范化桌位编号(大写 + 去空格)。 */
export function normalizeTableCode(code: string) { export function normalizeTableCode(code: string) {
return code.trim().toUpperCase(); return code.trim().toUpperCase();

View File

@@ -1,4 +1,4 @@
import type { ComputedRef, Ref } from 'vue'; import type { Ref } from 'vue';
/** /**
* 文件职责:堂食桌位动作。 * 文件职责:堂食桌位动作。
@@ -25,7 +25,6 @@ import {
} from '#/api/store-dinein'; } from '#/api/store-dinein';
import { import {
createDineInId,
generateBatchCodes, generateBatchCodes,
normalizeTableCode, normalizeTableCode,
sortTables, sortTables,
@@ -36,7 +35,6 @@ import {
interface CreateTableActionsOptions { interface CreateTableActionsOptions {
areas: Ref<DineInAreaDto[]>; areas: Ref<DineInAreaDto[]>;
batchForm: DineInBatchFormState; batchForm: DineInBatchFormState;
batchPreviewCodes: ComputedRef<string[]>;
isBatchModalOpen: Ref<boolean>; isBatchModalOpen: Ref<boolean>;
isSavingBatch: Ref<boolean>; isSavingBatch: Ref<boolean>;
isSavingTable: Ref<boolean>; isSavingTable: Ref<boolean>;
@@ -120,7 +118,6 @@ export function createTableActions(options: CreateTableActionsOptions) {
options.isSavingTable.value = true; options.isSavingTable.value = true;
try { try {
const tableId = options.tableForm.id || createDineInId('table');
let nextStatus: DineInTableStatus = options.tableForm.sourceStatus; let nextStatus: DineInTableStatus = options.tableForm.sourceStatus;
if (options.tableForm.isDisabled) { if (options.tableForm.isDisabled) {
nextStatus = 'disabled'; nextStatus = 'disabled';
@@ -128,28 +125,26 @@ export function createTableActions(options: CreateTableActionsOptions) {
nextStatus = 'free'; nextStatus = 'free';
} }
const tablePayload: DineInTableDto = { const savedTable = await saveDineInTableApi({
id: tableId, storeId: options.selectedStoreId.value,
table: {
id: options.tableForm.id || undefined,
code: normalizeTableCode(options.tableForm.code), code: normalizeTableCode(options.tableForm.code),
areaId: options.tableForm.areaId, areaId: options.tableForm.areaId,
seats: options.tableForm.seats, seats: options.tableForm.seats,
status: nextStatus, status: nextStatus,
tags: [...options.tableForm.tags], tags: [...options.tableForm.tags],
}; },
await saveDineInTableApi({
storeId: options.selectedStoreId.value,
table: tablePayload,
}); });
options.tables.value = options.tables.value =
options.tableDrawerMode.value === 'edit' && options.tableForm.id options.tableDrawerMode.value === 'edit' && options.tableForm.id
? sortTables( ? sortTables(
options.tables.value.map((item) => options.tables.value.map((item) =>
item.id === options.tableForm.id ? tablePayload : item, item.id === options.tableForm.id ? savedTable : item,
), ),
) )
: sortTables([...options.tables.value, tablePayload]); : sortTables([...options.tables.value, savedTable]);
options.updateSnapshot(); options.updateSnapshot();
options.isTableDrawerOpen.value = false; options.isTableDrawerOpen.value = false;
@@ -237,7 +232,7 @@ export function createTableActions(options: CreateTableActionsOptions) {
options.isSavingBatch.value = true; options.isSavingBatch.value = true;
try { try {
await batchCreateDineInTablesApi({ const result = await batchCreateDineInTablesApi({
storeId: options.selectedStoreId.value, storeId: options.selectedStoreId.value,
areaId: options.batchForm.areaId, areaId: options.batchForm.areaId,
codePrefix: options.batchForm.codePrefix, codePrefix: options.batchForm.codePrefix,
@@ -246,15 +241,7 @@ export function createTableActions(options: CreateTableActionsOptions) {
seats: options.batchForm.seats, seats: options.batchForm.seats,
}); });
const createdTables: DineInTableDto[] = const createdTables: DineInTableDto[] = result.createdTables ?? [];
options.batchPreviewCodes.value.map((code) => ({
id: createDineInId('table'),
areaId: options.batchForm.areaId,
code,
seats: options.batchForm.seats,
status: 'free',
tags: [],
}));
options.tables.value = sortTables([ options.tables.value = sortTables([
...options.tables.value, ...options.tables.value,

View File

@@ -23,9 +23,6 @@ import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
import { createAreaActions } from './dinein-page/area-actions'; import { createAreaActions } from './dinein-page/area-actions';
import { import {
DEFAULT_DINE_IN_AREAS,
DEFAULT_DINE_IN_BASIC_SETTINGS,
DEFAULT_DINE_IN_TABLES,
DINE_IN_SEATS_OPTIONS, DINE_IN_SEATS_OPTIONS,
DINE_IN_STATUS_MAP, DINE_IN_STATUS_MAP,
TABLE_TAG_SUGGESTIONS, TABLE_TAG_SUGGESTIONS,
@@ -33,18 +30,19 @@ import {
import { createCopyActions } from './dinein-page/copy-actions'; import { createCopyActions } from './dinein-page/copy-actions';
import { createDataActions } from './dinein-page/data-actions'; import { createDataActions } from './dinein-page/data-actions';
import { import {
cloneAreas,
cloneBasicSettings, cloneBasicSettings,
cloneTables,
countAreaTables, countAreaTables,
createSettingsSnapshot,
generateBatchCodes, generateBatchCodes,
resolveStatusClassName, resolveStatusClassName,
sortAreas,
sortTables,
} from './dinein-page/helpers'; } from './dinein-page/helpers';
import { createTableActions } from './dinein-page/table-actions'; import { createTableActions } from './dinein-page/table-actions';
const EMPTY_BASIC_SETTINGS: DineInBasicSettingsDto = {
enabled: false,
defaultDiningMinutes: 0,
overtimeReminderMinutes: 0,
};
export function useStoreDineInPage() { export function useStoreDineInPage() {
// 1. 页面 loading / submitting 状态。 // 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false); const isStoreLoading = ref(false);
@@ -54,27 +52,19 @@ export function useStoreDineInPage() {
const isSavingTable = ref(false); const isSavingTable = ref(false);
const isSavingBatch = ref(false); const isSavingBatch = ref(false);
const isCopySubmitting = ref(false); const isCopySubmitting = ref(false);
const isConfigured = ref(false);
// 2. 页面核心业务数据。 // 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]); const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref(''); const selectedStoreId = ref('');
const areas = ref<DineInAreaDto[]>( const loadedStoreId = ref('');
sortAreas(cloneAreas(DEFAULT_DINE_IN_AREAS)), const areas = ref<DineInAreaDto[]>([]);
); const tables = ref<DineInTableDto[]>([]);
const tables = ref<DineInTableDto[]>(
sortTables(cloneTables(DEFAULT_DINE_IN_TABLES)),
);
const basicSettings = reactive<DineInBasicSettingsDto>( const basicSettings = reactive<DineInBasicSettingsDto>(
cloneBasicSettings(DEFAULT_DINE_IN_BASIC_SETTINGS), cloneBasicSettings(EMPTY_BASIC_SETTINGS),
);
const selectedAreaId = ref(areas.value[0]?.id ?? '');
const snapshot = ref<DineInSettingsSnapshot | null>(
createSettingsSnapshot({
areas: areas.value,
tables: tables.value,
basicSettings,
}),
); );
const selectedAreaId = ref('');
const snapshot = ref<DineInSettingsSnapshot | null>(null);
// 3. 复制弹窗状态。 // 3. 复制弹窗状态。
const isCopyModalOpen = ref(false); const isCopyModalOpen = ref(false);
@@ -121,6 +111,20 @@ export function useStoreDineInPage() {
stores.value.find((store) => store.id === selectedStoreId.value)?.name ?? stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'', '',
); );
const hasSelectedStore = computed(() => Boolean(selectedStoreId.value));
const hasLoadedStoreSettings = computed(() => snapshot.value !== null);
const canOperate = computed(
() =>
hasSelectedStore.value &&
loadedStoreId.value === selectedStoreId.value &&
hasLoadedStoreSettings.value &&
!isStoreLoading.value &&
!isPageLoading.value &&
!isSavingArea.value &&
!isSavingTable.value &&
!isSavingBatch.value &&
!isSavingBasic.value,
);
const selectedArea = computed( const selectedArea = computed(
() => () =>
@@ -180,6 +184,18 @@ export function useStoreDineInPage() {
tableDrawerMode.value === 'edit' ? '保存修改' : '确认添加', tableDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
); );
function clearSettings() {
loadedStoreId.value = '';
areas.value = [];
tables.value = [];
selectedAreaId.value = '';
basicSettings.enabled = EMPTY_BASIC_SETTINGS.enabled;
basicSettings.defaultDiningMinutes =
EMPTY_BASIC_SETTINGS.defaultDiningMinutes;
basicSettings.overtimeReminderMinutes =
EMPTY_BASIC_SETTINGS.overtimeReminderMinutes;
}
// 7. 数据域动作装配。 // 7. 数据域动作装配。
const { const {
buildCurrentSnapshot, buildCurrentSnapshot,
@@ -191,9 +207,12 @@ export function useStoreDineInPage() {
} = createDataActions({ } = createDataActions({
areas, areas,
basicSettings, basicSettings,
clearSettings,
isConfigured,
isPageLoading, isPageLoading,
isSavingBasic, isSavingBasic,
isStoreLoading, isStoreLoading,
loadedStoreId,
selectedAreaId, selectedAreaId,
selectedStoreId, selectedStoreId,
snapshot, snapshot,
@@ -261,7 +280,6 @@ export function useStoreDineInPage() {
} = createTableActions({ } = createTableActions({
areas, areas,
batchForm, batchForm,
batchPreviewCodes,
isBatchModalOpen, isBatchModalOpen,
isSavingBatch, isSavingBatch,
isSavingTable, isSavingTable,
@@ -304,17 +322,12 @@ export function useStoreDineInPage() {
// 9. 门店切换时自动刷新配置。 // 9. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => { watch(selectedStoreId, async (storeId) => {
if (!storeId) { if (!storeId) {
areas.value = sortAreas(cloneAreas(DEFAULT_DINE_IN_AREAS)); clearSettings();
tables.value = sortTables(cloneTables(DEFAULT_DINE_IN_TABLES)); isConfigured.value = false;
basicSettings.enabled = DEFAULT_DINE_IN_BASIC_SETTINGS.enabled;
basicSettings.defaultDiningMinutes =
DEFAULT_DINE_IN_BASIC_SETTINGS.defaultDiningMinutes;
basicSettings.overtimeReminderMinutes =
DEFAULT_DINE_IN_BASIC_SETTINGS.overtimeReminderMinutes;
selectedAreaId.value = areas.value[0]?.id ?? '';
snapshot.value = null; snapshot.value = null;
return; return;
} }
isConfigured.value = false;
await loadStoreSettings(storeId); await loadStoreSettings(storeId);
}); });
@@ -335,6 +348,7 @@ export function useStoreDineInPage() {
basicSettings, basicSettings,
batchForm, batchForm,
batchPreviewCodes, batchPreviewCodes,
canOperate,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
filteredTables, filteredTables,
@@ -351,6 +365,7 @@ export function useStoreDineInPage() {
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
isPageLoading, isPageLoading,
isSavingArea, isSavingArea,
isSavingBasic, isSavingBasic,
@@ -368,6 +383,9 @@ export function useStoreDineInPage() {
selectedArea, selectedArea,
selectedAreaId, selectedAreaId,
selectedAreaTableCount, selectedAreaTableCount,
hasSelectedStore,
hasLoadedStoreSettings,
loadedStoreId,
selectedStoreId, selectedStoreId,
selectedStoreName, selectedStoreName,
setAreaDescription, setAreaDescription,

View File

@@ -32,6 +32,7 @@ const {
basicSettings, basicSettings,
batchForm, batchForm,
batchPreviewCodes, batchPreviewCodes,
canOperate,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
filteredTables, filteredTables,
@@ -42,12 +43,14 @@ const {
handleSubmitArea, handleSubmitArea,
handleSubmitBatch, handleSubmitBatch,
handleSubmitTable, handleSubmitTable,
hasLoadedStoreSettings,
isAreaDrawerOpen, isAreaDrawerOpen,
isBatchModalOpen, isBatchModalOpen,
isCopyAllChecked, isCopyAllChecked,
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
isPageLoading, isPageLoading,
isSavingArea, isSavingArea,
isSavingBasic, isSavingBasic,
@@ -125,7 +128,9 @@ function onViewQrCode(tableCode: string) {
:selected-store-id="selectedStoreId" :selected-store-id="selectedStoreId"
:store-options="storeOptions" :store-options="storeOptions"
:is-store-loading="isStoreLoading" :is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0" :copy-disabled="
!canOperate || !isConfigured || copyCandidates.length === 0
"
@update:selected-store-id="setSelectedStoreId" @update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal" @copy="openCopyModal"
/> />
@@ -138,7 +143,14 @@ function onViewQrCode(tableCode: string) {
<template v-else> <template v-else>
<Spin :spinning="isPageLoading"> <Spin :spinning="isPageLoading">
<Card v-if="hasLoadedStoreSettings && !isConfigured" :bordered="false">
<Empty
description="当前门店尚未配置堂食规则。请先填写并保存,之后可执行复制。"
/>
</Card>
<DineInAreaSection <DineInAreaSection
:can-operate="canOperate"
:areas="areas" :areas="areas"
:selected-area-id="selectedAreaId" :selected-area-id="selectedAreaId"
:selected-area="selectedArea" :selected-area="selectedArea"
@@ -151,6 +163,8 @@ function onViewQrCode(tableCode: string) {
/> />
<DineInTableGridSection <DineInTableGridSection
:can-operate="canOperate"
:has-selected-area="Boolean(selectedArea)"
:tables="filteredTables" :tables="filteredTables"
:is-saving="isSavingTable" :is-saving="isSavingTable"
:status-map="DINE_IN_STATUS_MAP" :status-map="DINE_IN_STATUS_MAP"
@@ -163,6 +177,7 @@ function onViewQrCode(tableCode: string) {
/> />
<DineInBasicSettingsCard <DineInBasicSettingsCard
:can-operate="canOperate"
:settings="basicSettings" :settings="basicSettings"
:is-saving="isSavingBasic" :is-saving="isSavingBasic"
:on-set-enabled="setDineInEnabled" :on-set-enabled="setDineInEnabled"

View File

@@ -8,6 +8,7 @@ import { Button, Card, InputNumber } from 'ant-design-vue';
interface Props { interface Props {
baseDeliveryFee: number; baseDeliveryFee: number;
canOperate: boolean;
freeDeliveryThreshold: null | number; freeDeliveryThreshold: null | number;
isSaving: boolean; isSaving: boolean;
minimumOrderAmount: number; minimumOrderAmount: number;
@@ -46,6 +47,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="1" :step="1"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="fees-input" class="fees-input"
placeholder="如15.00" placeholder="如15.00"
@update:value=" @update:value="
@@ -69,6 +71,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="1" :step="1"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="fees-input" class="fees-input"
placeholder="如3.00" placeholder="如3.00"
@update:value=" @update:value="
@@ -92,6 +95,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="1" :step="1"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="fees-input" class="fees-input"
placeholder="如30.00" placeholder="如30.00"
@update:value=" @update:value="
@@ -109,8 +113,18 @@ function toNumber(value: null | number | string, fallback = 0) {
</div> </div>
<div class="fees-actions"> <div class="fees-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button> <Button
<Button type="primary" :loading="props.isSaving" @click="emit('save')"> :disabled="props.isSaving || !props.canOperate"
@click="emit('reset')"
>
重置
</Button>
<Button
type="primary"
:loading="props.isSaving"
:disabled="!props.canOperate"
@click="emit('save')"
>
保存设置 保存设置
</Button> </Button>
</div> </div>

View File

@@ -7,6 +7,7 @@
import { Button, Card, InputNumber, Switch } from 'ant-design-vue'; import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
interface Props { interface Props {
canOperate: boolean;
cutleryAmount: number; cutleryAmount: number;
cutleryEnabled: boolean; cutleryEnabled: boolean;
isSaving: boolean; isSaving: boolean;
@@ -41,6 +42,7 @@ function toNumber(value: null | number | string, fallback = 0) {
<div class="other-fee-row"> <div class="other-fee-row">
<Switch <Switch
:checked="props.cutleryEnabled" :checked="props.cutleryEnabled"
:disabled="!props.canOperate"
@update:checked="(value) => props.onSetCutleryEnabled(Boolean(value))" @update:checked="(value) => props.onSetCutleryEnabled(Boolean(value))"
/> />
<div class="other-fee-meta"> <div class="other-fee-meta">
@@ -57,7 +59,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="0.5" :step="0.5"
:controls="false" :controls="false"
:disabled="!props.cutleryEnabled" :disabled="!props.cutleryEnabled || !props.canOperate"
class="other-fee-input" class="other-fee-input"
placeholder="如1.00" placeholder="如1.00"
@update:value=" @update:value="
@@ -71,6 +73,7 @@ function toNumber(value: null | number | string, fallback = 0) {
<div class="other-fee-row"> <div class="other-fee-row">
<Switch <Switch
:checked="props.rushEnabled" :checked="props.rushEnabled"
:disabled="!props.canOperate"
@update:checked="(value) => props.onSetRushEnabled(Boolean(value))" @update:checked="(value) => props.onSetRushEnabled(Boolean(value))"
/> />
<div class="other-fee-meta"> <div class="other-fee-meta">
@@ -85,7 +88,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="0.5" :step="0.5"
:controls="false" :controls="false"
:disabled="!props.rushEnabled" :disabled="!props.rushEnabled || !props.canOperate"
class="other-fee-input" class="other-fee-input"
placeholder="如3.00" placeholder="如3.00"
@update:value=" @update:value="
@@ -96,8 +99,18 @@ function toNumber(value: null | number | string, fallback = 0) {
</div> </div>
<div class="fees-actions"> <div class="fees-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button> <Button
<Button type="primary" :loading="props.isSaving" @click="emit('save')"> :disabled="props.isSaving || !props.canOperate"
@click="emit('reset')"
>
重置
</Button>
<Button
type="primary"
:loading="props.isSaving"
:disabled="!props.canOperate"
@click="emit('save')"
>
保存设置 保存设置
</Button> </Button>
</div> </div>

View File

@@ -9,6 +9,7 @@ import type { PackagingFeeTierDto } from '#/api/store-fees';
import { Button, Card, InputNumber, Switch } from 'ant-design-vue'; import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
interface Props { interface Props {
canOperate: boolean;
fixedPackagingFee: number; fixedPackagingFee: number;
formatCurrency: (value: number) => string; formatCurrency: (value: number) => string;
formatTierRange: (tier: PackagingFeeTierDto) => string; formatTierRange: (tier: PackagingFeeTierDto) => string;
@@ -31,11 +32,6 @@ const emit = defineEmits<{
(event: 'save'): void; (event: 'save'): void;
}>(); }>();
const packagingModes: Array<{ label: string; value: 'item' | 'order' }> = [
{ label: '按订单收取', value: 'order' },
{ label: '按商品收取', value: 'item' },
];
function toNumber(value: null | number | string, fallback = 0) { function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
@@ -48,17 +44,51 @@ function toNumber(value: null | number | string, fallback = 0) {
<span class="section-title">包装费设置</span> <span class="section-title">包装费设置</span>
</template> </template>
<div class="packaging-mode-switch"> <div class="packaging-mode-toggle-row">
<button <button
v-for="mode in packagingModes"
:key="mode.value"
type="button" type="button"
class="mode-switch-item" class="mode-toggle-label"
:class="{ active: props.packagingMode === mode.value }" :class="{ active: props.packagingMode === 'order' }"
@click="props.onSetPackagingMode(mode.value)" :disabled="!props.canOperate"
@click="props.onSetPackagingMode('order')"
> >
{{ mode.label }} 按订单收取
</button> </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="{ active: props.packagingMode === 'item' }"
:disabled="!props.canOperate"
@click="props.onSetPackagingMode('item')"
>
按商品收取
</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>
</div> </div>
<template v-if="props.packagingMode === 'order'"> <template v-if="props.packagingMode === 'order'">
@@ -73,6 +103,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="0.5" :step="0.5"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="fees-input" class="fees-input"
placeholder="如2.00" placeholder="如2.00"
@update:value=" @update:value="
@@ -91,6 +122,7 @@ function toNumber(value: null | number | string, fallback = 0) {
<div class="packaging-tier-toggle-row"> <div class="packaging-tier-toggle-row">
<Switch <Switch
:checked="props.tieredEnabled" :checked="props.tieredEnabled"
:disabled="!props.canOperate"
@update:checked=" @update:checked="
(value) => props.onSetTieredEnabled(Boolean(value)) (value) => props.onSetTieredEnabled(Boolean(value))
" "
@@ -117,12 +149,15 @@ function toNumber(value: null | number | string, fallback = 0) {
<td>{{ props.formatTierRange(tier) }}</td> <td>{{ props.formatTierRange(tier) }}</td>
<td>{{ props.formatCurrency(tier.fee) }}</td> <td>{{ props.formatCurrency(tier.fee) }}</td>
<td> <td>
<a class="fees-table-link" @click="emit('editTier', tier)"> <a
class="fees-table-link"
@click="props.canOperate && emit('editTier', tier)"
>
编辑 编辑
</a> </a>
<a <a
class="fees-table-link danger" class="fees-table-link danger"
@click="emit('deleteTier', tier)" @click="props.canOperate && emit('deleteTier', tier)"
> >
删除 删除
</a> </a>
@@ -133,7 +168,9 @@ function toNumber(value: null | number | string, fallback = 0) {
</div> </div>
<div class="packaging-tier-add-row"> <div class="packaging-tier-add-row">
<Button @click="emit('addTier')">+ 添加阶梯</Button> <Button :disabled="!props.canOperate" @click="emit('addTier')">
+ 添加阶梯
</Button>
</div> </div>
</template> </template>
</div> </div>
@@ -149,8 +186,18 @@ function toNumber(value: null | number | string, fallback = 0) {
</template> </template>
<div class="fees-actions"> <div class="fees-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button> <Button
<Button type="primary" :loading="props.isSaving" @click="emit('save')"> :disabled="props.isSaving || !props.canOperate"
@click="emit('reset')"
>
重置
</Button>
<Button
type="primary"
:loading="props.isSaving"
:disabled="!props.canOperate"
@click="emit('save')"
>
保存设置 保存设置
</Button> </Button>
</div> </div>

View File

@@ -9,6 +9,7 @@ import type { PackagingFeeTierFormState } from '#/views/store/fees/types';
import { Button, Drawer, InputNumber } from 'ant-design-vue'; import { Button, Drawer, InputNumber } from 'ant-design-vue';
interface Props { interface Props {
canOperate: boolean;
form: PackagingFeeTierFormState; form: PackagingFeeTierFormState;
isSaving: boolean; isSaving: boolean;
onSetFee: (value: number) => void; onSetFee: (value: number) => void;
@@ -49,6 +50,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="1" :step="1"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="drawer-input" class="drawer-input"
placeholder="起始金额" placeholder="起始金额"
@update:value=" @update:value="
@@ -63,6 +65,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="1" :step="1"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="drawer-input" class="drawer-input"
placeholder="结束金额(留空表示无上限)" placeholder="结束金额(留空表示无上限)"
@update:value=" @update:value="
@@ -90,6 +93,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:precision="2" :precision="2"
:step="0.5" :step="0.5"
:controls="false" :controls="false"
:disabled="!props.canOperate"
class="drawer-input" class="drawer-input"
placeholder="如2.00" placeholder="如2.00"
@update:value=" @update:value="
@@ -102,12 +106,16 @@ function toNumber(value: null | number | string, fallback = 0) {
<template #footer> <template #footer>
<div class="drawer-footer"> <div class="drawer-footer">
<Button :disabled="props.isSaving" @click="emit('update:open', false)"> <Button
:disabled="props.isSaving || !props.canOperate"
@click="emit('update:open', false)"
>
取消 取消
</Button> </Button>
<Button <Button
type="primary" type="primary"
:loading="props.isSaving" :loading="props.isSaving"
:disabled="!props.canOperate"
@click="emit('submit')" @click="emit('submit')"
> >
{{ props.form.id ? '保存修改' : '新增并保存' }} {{ props.form.id ? '保存修改' : '新增并保存' }}

View File

@@ -1,8 +1,4 @@
import type { import type { PackagingFeeMode } from '#/api/store-fees';
PackagingFeeMode,
PackagingFeeTierDto,
StoreFeesSettingsDto,
} from '#/api/store-fees';
/** 文件职责:费用设置页面常量定义。 */ /** 文件职责:费用设置页面常量定义。 */
@@ -15,47 +11,3 @@ export const PACKAGING_MODE_OPTIONS: Array<{
{ label: '按订单收取', value: 'order' }, { label: '按订单收取', value: 'order' },
{ label: '按商品收取', value: 'item' }, { label: '按商品收取', value: 'item' },
]; ];
export const DEFAULT_PACKAGING_TIERS: PackagingFeeTierDto[] = [
{
id: 'packaging-tier-1',
minAmount: 0,
maxAmount: 30,
fee: 2,
sort: 1,
},
{
id: 'packaging-tier-2',
minAmount: 30,
maxAmount: 60,
fee: 3,
sort: 2,
},
{
id: 'packaging-tier-3',
minAmount: 60,
maxAmount: null,
fee: 5,
sort: 3,
},
];
export const DEFAULT_FEES_SETTINGS: Omit<StoreFeesSettingsDto, 'storeId'> = {
minimumOrderAmount: 15,
baseDeliveryFee: 3,
freeDeliveryThreshold: 30,
packagingFeeMode: 'order',
orderPackagingFeeMode: 'tiered',
fixedPackagingFee: 2,
packagingFeeTiers: DEFAULT_PACKAGING_TIERS,
otherFees: {
cutlery: {
enabled: false,
amount: 1,
},
rush: {
enabled: false,
amount: 3,
},
},
};

View File

@@ -6,7 +6,10 @@ import type { StoreListItemDto } from '#/api/store';
* 1. 加载门店列表与门店费用配置。 * 1. 加载门店列表与门店费用配置。
* 2. 保存费用配置并维护快照。 * 2. 保存费用配置并维护快照。
*/ */
import type { StoreFeesSettingsDto } from '#/api/store-fees'; import type {
SaveStoreFeesSettingsParams,
StoreFeesSettingsDto,
} from '#/api/store-fees';
import type { import type {
StoreFeesFormState, StoreFeesFormState,
StoreFeesSettingsSnapshot, StoreFeesSettingsSnapshot,
@@ -20,7 +23,6 @@ import {
saveStoreFeesSettingsApi, saveStoreFeesSettingsApi,
} from '#/api/store-fees'; } from '#/api/store-fees';
import { DEFAULT_FEES_SETTINGS, DEFAULT_PACKAGING_TIERS } from './constants';
import { import {
cloneOtherFees, cloneOtherFees,
cloneTiers, cloneTiers,
@@ -30,14 +32,28 @@ import {
} from './helpers'; } from './helpers';
interface CreateDataActionsOptions { interface CreateDataActionsOptions {
clearSettings: () => void;
form: StoreFeesFormState; form: StoreFeesFormState;
isConfigured: Ref<boolean>;
isPageLoading: Ref<boolean>; isPageLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>; isStoreLoading: Ref<boolean>;
loadedStoreId: Ref<string>;
selectedStoreId: Ref<string>; selectedStoreId: Ref<string>;
snapshot: Ref<null | StoreFeesSettingsSnapshot>; snapshot: Ref<null | StoreFeesSettingsSnapshot>;
stores: Ref<StoreListItemDto[]>; stores: Ref<StoreListItemDto[]>;
} }
const EMPTY_OTHER_FEES = {
cutlery: {
enabled: false,
amount: 0,
},
rush: {
enabled: false,
amount: 0,
},
};
export function createDataActions(options: CreateDataActionsOptions) { export function createDataActions(options: CreateDataActionsOptions) {
/** 同步页面表单,保持 reactive 引用不变。 */ /** 同步页面表单,保持 reactive 引用不变。 */
function syncForm(next: StoreFeesFormState) { function syncForm(next: StoreFeesFormState) {
@@ -64,65 +80,52 @@ export function createDataActions(options: CreateDataActionsOptions) {
return createSettingsSnapshot(options.form); return createSettingsSnapshot(options.form);
} }
/** 应用默认配置。 */
function applyDefaultSettings() {
syncForm({
...DEFAULT_FEES_SETTINGS,
packagingFeeTiers: cloneTiers(DEFAULT_PACKAGING_TIERS),
otherFees: cloneOtherFees(DEFAULT_FEES_SETTINGS.otherFees),
});
}
/** 将接口返回值转为页面表单态。 */ /** 将接口返回值转为页面表单态。 */
function normalizeSettings( function normalizeSettings(
source: null | Partial<StoreFeesSettingsDto> | undefined, source: null | Partial<StoreFeesSettingsDto> | undefined,
): StoreFeesFormState { ): StoreFeesFormState {
const packagingFeeMode =
source?.packagingFeeMode === 'item' ||
source?.packagingFeeMode === 'order'
? source.packagingFeeMode
: 'order';
const orderPackagingFeeMode =
source?.orderPackagingFeeMode === 'fixed' ||
source?.orderPackagingFeeMode === 'tiered'
? source.orderPackagingFeeMode
: 'fixed';
const otherFees = cloneOtherFees({
cutlery: {
enabled: Boolean(source?.otherFees?.cutlery?.enabled),
amount: normalizeMoney(source?.otherFees?.cutlery?.amount ?? 0, 0),
},
rush: {
enabled: Boolean(source?.otherFees?.rush?.enabled),
amount: normalizeMoney(source?.otherFees?.rush?.amount ?? 0, 0),
},
});
return { return {
minimumOrderAmount: normalizeMoney( minimumOrderAmount: normalizeMoney(source?.minimumOrderAmount ?? 0, 0),
source?.minimumOrderAmount ?? DEFAULT_FEES_SETTINGS.minimumOrderAmount, baseDeliveryFee: normalizeMoney(source?.baseDeliveryFee ?? 0, 0),
DEFAULT_FEES_SETTINGS.minimumOrderAmount,
),
baseDeliveryFee: normalizeMoney(
source?.baseDeliveryFee ?? DEFAULT_FEES_SETTINGS.baseDeliveryFee,
DEFAULT_FEES_SETTINGS.baseDeliveryFee,
),
freeDeliveryThreshold: freeDeliveryThreshold:
source?.freeDeliveryThreshold === null || source?.freeDeliveryThreshold === null ||
source?.freeDeliveryThreshold === undefined source?.freeDeliveryThreshold === undefined
? null ? null
: normalizeMoney( : normalizeMoney(source.freeDeliveryThreshold, 0),
source.freeDeliveryThreshold, packagingFeeMode,
DEFAULT_FEES_SETTINGS.freeDeliveryThreshold ?? 0, orderPackagingFeeMode,
), fixedPackagingFee: normalizeMoney(source?.fixedPackagingFee ?? 0, 0),
packagingFeeMode: packagingFeeTiers: sortTiers(cloneTiers(source?.packagingFeeTiers ?? [])),
source?.packagingFeeMode === 'item' || otherFees: source?.otherFees
source?.packagingFeeMode === 'order' ? otherFees
? source.packagingFeeMode : cloneOtherFees(EMPTY_OTHER_FEES),
: DEFAULT_FEES_SETTINGS.packagingFeeMode,
orderPackagingFeeMode:
source?.orderPackagingFeeMode === 'fixed' ||
source?.orderPackagingFeeMode === 'tiered'
? source.orderPackagingFeeMode
: DEFAULT_FEES_SETTINGS.orderPackagingFeeMode,
fixedPackagingFee: normalizeMoney(
source?.fixedPackagingFee ?? DEFAULT_FEES_SETTINGS.fixedPackagingFee,
DEFAULT_FEES_SETTINGS.fixedPackagingFee,
),
packagingFeeTiers: sortTiers(
cloneTiers(
source?.packagingFeeTiers?.length
? source.packagingFeeTiers
: DEFAULT_PACKAGING_TIERS,
),
),
otherFees: cloneOtherFees(
source?.otherFees ?? DEFAULT_FEES_SETTINGS.otherFees,
),
}; };
} }
/** 按当前门店构建保存参数。 */ /** 按当前门店构建保存参数。 */
function buildSavePayload(storeId: string): StoreFeesSettingsDto { function buildSavePayload(storeId: string): SaveStoreFeesSettingsParams {
return { return {
storeId, storeId,
minimumOrderAmount: options.form.minimumOrderAmount, minimumOrderAmount: options.form.minimumOrderAmount,
@@ -144,12 +147,25 @@ export function createDataActions(options: CreateDataActionsOptions) {
const result = await getStoreFeesSettingsApi(storeId); const result = await getStoreFeesSettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return; if (options.selectedStoreId.value !== currentStoreId) return;
if (!result.isConfigured) {
options.clearSettings();
options.isConfigured.value = false;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot();
return;
}
syncForm(normalizeSettings(result)); syncForm(normalizeSettings(result));
options.isConfigured.value = true;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot(); options.snapshot.value = buildCurrentSnapshot();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
applyDefaultSettings(); options.isConfigured.value = false;
options.snapshot.value = buildCurrentSnapshot(); options.loadedStoreId.value = '';
options.snapshot.value = null;
options.clearSettings();
message.error('加载费用设置失败,请稍后重试');
} finally { } finally {
options.isPageLoading.value = false; options.isPageLoading.value = false;
} }
@@ -171,8 +187,9 @@ export function createDataActions(options: CreateDataActionsOptions) {
if (options.stores.value.length === 0) { if (options.stores.value.length === 0) {
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
options.isConfigured.value = false;
options.snapshot.value = null; options.snapshot.value = null;
applyDefaultSettings(); options.clearSettings();
return; return;
} }
@@ -189,10 +206,12 @@ export function createDataActions(options: CreateDataActionsOptions) {
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error('加载门店失败,请稍后重试');
options.stores.value = []; options.stores.value = [];
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
options.isConfigured.value = false;
options.snapshot.value = null; options.snapshot.value = null;
applyDefaultSettings(); options.clearSettings();
} finally { } finally {
options.isStoreLoading.value = false; options.isStoreLoading.value = false;
} }
@@ -205,6 +224,7 @@ export function createDataActions(options: CreateDataActionsOptions) {
const payload = buildSavePayload(options.selectedStoreId.value); const payload = buildSavePayload(options.selectedStoreId.value);
const result = await saveStoreFeesSettingsApi(payload); const result = await saveStoreFeesSettingsApi(payload);
syncForm(normalizeSettings(result ?? payload)); syncForm(normalizeSettings(result ?? payload));
options.isConfigured.value = true;
options.snapshot.value = buildCurrentSnapshot(); options.snapshot.value = buildCurrentSnapshot();
message.success(successText); message.success(successText);
return true; return true;
@@ -218,7 +238,7 @@ export function createDataActions(options: CreateDataActionsOptions) {
/** 重置到最近一次快照。 */ /** 重置到最近一次快照。 */
function resetFromSnapshot() { function resetFromSnapshot() {
if (!options.snapshot.value) { if (!options.snapshot.value) {
applyDefaultSettings(); message.warning('暂无可恢复的已保存配置');
return; return;
} }
syncForm(options.snapshot.value); syncForm(options.snapshot.value);

View File

@@ -9,7 +9,6 @@ import type {
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { DEFAULT_PACKAGING_TIERS } from './constants';
import { import {
cloneTiers, cloneTiers,
createTierId, createTierId,
@@ -51,7 +50,7 @@ export function createPackagingActions(options: CreatePackagingActionsOptions) {
options.tierForm.id = ''; options.tierForm.id = '';
options.tierForm.minAmount = defaultMin; options.tierForm.minAmount = defaultMin;
options.tierForm.maxAmount = null; options.tierForm.maxAmount = null;
options.tierForm.fee = normalizeMoney(lastTier?.fee ?? 2, 2); options.tierForm.fee = normalizeMoney(lastTier?.fee ?? 0, 0);
options.isTierDrawerOpen.value = true; options.isTierDrawerOpen.value = true;
} }
@@ -96,9 +95,6 @@ export function createPackagingActions(options: CreatePackagingActionsOptions) {
/** 切换是否启用阶梯包装费。 */ /** 切换是否启用阶梯包装费。 */
function toggleTiered(checked: boolean) { function toggleTiered(checked: boolean) {
options.form.orderPackagingFeeMode = checked ? 'tiered' : 'fixed'; options.form.orderPackagingFeeMode = checked ? 'tiered' : 'fixed';
if (checked && options.form.packagingFeeTiers.length === 0) {
options.form.packagingFeeTiers = cloneTiers(DEFAULT_PACKAGING_TIERS);
}
} }
/** 删除阶梯。 */ /** 删除阶梯。 */

View File

@@ -15,12 +15,9 @@ import type {
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import { import { PACKAGING_MODE_OPTIONS } from './fees-page/constants';
DEFAULT_FEES_SETTINGS,
PACKAGING_MODE_OPTIONS,
} from './fees-page/constants';
import { createCopyActions } from './fees-page/copy-actions'; import { createCopyActions } from './fees-page/copy-actions';
import { createDataActions } from './fees-page/data-actions'; import { createDataActions } from './fees-page/data-actions';
import { import {
@@ -33,6 +30,28 @@ import {
} from './fees-page/helpers'; } from './fees-page/helpers';
import { createPackagingActions } from './fees-page/packaging-actions'; import { createPackagingActions } from './fees-page/packaging-actions';
const EMPTY_FEES_SETTINGS: StoreFeesFormState = {
minimumOrderAmount: 0,
baseDeliveryFee: 0,
freeDeliveryThreshold: null,
packagingFeeMode: 'order',
orderPackagingFeeMode: 'fixed',
fixedPackagingFee: 0,
packagingFeeTiers: [],
otherFees: {
cutlery: {
enabled: false,
amount: 0,
},
rush: {
enabled: false,
amount: 0,
},
},
};
const PACKAGING_MODE_SWITCH_CONFIRM_KEY =
'store-fees-packaging-mode-switch-confirmed';
export function useStoreFeesPage() { export function useStoreFeesPage() {
// 1. 页面 loading / submitting 状态。 // 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false); const isStoreLoading = ref(false);
@@ -41,13 +60,13 @@ export function useStoreFeesPage() {
const isSavingPackaging = ref(false); const isSavingPackaging = ref(false);
const isSavingOther = ref(false); const isSavingOther = ref(false);
const isCopySubmitting = ref(false); const isCopySubmitting = ref(false);
const isConfigured = ref(false);
// 2. 页面核心业务数据。 // 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]); const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref(''); const selectedStoreId = ref('');
const form = reactive<StoreFeesFormState>( const loadedStoreId = ref('');
cloneFeesForm(DEFAULT_FEES_SETTINGS), const form = reactive<StoreFeesFormState>(cloneFeesForm(EMPTY_FEES_SETTINGS));
);
const snapshot = ref<null | StoreFeesSettingsSnapshot>(null); const snapshot = ref<null | StoreFeesSettingsSnapshot>(null);
// 3. 复制弹窗状态。 // 3. 复制弹窗状态。
@@ -61,7 +80,7 @@ export function useStoreFeesPage() {
id: '', id: '',
minAmount: 0, minAmount: 0,
maxAmount: null, maxAmount: null,
fee: 2, fee: 0,
}); });
// 5. 页面衍生视图数据。 // 5. 页面衍生视图数据。
@@ -74,6 +93,19 @@ export function useStoreFeesPage() {
stores.value.find((store) => store.id === selectedStoreId.value)?.name ?? stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'', '',
); );
const hasSelectedStore = computed(() => Boolean(selectedStoreId.value));
const hasLoadedStoreSettings = computed(() => snapshot.value !== null);
const canOperate = computed(
() =>
hasSelectedStore.value &&
loadedStoreId.value === selectedStoreId.value &&
hasLoadedStoreSettings.value &&
!isStoreLoading.value &&
!isPageLoading.value &&
!isSavingDelivery.value &&
!isSavingPackaging.value &&
!isSavingOther.value,
);
const copyCandidates = computed(() => const copyCandidates = computed(() =>
stores.value.filter((store) => store.id !== selectedStoreId.value), stores.value.filter((store) => store.id !== selectedStoreId.value),
@@ -97,6 +129,37 @@ export function useStoreFeesPage() {
const isOrderMode = computed(() => form.packagingFeeMode === 'order'); 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 '切换后订单包装费将汇总商品维度配置,本页固定/阶梯包装费将暂不生效。';
}
function clearSettings() {
loadedStoreId.value = '';
Object.assign(form, cloneFeesForm(EMPTY_FEES_SETTINGS));
isTierDrawerOpen.value = false;
}
// 6. 动作装配。 // 6. 动作装配。
const { const {
loadStoreSettings, loadStoreSettings,
@@ -104,9 +167,12 @@ export function useStoreFeesPage() {
resetFromSnapshot, resetFromSnapshot,
saveCurrentSettings, saveCurrentSettings,
} = createDataActions({ } = createDataActions({
clearSettings,
form, form,
isConfigured,
isPageLoading, isPageLoading,
isStoreLoading, isStoreLoading,
loadedStoreId,
selectedStoreId, selectedStoreId,
snapshot, snapshot,
stores, stores,
@@ -169,7 +235,25 @@ export function useStoreFeesPage() {
} }
function setPackagingMode(value: PackagingFeeMode) { function setPackagingMode(value: PackagingFeeMode) {
setPackagingFeeMode(value); if (!canOperate.value) return;
if (value === form.packagingFeeMode) return;
const applyMode = () => setPackagingFeeMode(value);
if (hasConfirmedPackagingModeSwitch()) {
applyMode();
return;
}
Modal.confirm({
title: '确认切换包装费收取方式?',
content: getPackagingModeConfirmContent(value),
okText: '确认切换',
cancelText: '取消',
onOk() {
applyMode();
markPackagingModeSwitchConfirmed();
},
});
} }
function setCutleryEnabled(value: boolean) { function setCutleryEnabled(value: boolean) {
@@ -196,7 +280,11 @@ export function useStoreFeesPage() {
/** 重置“起送与配送费”分区。 */ /** 重置“起送与配送费”分区。 */
function resetDeliverySection() { function resetDeliverySection() {
const source = snapshot.value ?? cloneFeesForm(DEFAULT_FEES_SETTINGS); if (!snapshot.value) {
message.warning('暂无可恢复的已保存配置');
return;
}
const source = snapshot.value;
form.minimumOrderAmount = source.minimumOrderAmount; form.minimumOrderAmount = source.minimumOrderAmount;
form.baseDeliveryFee = source.baseDeliveryFee; form.baseDeliveryFee = source.baseDeliveryFee;
form.freeDeliveryThreshold = source.freeDeliveryThreshold; form.freeDeliveryThreshold = source.freeDeliveryThreshold;
@@ -205,7 +293,11 @@ export function useStoreFeesPage() {
/** 重置“包装费设置”分区。 */ /** 重置“包装费设置”分区。 */
function resetPackagingSection() { function resetPackagingSection() {
const source = snapshot.value ?? cloneFeesForm(DEFAULT_FEES_SETTINGS); if (!snapshot.value) {
message.warning('暂无可恢复的已保存配置');
return;
}
const source = snapshot.value;
form.packagingFeeMode = source.packagingFeeMode; form.packagingFeeMode = source.packagingFeeMode;
form.orderPackagingFeeMode = source.orderPackagingFeeMode; form.orderPackagingFeeMode = source.orderPackagingFeeMode;
form.fixedPackagingFee = source.fixedPackagingFee; form.fixedPackagingFee = source.fixedPackagingFee;
@@ -215,14 +307,18 @@ export function useStoreFeesPage() {
/** 重置“其他费用”分区。 */ /** 重置“其他费用”分区。 */
function resetOtherSection() { function resetOtherSection() {
const source = snapshot.value ?? cloneFeesForm(DEFAULT_FEES_SETTINGS); if (!snapshot.value) {
message.warning('暂无可恢复的已保存配置');
return;
}
const source = snapshot.value;
form.otherFees = cloneOtherFees(source.otherFees); form.otherFees = cloneOtherFees(source.otherFees);
message.success('已重置其他费用'); message.success('已重置其他费用');
} }
/** 保存“起送与配送费”分区。 */ /** 保存“起送与配送费”分区。 */
async function saveDeliverySection() { async function saveDeliverySection() {
if (!selectedStoreId.value) return; if (!canOperate.value) return;
isSavingDelivery.value = true; isSavingDelivery.value = true;
try { try {
await saveCurrentSettings('起送与配送费已保存'); await saveCurrentSettings('起送与配送费已保存');
@@ -233,7 +329,7 @@ export function useStoreFeesPage() {
/** 保存“包装费设置”分区。 */ /** 保存“包装费设置”分区。 */
async function savePackagingSection() { async function savePackagingSection() {
if (!selectedStoreId.value) return; if (!canOperate.value) return;
if (!validateCurrentPackaging()) return; if (!validateCurrentPackaging()) return;
isSavingPackaging.value = true; isSavingPackaging.value = true;
try { try {
@@ -245,7 +341,7 @@ export function useStoreFeesPage() {
/** 保存“其他费用”分区。 */ /** 保存“其他费用”分区。 */
async function saveOtherSection() { async function saveOtherSection() {
if (!selectedStoreId.value) return; if (!canOperate.value) return;
isSavingOther.value = true; isSavingOther.value = true;
try { try {
await saveCurrentSettings('其他费用已保存'); await saveCurrentSettings('其他费用已保存');
@@ -274,11 +370,14 @@ export function useStoreFeesPage() {
/** 切换门店时同步拉取配置。 */ /** 切换门店时同步拉取配置。 */
watch(selectedStoreId, async (storeId) => { watch(selectedStoreId, async (storeId) => {
if (!storeId) { if (!storeId) {
Object.assign(form, cloneFeesForm(DEFAULT_FEES_SETTINGS)); clearSettings();
isConfigured.value = false;
snapshot.value = null; snapshot.value = null;
isTierDrawerOpen.value = false;
return; return;
} }
loadedStoreId.value = '';
isConfigured.value = false;
isTierDrawerOpen.value = false;
await loadStoreSettings(storeId); await loadStoreSettings(storeId);
}); });
@@ -291,6 +390,7 @@ export function useStoreFeesPage() {
PACKAGING_MODE_OPTIONS, PACKAGING_MODE_OPTIONS,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
canOperate,
form, form,
formatCurrency, formatCurrency,
formatTierRange, formatTierRange,
@@ -301,6 +401,7 @@ export function useStoreFeesPage() {
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
isOrderMode, isOrderMode,
isPageLoading, isPageLoading,
isSavingDelivery, isSavingDelivery,
@@ -308,6 +409,7 @@ export function useStoreFeesPage() {
isSavingPackaging, isSavingPackaging,
isStoreLoading, isStoreLoading,
isTierDrawerOpen, isTierDrawerOpen,
loadedStoreId,
onDeleteTier, onDeleteTier,
openCopyModal, openCopyModal,
openTierDrawer, openTierDrawer,
@@ -318,6 +420,8 @@ export function useStoreFeesPage() {
saveDeliverySection, saveDeliverySection,
saveOtherSection, saveOtherSection,
savePackagingSection, savePackagingSection,
hasLoadedStoreSettings,
hasSelectedStore,
selectedStoreId, selectedStoreId,
selectedStoreName, selectedStoreName,
setBaseDeliveryFee, setBaseDeliveryFee,

View File

@@ -19,6 +19,7 @@ import FeesTierDrawer from './components/FeesTierDrawer.vue';
import { useStoreFeesPage } from './composables/useStoreFeesPage'; import { useStoreFeesPage } from './composables/useStoreFeesPage';
const { const {
canOperate,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
form, form,
@@ -27,10 +28,12 @@ const {
handleCopyCheckAll, handleCopyCheckAll,
handleCopySubmit, handleCopySubmit,
handleSubmitTier, handleSubmitTier,
hasLoadedStoreSettings,
isCopyAllChecked, isCopyAllChecked,
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
isPageLoading, isPageLoading,
isSavingDelivery, isSavingDelivery,
isSavingOther, isSavingOther,
@@ -81,7 +84,9 @@ function onEditTier(tier: PackagingFeeTierDto) {
:selected-store-id="selectedStoreId" :selected-store-id="selectedStoreId"
:store-options="storeOptions" :store-options="storeOptions"
:is-store-loading="isStoreLoading" :is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0" :copy-disabled="
!canOperate || !isConfigured || copyCandidates.length === 0
"
copy-button-text="复制费用设置到其他门店" copy-button-text="复制费用设置到其他门店"
@update:selected-store-id="setSelectedStoreId" @update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal" @copy="openCopyModal"
@@ -95,7 +100,14 @@ function onEditTier(tier: PackagingFeeTierDto) {
<template v-else> <template v-else>
<Spin :spinning="isPageLoading"> <Spin :spinning="isPageLoading">
<Card v-if="hasLoadedStoreSettings && !isConfigured" :bordered="false">
<Empty
description="当前门店尚未配置费用规则。请先填写并保存,之后可执行复制。"
/>
</Card>
<FeesDeliveryCard <FeesDeliveryCard
:can-operate="canOperate"
:minimum-order-amount="form.minimumOrderAmount" :minimum-order-amount="form.minimumOrderAmount"
:base-delivery-fee="form.baseDeliveryFee" :base-delivery-fee="form.baseDeliveryFee"
:free-delivery-threshold="form.freeDeliveryThreshold" :free-delivery-threshold="form.freeDeliveryThreshold"
@@ -108,6 +120,7 @@ function onEditTier(tier: PackagingFeeTierDto) {
/> />
<FeesPackagingCard <FeesPackagingCard
:can-operate="canOperate"
:packaging-mode="form.packagingFeeMode" :packaging-mode="form.packagingFeeMode"
:tiered-enabled="form.orderPackagingFeeMode === 'tiered'" :tiered-enabled="form.orderPackagingFeeMode === 'tiered'"
:fixed-packaging-fee="form.fixedPackagingFee" :fixed-packaging-fee="form.fixedPackagingFee"
@@ -126,6 +139,7 @@ function onEditTier(tier: PackagingFeeTierDto) {
/> />
<FeesOtherCard <FeesOtherCard
:can-operate="canOperate"
:cutlery-enabled="form.otherFees.cutlery.enabled" :cutlery-enabled="form.otherFees.cutlery.enabled"
:cutlery-amount="form.otherFees.cutlery.amount" :cutlery-amount="form.otherFees.cutlery.amount"
:rush-enabled="form.otherFees.rush.enabled" :rush-enabled="form.otherFees.rush.enabled"
@@ -142,6 +156,7 @@ function onEditTier(tier: PackagingFeeTierDto) {
</template> </template>
<FeesTierDrawer <FeesTierDrawer
:can-operate="canOperate"
:open="isTierDrawerOpen" :open="isTierDrawerOpen"
:title="tierDrawerTitle" :title="tierDrawerTitle"
:form="tierForm" :form="tierForm"

View File

@@ -1,30 +1,58 @@
/* 文件职责:包装费卡片样式。 */ /* 文件职责:包装费卡片样式。 */
.page-store-fees { .page-store-fees {
.packaging-mode-switch { .packaging-mode-toggle-row {
display: inline-flex; display: inline-flex;
gap: 2px; gap: 10px;
padding: 3px; align-items: center;
margin-bottom: 16px; margin-bottom: 12px;
background: #f8f9fb;
border-radius: 8px;
} }
.mode-switch-item { .mode-toggle-label {
padding: 6px 18px; padding: 0;
font-size: 13px; font-size: 13px;
color: #4b5563; color: #4b5563;
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
border: none; border: none;
border-radius: 6px; transition: color 0.2s ease;
transition: all 0.2s ease;
} }
.mode-switch-item.active { .mode-toggle-label.active {
font-weight: 600; font-weight: 600;
color: #1677ff; color: #1677ff;
background: #fff; }
box-shadow: 0 1px 3px rgb(15 23 42 / 10%);
.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;
font-weight: 600;
color: #1f2937;
}
.packaging-mode-guide .guide-desc {
font-size: 12px;
color: #4b5563;
} }
.packaging-tier-block { .packaging-tier-block {

View File

@@ -23,13 +23,13 @@
width: 100%; width: 100%;
} }
.packaging-mode-switch { .packaging-mode-toggle-row {
display: flex; display: flex;
justify-content: space-between;
width: 100%; width: 100%;
} }
.mode-switch-item { .mode-toggle-label {
flex: 1;
text-align: center; text-align: center;
} }
} }

View File

@@ -43,16 +43,16 @@ export const FINE_INTERVAL_OPTIONS: Array<{ label: string; value: number }> = [
]; ];
export const DEFAULT_PICKUP_BASIC_SETTINGS: PickupBasicSettingsDto = { export const DEFAULT_PICKUP_BASIC_SETTINGS: PickupBasicSettingsDto = {
allowSameDayPickup: true, allowSameDayPickup: false,
bookingDays: 3, bookingDays: 1,
maxItemsPerOrder: 20, maxItemsPerOrder: null,
}; };
export const DEFAULT_FINE_RULE: PickupFineRuleDto = { export const DEFAULT_FINE_RULE: PickupFineRuleDto = {
intervalMinutes: 30, intervalMinutes: 30,
slotCapacity: 5, slotCapacity: 1,
dayStartTime: '09:00', dayStartTime: '',
dayEndTime: '20:30', dayEndTime: '',
minAdvanceHours: 2, minAdvanceHours: 0,
dayOfWeeks: [...ALL_WEEK_DAYS], dayOfWeeks: [],
}; };

View File

@@ -42,12 +42,15 @@ import {
interface CreateDataActionsOptions { interface CreateDataActionsOptions {
basicSettings: PickupBasicSettingsDto; basicSettings: PickupBasicSettingsDto;
bigSlots: Ref<PickupSlotDto[]>; bigSlots: Ref<PickupSlotDto[]>;
clearSettings: () => void;
fineRule: PickupFineRuleDto; fineRule: PickupFineRuleDto;
isConfigured: Ref<boolean>;
isPageLoading: Ref<boolean>; isPageLoading: Ref<boolean>;
isSavingBasic: Ref<boolean>; isSavingBasic: Ref<boolean>;
isSavingFineRule: Ref<boolean>; isSavingFineRule: Ref<boolean>;
isSavingSlots: Ref<boolean>; isSavingSlots: Ref<boolean>;
isStoreLoading: Ref<boolean>; isStoreLoading: Ref<boolean>;
loadedStoreId: Ref<string>;
mode: Ref<PickupMode>; mode: Ref<PickupMode>;
previewDays: Ref<PickupPreviewDayDto[]>; previewDays: Ref<PickupPreviewDayDto[]>;
selectedStoreId: Ref<string>; selectedStoreId: Ref<string>;
@@ -84,15 +87,6 @@ export function createDataActions(options: CreateDataActionsOptions) {
}); });
} }
/** 应用默认配置(接口异常兜底)。 */
function applyDefaultSettings() {
options.mode.value = DEFAULT_PICKUP_MODE;
syncBasicSettings(cloneBasicSettings(DEFAULT_PICKUP_BASIC_SETTINGS));
options.bigSlots.value = [];
syncFineRule(cloneFineRule(DEFAULT_FINE_RULE));
options.previewDays.value = [];
}
/** 应用快照到当前页面状态。 */ /** 应用快照到当前页面状态。 */
function applySnapshot(snapshot: PickupSettingsSnapshot) { function applySnapshot(snapshot: PickupSettingsSnapshot) {
options.mode.value = snapshot.mode; options.mode.value = snapshot.mode;
@@ -110,28 +104,43 @@ export function createDataActions(options: CreateDataActionsOptions) {
const result = await getStorePickupSettingsApi(storeId); const result = await getStorePickupSettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return; if (options.selectedStoreId.value !== currentStoreId) return;
if (!result.isConfigured) {
options.clearSettings();
options.isConfigured.value = false;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot();
return;
}
options.mode.value = result.mode ?? DEFAULT_PICKUP_MODE; options.mode.value = result.mode ?? DEFAULT_PICKUP_MODE;
syncBasicSettings({ syncBasicSettings(
...DEFAULT_PICKUP_BASIC_SETTINGS, result.basicSettings
...result.basicSettings, ? cloneBasicSettings(result.basicSettings)
}); : cloneBasicSettings(DEFAULT_PICKUP_BASIC_SETTINGS),
);
options.bigSlots.value = sortSlots( options.bigSlots.value = sortSlots(
result.bigSlots?.length ? result.bigSlots : [], result.bigSlots?.length ? result.bigSlots : [],
); );
syncFineRule({ syncFineRule(
...DEFAULT_FINE_RULE, result.fineRule
...result.fineRule, ? cloneFineRule(result.fineRule)
}); : cloneFineRule(DEFAULT_FINE_RULE),
);
options.previewDays.value = options.previewDays.value =
result.previewDays?.length > 0 result.previewDays?.length > 0
? clonePreviewDays(result.previewDays) ? clonePreviewDays(result.previewDays)
: []; : [];
options.isConfigured.value = true;
options.loadedStoreId.value = currentStoreId;
options.snapshot.value = buildCurrentSnapshot(); options.snapshot.value = buildCurrentSnapshot();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
applyDefaultSettings(); options.clearSettings();
options.snapshot.value = buildCurrentSnapshot(); options.isConfigured.value = false;
options.loadedStoreId.value = '';
options.snapshot.value = null;
message.error('加载自提设置失败,请稍后重试');
} finally { } finally {
options.isPageLoading.value = false; options.isPageLoading.value = false;
} }
@@ -153,8 +162,10 @@ export function createDataActions(options: CreateDataActionsOptions) {
if (options.stores.value.length === 0) { if (options.stores.value.length === 0) {
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
options.loadedStoreId.value = '';
options.isConfigured.value = false;
options.snapshot.value = null; options.snapshot.value = null;
applyDefaultSettings(); options.clearSettings();
return; return;
} }
@@ -173,8 +184,11 @@ export function createDataActions(options: CreateDataActionsOptions) {
console.error(error); console.error(error);
options.stores.value = []; options.stores.value = [];
options.selectedStoreId.value = ''; options.selectedStoreId.value = '';
options.loadedStoreId.value = '';
options.isConfigured.value = false;
options.snapshot.value = null; options.snapshot.value = null;
applyDefaultSettings(); options.clearSettings();
message.error('加载门店失败,请稍后重试');
} finally { } finally {
options.isStoreLoading.value = false; options.isStoreLoading.value = false;
} }
@@ -243,7 +257,7 @@ export function createDataActions(options: CreateDataActionsOptions) {
/** 重置到最近一次快照。 */ /** 重置到最近一次快照。 */
function resetFromSnapshot() { function resetFromSnapshot() {
if (!options.snapshot.value) { if (!options.snapshot.value) {
applyDefaultSettings(); options.clearSettings();
return; return;
} }
applySnapshot(options.snapshot.value); applySnapshot(options.snapshot.value);

View File

@@ -48,6 +48,8 @@ export function useStorePickupPage() {
const isSavingSlots = ref(false); const isSavingSlots = ref(false);
const isSavingFineRule = ref(false); const isSavingFineRule = ref(false);
const isCopySubmitting = ref(false); const isCopySubmitting = ref(false);
const isConfigured = ref(false);
const loadedStoreId = ref('');
// 2. 页面核心业务数据。 // 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]); const stores = ref<StoreListItemDto[]>([]);
@@ -119,6 +121,34 @@ export function useStorePickupPage() {
? `编辑时段 - ${slotForm.name}` ? `编辑时段 - ${slotForm.name}`
: '添加时段', : '添加时段',
); );
const hasSelectedStore = computed(() => Boolean(selectedStoreId.value));
const hasLoadedStoreSettings = computed(
() =>
loadedStoreId.value === selectedStoreId.value && snapshot.value !== null,
);
const canOperate = computed(
() =>
hasSelectedStore.value &&
loadedStoreId.value === selectedStoreId.value &&
!isStoreLoading.value &&
!isPageLoading.value &&
!isSavingBasic.value &&
!isSavingSlots.value &&
!isSavingFineRule.value,
);
function clearSettings() {
pickupMode.value = DEFAULT_PICKUP_MODE;
basicSettings.allowSameDayPickup =
DEFAULT_PICKUP_BASIC_SETTINGS.allowSameDayPickup;
basicSettings.bookingDays = DEFAULT_PICKUP_BASIC_SETTINGS.bookingDays;
basicSettings.maxItemsPerOrder =
DEFAULT_PICKUP_BASIC_SETTINGS.maxItemsPerOrder;
bigSlots.value = [];
Object.assign(fineRule, cloneFineRule(DEFAULT_FINE_RULE));
previewDays.value = [];
selectedPreviewDate.value = '';
}
// 6. 数据域动作装配。 // 6. 数据域动作装配。
const { const {
@@ -131,12 +161,15 @@ export function useStorePickupPage() {
} = createDataActions({ } = createDataActions({
basicSettings, basicSettings,
bigSlots, bigSlots,
clearSettings,
fineRule, fineRule,
isConfigured,
isPageLoading, isPageLoading,
isSavingBasic, isSavingBasic,
isSavingFineRule, isSavingFineRule,
isSavingSlots, isSavingSlots,
isStoreLoading, isStoreLoading,
loadedStoreId,
mode: pickupMode, mode: pickupMode,
previewDays, previewDays,
selectedStoreId, selectedStoreId,
@@ -204,6 +237,7 @@ export function useStorePickupPage() {
} }
function setPickupMode(value: 'big' | 'fine') { function setPickupMode(value: 'big' | 'fine') {
if (!canOperate.value) return;
pickupMode.value = value; pickupMode.value = value;
} }
@@ -255,19 +289,16 @@ export function useStorePickupPage() {
// 8. 门店切换时自动刷新配置。 // 8. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => { watch(selectedStoreId, async (storeId) => {
if (!storeId) { if (!storeId) {
pickupMode.value = DEFAULT_PICKUP_MODE; loadedStoreId.value = '';
basicSettings.allowSameDayPickup = isConfigured.value = false;
DEFAULT_PICKUP_BASIC_SETTINGS.allowSameDayPickup; clearSettings();
basicSettings.bookingDays = DEFAULT_PICKUP_BASIC_SETTINGS.bookingDays;
basicSettings.maxItemsPerOrder =
DEFAULT_PICKUP_BASIC_SETTINGS.maxItemsPerOrder;
bigSlots.value = [];
Object.assign(fineRule, cloneFineRule(DEFAULT_FINE_RULE));
previewDays.value = [];
selectedPreviewDate.value = previewDays.value[0]?.date ?? '';
snapshot.value = null; snapshot.value = null;
return; return;
} }
loadedStoreId.value = '';
isConfigured.value = false;
snapshot.value = null;
clearSettings();
await loadStoreSettings(storeId); await loadStoreSettings(storeId);
selectedPreviewDate.value = previewDays.value[0]?.date ?? ''; selectedPreviewDate.value = previewDays.value[0]?.date ?? '';
}); });
@@ -297,6 +328,7 @@ export function useStorePickupPage() {
WEEKDAY_OPTIONS, WEEKDAY_OPTIONS,
basicSettings, basicSettings,
bigSlots, bigSlots,
canOperate,
calcReservedPercent, calcReservedPercent,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
@@ -312,6 +344,9 @@ export function useStorePickupPage() {
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
hasLoadedStoreSettings,
hasSelectedStore,
isFineDaySelected, isFineDaySelected,
isPageLoading, isPageLoading,
isSavingBasic, isSavingBasic,
@@ -322,6 +357,7 @@ export function useStorePickupPage() {
isStoreLoading, isStoreLoading,
openCopyModal, openCopyModal,
openSlotDrawer, openSlotDrawer,
loadedStoreId,
pickupMode, pickupMode,
previewDays, previewDays,
quickSelectFineDays, quickSelectFineDays,

View File

@@ -24,6 +24,7 @@ const {
WEEKDAY_OPTIONS, WEEKDAY_OPTIONS,
basicSettings, basicSettings,
bigSlots, bigSlots,
canOperate,
calcReservedPercent, calcReservedPercent,
copyCandidates, copyCandidates,
copyTargetStoreIds, copyTargetStoreIds,
@@ -39,6 +40,8 @@ const {
isCopyIndeterminate, isCopyIndeterminate,
isCopyModalOpen, isCopyModalOpen,
isCopySubmitting, isCopySubmitting,
isConfigured,
hasLoadedStoreSettings,
isFineDaySelected, isFineDaySelected,
isPageLoading, isPageLoading,
isSavingBasic, isSavingBasic,
@@ -92,7 +95,9 @@ const {
:selected-store-id="selectedStoreId" :selected-store-id="selectedStoreId"
:store-options="storeOptions" :store-options="storeOptions"
:is-store-loading="isStoreLoading" :is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0" :copy-disabled="
!canOperate || !isConfigured || copyCandidates.length === 0
"
@update:selected-store-id="setSelectedStoreId" @update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal" @copy="openCopyModal"
/> />
@@ -105,6 +110,12 @@ const {
<template v-else> <template v-else>
<Spin :spinning="isPageLoading"> <Spin :spinning="isPageLoading">
<Card v-if="hasLoadedStoreSettings && !isConfigured" :bordered="false">
<Empty
description="当前门店尚未配置自提规则。请先完成配置并保存,之后可执行复制。"
/>
</Card>
<PickupBasicSettingsCard <PickupBasicSettingsCard
:settings="basicSettings" :settings="basicSettings"
:is-saving="isSavingBasic" :is-saving="isSavingBasic"

View File

@@ -83,6 +83,30 @@ export function resolveShiftTimeByType(
}; };
} }
/** 按班次类型构建单日排班(总是使用模板时间)。 */
export function buildDayShiftByType(payload: {
dayOfWeek: number;
shiftType: ShiftType;
templates: StoreShiftTemplatesDto;
}): StaffDayShiftDto {
if (payload.shiftType === 'off') {
return {
dayOfWeek: payload.dayOfWeek,
shiftType: 'off',
startTime: '',
endTime: '',
};
}
const template = payload.templates[payload.shiftType];
return {
dayOfWeek: payload.dayOfWeek,
shiftType: payload.shiftType,
startTime: template.startTime,
endTime: template.endTime,
};
}
/** 归一化单日排班。 */ /** 归一化单日排班。 */
export function normalizeDayShift(payload: { export function normalizeDayShift(payload: {
dayOfWeek: number; dayOfWeek: number;
@@ -171,16 +195,9 @@ export function updateWeekRowShift(payload: {
row: WeekEditorRow; row: WeekEditorRow;
templates: StoreShiftTemplatesDto; templates: StoreShiftTemplatesDto;
}) { }) {
const currentShift = payload.row.shifts.find( const nextShift = buildDayShiftByType({
(item) => item.dayOfWeek === payload.dayOfWeek,
);
const nextShift = normalizeDayShift({
dayOfWeek: payload.dayOfWeek,
shift: {
dayOfWeek: payload.dayOfWeek, dayOfWeek: payload.dayOfWeek,
shiftType: payload.nextShiftType, shiftType: payload.nextShiftType,
},
fallback: currentShift,
templates: payload.templates, templates: payload.templates,
}); });

View File

@@ -25,6 +25,7 @@ import {
import { SHIFT_CYCLE } from './constants'; import { SHIFT_CYCLE } from './constants';
import { import {
buildDayShiftByType,
buildWeekEditorRows, buildWeekEditorRows,
cloneScheduleMap, cloneScheduleMap,
cloneShifts, cloneShifts,
@@ -74,16 +75,9 @@ export function createScheduleActions(options: CreateScheduleActionsOptions) {
/** 更新个人排班班次类型。 */ /** 更新个人排班班次类型。 */
function setPersonalShiftType(dayOfWeek: number, shiftType: ShiftType) { function setPersonalShiftType(dayOfWeek: number, shiftType: ShiftType) {
const currentShift = options.personalForm.shifts.find( const nextShift = buildDayShiftByType({
(item) => item.dayOfWeek === dayOfWeek,
);
const nextShift = normalizeDayShift({
dayOfWeek,
shift: {
dayOfWeek, dayOfWeek,
shiftType, shiftType,
},
fallback: currentShift,
templates: options.templates.value, templates: options.templates.value,
}); });
upsertPersonalShift(dayOfWeek, nextShift); upsertPersonalShift(dayOfWeek, nextShift);

View File

@@ -39,11 +39,19 @@
color: #667085; color: #667085;
background: #fafafa; background: #fafafa;
} }
th:first-child {
width: 140px;
padding-left: 16px;
text-align: left;
}
} }
.schedule-staff-cell, .schedule-staff-cell,
.staff-week-name-cell { .staff-week-name-cell {
min-width: 96px; width: 140px;
min-width: 140px;
padding-left: 16px !important;
text-align: left !important; text-align: left !important;
background: #fff; background: #fff;
} }