feat: 完成门店配置拆分并新增配送与自提设置页面

This commit is contained in:
2026-02-16 14:39:11 +08:00
parent 07495f8c35
commit 8d1325edf0
63 changed files with 6827 additions and 368 deletions

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
/**
* 文件职责:通用配送设置区块。
* 1. 展示通用字段输入框。
* 2. 通过回调将字段改动上抛给父级。
*/
import type { DeliveryGeneralSettingsDto } from '#/api/store-delivery';
import { Button, Card, InputNumber } from 'ant-design-vue';
interface Props {
isSaving: boolean;
onSetEtaAdjustmentMinutes: (value: number) => void;
onSetFreeDeliveryThreshold: (value: null | number) => void;
onSetHourlyCapacityLimit: (value: number) => void;
onSetMaxDeliveryDistance: (value: number) => void;
settings: DeliveryGeneralSettingsDto;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'reset'): void;
(event: 'save'): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
</script>
<template>
<Card :bordered="false">
<template #title>
<span class="section-title">通用设置</span>
</template>
<div class="general-grid">
<div class="general-field">
<label>免配送费门槛</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.freeDeliveryThreshold ?? undefined"
:min="0"
:precision="0"
:controls="false"
placeholder="如50"
class="field-input"
@update:value="
(value) =>
props.onSetFreeDeliveryThreshold(
value === null || value === undefined
? null
: toNumber(value, 0),
)
"
/>
<span></span>
</div>
<div class="field-hint">订单满此金额免配送费留空则不启用</div>
</div>
<div class="general-field">
<label>最大配送距离</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.maxDeliveryDistance"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
placeholder="如5"
class="field-input"
@update:value="
(value) => props.onSetMaxDeliveryDistance(toNumber(value, 0))
"
/>
<span>公里</span>
</div>
<div class="field-hint">仅半径模式生效</div>
</div>
<div class="general-field">
<label>每小时配送上限</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.hourlyCapacityLimit"
:min="1"
:precision="0"
:controls="false"
placeholder="如50"
class="field-input"
@update:value="
(value) => props.onSetHourlyCapacityLimit(toNumber(value, 1))
"
/>
<span></span>
</div>
<div class="field-hint">达到上限后暂停接单</div>
</div>
<div class="general-field">
<label>配送时间预估加成</label>
<div class="field-input-row">
<InputNumber
:value="props.settings.etaAdjustmentMinutes"
:min="0"
:precision="0"
:controls="false"
placeholder="如10"
class="field-input"
@update:value="
(value) => props.onSetEtaAdjustmentMinutes(toNumber(value, 0))
"
/>
<span>分钟</span>
</div>
<div class="field-hint">高峰期可适当上调预估送达时间</div>
</div>
</div>
<div class="general-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
保存设置
</Button>
</div>
</Card>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
/**
* 文件职责:配送模式区块。
* 1. 展示配送模式切换按钮。
* 2. 展示地图占位与半径/区域两种预览。
*/
import type { DeliveryMode, RadiusTierDto } from '#/api/store-delivery';
import { computed } from 'vue';
import { Card } from 'ant-design-vue';
interface Props {
mode: DeliveryMode;
modeOptions: Array<{ label: string; value: DeliveryMode }>;
radiusTiers: RadiusTierDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'changeMode', mode: DeliveryMode): void;
}>();
const radiusLabels = computed(() => {
const fallback = ['1km', '3km', '5km'];
const sorted = props.radiusTiers
.toSorted((a, b) => a.maxDistance - b.maxDistance)
.slice(0, 3);
if (sorted.length === 0) return fallback;
const labels = sorted.map((item) => `${item.maxDistance}km`);
while (labels.length < 3) {
labels.push(fallback[labels.length] ?? '5km');
}
return labels;
});
</script>
<template>
<Card :bordered="false" class="delivery-mode-card">
<template #title>
<span class="section-title">配送模式</span>
</template>
<div class="delivery-mode-switch">
<button
v-for="item in props.modeOptions"
:key="item.value"
type="button"
class="mode-switch-item"
:class="{ active: props.mode === item.value }"
@click="emit('changeMode', item.value)"
>
{{ item.label }}
</button>
</div>
<div class="delivery-map-area">
<span class="map-grid grid-h map-grid-h-1"></span>
<span class="map-grid grid-h map-grid-h-2"></span>
<span class="map-grid grid-h map-grid-h-3"></span>
<span class="map-grid grid-v map-grid-v-1"></span>
<span class="map-grid grid-v map-grid-v-2"></span>
<span class="map-grid grid-v map-grid-v-3"></span>
<span class="map-pin"></span>
<template v-if="props.mode === 'radius'">
<span class="radius-circle radius-3">
<span class="radius-label">{{ radiusLabels[2] }}</span>
</span>
<span class="radius-circle radius-2">
<span class="radius-label">{{ radiusLabels[1] }}</span>
</span>
<span class="radius-circle radius-1">
<span class="radius-label">{{ radiusLabels[0] }}</span>
</span>
</template>
<div v-else class="polygon-hint">
<div class="polygon-hint-title">多边形区域模式</div>
<div class="polygon-hint-desc">
点击绘制新区域后可在地图上框选配送范围
</div>
</div>
</div>
</Card>
</template>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
/**
* 文件职责:半径梯度编辑抽屉。
* 1. 展示梯度表单字段。
* 2. 通过回调更新父级状态并提交。
*/
import type { RadiusTierFormState } from '../types';
import { Button, Drawer, InputNumber } from 'ant-design-vue';
interface Props {
colorPalette: string[];
form: RadiusTierFormState;
onSetColor: (value: string) => void;
onSetDeliveryFee: (value: number) => void;
onSetEtaMinutes: (value: number) => void;
onSetMaxDistance: (value: number) => void;
onSetMinDistance: (value: number) => void;
onSetMinOrderAmount: (value: number) => void;
open: boolean;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
</script>
<template>
<Drawer
class="delivery-tier-drawer-wrap"
:open="props.open"
:title="props.title"
:width="460"
:mask-closable="true"
@update:open="(value) => emit('update:open', value)"
>
<div class="drawer-form-block">
<label class="drawer-form-label required">距离范围</label>
<div class="distance-range-row">
<InputNumber
:value="props.form.minDistance"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMinDistance(toNumber(value, props.form.minDistance))
"
/>
<span class="distance-separator">~</span>
<InputNumber
:value="props.form.maxDistance"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMaxDistance(toNumber(value, props.form.maxDistance))
"
/>
<span>km</span>
</div>
</div>
<div class="drawer-form-grid">
<div class="drawer-form-block">
<label class="drawer-form-label required">配送费</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.deliveryFee"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetDeliveryFee(toNumber(value, props.form.deliveryFee))
"
/>
<span></span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">预计送达</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.etaMinutes"
:min="1"
:precision="0"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetEtaMinutes(toNumber(value, props.form.etaMinutes))
"
/>
<span>分钟</span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">起送金额</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.minOrderAmount"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMinOrderAmount(
toNumber(value, props.form.minOrderAmount),
)
"
/>
<span></span>
</div>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">梯度颜色</label>
<div class="color-palette">
<button
v-for="color in props.colorPalette"
:key="color"
type="button"
class="color-dot"
:class="{ active: props.form.color === color }"
:style="{ background: color }"
@click="props.onSetColor(color)"
></button>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button type="primary" @click="emit('submit')">确认</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* 文件职责:配送区域编辑抽屉。
* 1. 展示区域字段编辑表单。
* 2. 通过回调更新父级状态并提交。
*/
import type { PolygonZoneFormState } from '../types';
import { Button, Drawer, Input, InputNumber } from 'ant-design-vue';
interface Props {
colorPalette: string[];
form: PolygonZoneFormState;
onSetColor: (value: string) => void;
onSetDeliveryFee: (value: number) => void;
onSetEtaMinutes: (value: number) => void;
onSetMinOrderAmount: (value: number) => void;
onSetName: (value: string) => void;
onSetPriority: (value: number) => void;
open: boolean;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function toNumber(value: null | number | string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
class="delivery-zone-drawer-wrap"
:open="props.open"
:title="props.title"
:width="460"
:mask-closable="true"
@update:open="(value) => emit('update:open', value)"
>
<div class="drawer-form-block">
<label class="drawer-form-label required">区域名称</label>
<Input
:value="props.form.name"
:maxlength="20"
show-count
placeholder="例如:核心区域"
@input="(event) => props.onSetName(readInputValue(event))"
/>
</div>
<div class="drawer-form-grid">
<div class="drawer-form-block">
<label class="drawer-form-label required">配送费</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.deliveryFee"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetDeliveryFee(toNumber(value, props.form.deliveryFee))
"
/>
<span></span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">起送金额</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.minOrderAmount"
:min="0"
:precision="2"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetMinOrderAmount(
toNumber(value, props.form.minOrderAmount),
)
"
/>
<span></span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">预计送达</label>
<div class="drawer-input-with-unit">
<InputNumber
:value="props.form.etaMinutes"
:min="1"
:precision="0"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) =>
props.onSetEtaMinutes(toNumber(value, props.form.etaMinutes))
"
/>
<span>分钟</span>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">优先级</label>
<InputNumber
:value="props.form.priority"
:min="1"
:precision="0"
:step="1"
:controls="false"
class="drawer-input"
@update:value="
(value) => props.onSetPriority(toNumber(value, props.form.priority))
"
/>
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label">区域颜色</label>
<div class="color-palette">
<button
v-for="color in props.colorPalette"
:key="color"
type="button"
class="color-dot"
:class="{ active: props.form.color === color }"
:style="{ background: color }"
@click="props.onSetColor(color)"
></button>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button type="primary" @click="emit('submit')">确认</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
/**
* 文件职责:多边形区域列表区块。
* 1. 展示区域表格信息。
* 2. 抛出新增、编辑、删除事件。
*/
import type { PolygonZoneDto } from '#/api/store-delivery';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
interface Props {
formatCurrency: (value: number) => string;
isSaving: boolean;
zones: PolygonZoneDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', zoneId: string): void;
(event: 'edit', zone: PolygonZoneDto): void;
}>();
</script>
<template>
<Card :bordered="false">
<template #title>
<span class="section-title">配送区域</span>
</template>
<template #extra>
<Button type="primary" :disabled="props.isSaving" @click="emit('add')">
绘制新区域
</Button>
</template>
<div v-if="props.zones.length > 0" class="zone-table-wrap">
<table class="zone-table">
<thead>
<tr>
<th>区域名称</th>
<th>配送费</th>
<th>起送金额</th>
<th>预计送达</th>
<th>优先级</th>
<th class="zone-op-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="zone in props.zones" :key="zone.id">
<td>
<span
class="zone-color"
:style="{ background: zone.color }"
></span>
{{ zone.name }}
</td>
<td>{{ props.formatCurrency(zone.deliveryFee) }}</td>
<td>{{ props.formatCurrency(zone.minOrderAmount) }}</td>
<td>{{ zone.etaMinutes }} 分钟</td>
<td>{{ zone.priority }}</td>
<td class="zone-op-cell">
<div class="zone-actions">
<Button type="link" size="small" @click="emit('edit', zone)">
编辑
</Button>
<Popconfirm
title="确认删除该配送区域吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', zone.id)"
>
<Button type="link" size="small" danger>删除</Button>
</Popconfirm>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Empty v-else description="暂无区域配置" />
</Card>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* 文件职责:半径梯度列表区块。
* 1. 展示梯度卡片信息。
* 2. 抛出新增、编辑、删除事件。
*/
import type { RadiusTierDto } from '#/api/store-delivery';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
interface Props {
formatCurrency: (value: number) => string;
formatDistanceRange: (tier: RadiusTierDto) => string;
isSaving: boolean;
tiers: RadiusTierDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', tierId: string): void;
(event: 'edit', tier: RadiusTierDto): void;
}>();
</script>
<template>
<Card :bordered="false">
<template #title>
<span class="section-title">距离梯度</span>
</template>
<template #extra>
<Button type="primary" :disabled="props.isSaving" @click="emit('add')">
添加梯度
</Button>
</template>
<div v-if="props.tiers.length > 0" class="tier-list">
<div
v-for="(tier, index) in props.tiers"
:key="tier.id"
class="tier-card"
>
<div class="tier-num" :style="{ background: tier.color }">
{{ index + 1 }}
</div>
<div class="tier-field">
<label>距离范围</label>
<div class="value">{{ props.formatDistanceRange(tier) }}</div>
</div>
<div class="tier-field">
<label>配送费</label>
<div class="value">{{ props.formatCurrency(tier.deliveryFee) }}</div>
</div>
<div class="tier-field">
<label>预计送达</label>
<div class="value">{{ tier.etaMinutes }} 分钟</div>
</div>
<div class="tier-field">
<label>起送金额</label>
<div class="value">
{{ props.formatCurrency(tier.minOrderAmount) }}
</div>
</div>
<div class="tier-actions">
<Button type="text" size="small" @click="emit('edit', tier)">
编辑
</Button>
<Popconfirm
title="确认删除该梯度吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', tier.id)"
>
<Button type="text" size="small" danger>删除</Button>
</Popconfirm>
</div>
</div>
</div>
<Empty v-else description="暂无梯度配置" />
<div class="delivery-tip">超出最大配送半径的地址将无法下单</div>
</Card>
</template>

View File

@@ -0,0 +1,96 @@
/**
* 文件职责:配送设置页面常量。
* 1. 定义默认配送模式与默认数据。
* 2. 统一维护颜色、选项等静态配置。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
export const DELIVERY_MODE_OPTIONS: Array<{
label: string;
value: DeliveryMode;
}> = [
{ label: '按半径配送', value: 'radius' },
{ label: '按区域配送(多边形)', value: 'polygon' },
];
export const TIER_COLOR_PALETTE = [
'#52c41a',
'#faad14',
'#ff4d4f',
'#1677ff',
'#13c2c2',
];
export const DEFAULT_DELIVERY_MODE: DeliveryMode = 'radius';
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',
},
];
export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
{
id: 'zone-core',
name: '核心区域',
color: '#52c41a',
deliveryFee: 3,
minOrderAmount: 15,
etaMinutes: 20,
priority: 1,
},
{
id: 'zone-cbd',
name: '朝阳CBD',
color: '#1677ff',
deliveryFee: 5,
minOrderAmount: 20,
etaMinutes: 35,
priority: 2,
},
{
id: 'zone-slt',
name: '三里屯片区',
color: '#faad14',
deliveryFee: 6,
minOrderAmount: 25,
etaMinutes: 40,
priority: 3,
},
];
export const DEFAULT_GENERAL_SETTINGS: DeliveryGeneralSettingsDto = {
freeDeliveryThreshold: 30,
maxDeliveryDistance: 5,
hourlyCapacityLimit: 50,
etaAdjustmentMinutes: 10,
};

View File

@@ -0,0 +1,81 @@
import type { ComputedRef, Ref } from 'vue';
/**
* 文件职责:配送设置复制动作。
* 1. 维护复制弹窗开关与目标门店选择。
* 2. 提交复制请求并反馈结果。
*/
import type { StoreListItemDto } from '#/api/store';
import { message } from 'ant-design-vue';
import { copyStoreDeliverySettingsApi } from '#/api/store-delivery';
interface CreateCopyActionsOptions {
copyCandidates: ComputedRef<StoreListItemDto[]>;
copyTargetStoreIds: Ref<string[]>;
isCopyModalOpen: Ref<boolean>;
isCopySubmitting: Ref<boolean>;
selectedStoreId: Ref<string>;
}
export function createCopyActions(options: CreateCopyActionsOptions) {
/** 打开弹窗前清空勾选,避免脏数据残留。 */
function openCopyModal() {
if (!options.selectedStoreId.value) return;
options.copyTargetStoreIds.value = [];
options.isCopyModalOpen.value = true;
}
/** 切换单个目标门店选中状态。 */
function toggleCopyStore(storeId: string, checked: boolean) {
options.copyTargetStoreIds.value = checked
? [...new Set([storeId, ...options.copyTargetStoreIds.value])]
: options.copyTargetStoreIds.value.filter((id) => id !== storeId);
}
/** 切换全选状态。 */
function handleCopyCheckAll(checked: boolean) {
if (checked) {
options.copyTargetStoreIds.value = options.copyCandidates.value.map(
(item) => item.id,
);
return;
}
options.copyTargetStoreIds.value = [];
}
/** 提交复制请求。 */
async function handleCopySubmit() {
if (!options.selectedStoreId.value) return;
if (options.copyTargetStoreIds.value.length === 0) {
message.error('请至少选择一个目标门店');
return;
}
options.isCopySubmitting.value = true;
try {
await copyStoreDeliverySettingsApi({
sourceStoreId: options.selectedStoreId.value,
targetStoreIds: options.copyTargetStoreIds.value,
});
message.success(
`已复制到 ${options.copyTargetStoreIds.value.length} 家门店`,
);
options.isCopyModalOpen.value = false;
options.copyTargetStoreIds.value = [];
} catch (error) {
console.error(error);
} finally {
options.isCopySubmitting.value = false;
}
}
return {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
};
}

View File

@@ -0,0 +1,205 @@
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:配送设置数据动作。
* 1. 加载门店列表与当前门店配送设置。
* 2. 保存与重置页面配置。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
import type { DeliverySettingsSnapshot } from '#/views/store/delivery/types';
import { message } from 'ant-design-vue';
import { getStoreListApi } from '#/api/store';
import {
getStoreDeliverySettingsApi,
saveStoreDeliverySettingsApi,
} from '#/api/store-delivery';
import {
DEFAULT_DELIVERY_MODE,
DEFAULT_GENERAL_SETTINGS,
DEFAULT_POLYGON_ZONES,
DEFAULT_RADIUS_TIERS,
} from './constants';
import {
cloneGeneralSettings,
clonePolygonZones,
cloneRadiusTiers,
createSettingsSnapshot,
sortPolygonZones,
sortRadiusTiers,
} from './helpers';
interface CreateDataActionsOptions {
generalSettings: DeliveryGeneralSettingsDto;
isSaving: Ref<boolean>;
isSettingsLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
mode: Ref<DeliveryMode>;
polygonZones: Ref<PolygonZoneDto[]>;
radiusTiers: Ref<RadiusTierDto[]>;
selectedStoreId: Ref<string>;
snapshot: Ref<DeliverySettingsSnapshot | null>;
stores: Ref<StoreListItemDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
/** 同步通用设置对象,保留 reactive 引用。 */
function syncGeneralSettings(next: DeliveryGeneralSettingsDto) {
options.generalSettings.freeDeliveryThreshold = next.freeDeliveryThreshold;
options.generalSettings.maxDeliveryDistance = next.maxDeliveryDistance;
options.generalSettings.hourlyCapacityLimit = next.hourlyCapacityLimit;
options.generalSettings.etaAdjustmentMinutes = next.etaAdjustmentMinutes;
}
/** 将快照应用到页面状态。 */
function applySnapshot(snapshot: DeliverySettingsSnapshot) {
options.mode.value = snapshot.mode;
options.radiusTiers.value = sortRadiusTiers(snapshot.radiusTiers);
options.polygonZones.value = sortPolygonZones(snapshot.polygonZones);
syncGeneralSettings(snapshot.generalSettings);
}
/** 读取当前页面状态并生成快照。 */
function buildCurrentSnapshot() {
return createSettingsSnapshot({
mode: options.mode.value,
radiusTiers: options.radiusTiers.value,
polygonZones: options.polygonZones.value,
generalSettings: options.generalSettings,
});
}
/** 回填默认配置,作为接口异常时的兜底展示。 */
function applyDefaultSettings() {
options.mode.value = DEFAULT_DELIVERY_MODE;
options.radiusTiers.value = sortRadiusTiers(DEFAULT_RADIUS_TIERS);
options.polygonZones.value = sortPolygonZones(DEFAULT_POLYGON_ZONES);
syncGeneralSettings(cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS));
}
/** 加载指定门店的配送设置。 */
async function loadStoreSettings(storeId: string) {
options.isSettingsLoading.value = true;
try {
const currentStoreId = storeId;
const result = await getStoreDeliverySettingsApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return;
options.mode.value = result.mode ?? DEFAULT_DELIVERY_MODE;
options.radiusTiers.value = sortRadiusTiers(
result.radiusTiers?.length ? result.radiusTiers : DEFAULT_RADIUS_TIERS,
);
options.polygonZones.value = sortPolygonZones(
result.polygonZones?.length
? result.polygonZones
: clonePolygonZones(DEFAULT_POLYGON_ZONES),
);
syncGeneralSettings({
...DEFAULT_GENERAL_SETTINGS,
...result.generalSettings,
});
options.snapshot.value = buildCurrentSnapshot();
} catch (error) {
console.error(error);
applyDefaultSettings();
options.snapshot.value = buildCurrentSnapshot();
} finally {
options.isSettingsLoading.value = false;
}
}
/** 加载门店列表并处理默认选中门店。 */
async function loadStores() {
options.isStoreLoading.value = true;
try {
const result = await getStoreListApi({
keyword: undefined,
businessStatus: undefined,
auditStatus: undefined,
serviceType: undefined,
page: 1,
pageSize: 200,
});
options.stores.value = result.items ?? [];
if (options.stores.value.length === 0) {
options.selectedStoreId.value = '';
options.snapshot.value = null;
applyDefaultSettings();
return;
}
const hasSelected = options.stores.value.some(
(store) => store.id === options.selectedStoreId.value,
);
if (!hasSelected) {
const firstStore = options.stores.value[0];
if (firstStore) {
options.selectedStoreId.value = firstStore.id;
}
return;
}
if (options.selectedStoreId.value) {
await loadStoreSettings(options.selectedStoreId.value);
}
} catch (error) {
console.error(error);
options.stores.value = [];
options.selectedStoreId.value = '';
options.snapshot.value = null;
applyDefaultSettings();
} finally {
options.isStoreLoading.value = false;
}
}
/** 保存当前门店配送设置。 */
async function saveCurrentSettings() {
if (!options.selectedStoreId.value) return;
options.isSaving.value = true;
try {
await saveStoreDeliverySettingsApi({
storeId: options.selectedStoreId.value,
mode: options.mode.value,
radiusTiers: cloneRadiusTiers(options.radiusTiers.value),
polygonZones: clonePolygonZones(options.polygonZones.value),
generalSettings: cloneGeneralSettings(options.generalSettings),
});
options.snapshot.value = buildCurrentSnapshot();
message.success('配送设置已保存');
} catch (error) {
console.error(error);
} finally {
options.isSaving.value = false;
}
}
/** 重置到最近一次加载/保存后的快照。 */
function resetFromSnapshot() {
if (!options.snapshot.value) {
applyDefaultSettings();
return;
}
applySnapshot(options.snapshot.value);
message.success('已恢复到最近一次保存状态');
}
return {
loadStoreSettings,
loadStores,
resetFromSnapshot,
saveCurrentSettings,
};
}

View File

@@ -0,0 +1,82 @@
/**
* 文件职责:配送设置纯函数工具。
* 1. 负责格式化展示文案。
* 2. 负责克隆与归一化数据,避免引用污染。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
import type { DeliverySettingsSnapshot } from '#/views/store/delivery/types';
import { TIER_COLOR_PALETTE } from './constants';
/** 深拷贝半径梯度数据,避免直接复用原引用。 */
export function cloneRadiusTiers(source: RadiusTierDto[]) {
return source.map((item) => ({ ...item }));
}
/** 深拷贝多边形区域数据,避免直接复用原引用。 */
export function clonePolygonZones(source: PolygonZoneDto[]) {
return source.map((item) => ({ ...item }));
}
/** 复制通用设置对象,供快照与回滚使用。 */
export function cloneGeneralSettings(source: DeliveryGeneralSettingsDto) {
return { ...source };
}
/** 生成页面级快照,用于重置恢复。 */
export function createSettingsSnapshot(payload: {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
}): DeliverySettingsSnapshot {
return {
mode: payload.mode,
radiusTiers: cloneRadiusTiers(payload.radiusTiers),
polygonZones: clonePolygonZones(payload.polygonZones),
generalSettings: cloneGeneralSettings(payload.generalSettings),
};
}
/** 按距离上限升序整理梯度,保证展示顺序稳定。 */
export function sortRadiusTiers(source: RadiusTierDto[]) {
return cloneRadiusTiers(source).toSorted(
(a, b) => a.maxDistance - b.maxDistance,
);
}
/** 按优先级升序整理区域,保证展示顺序稳定。 */
export function sortPolygonZones(source: PolygonZoneDto[]) {
return clonePolygonZones(source).toSorted((a, b) => a.priority - b.priority);
}
/** 将金额格式化为货币文案。 */
export function formatCurrency(value: number) {
return `¥${Number(value || 0).toFixed(2)}`;
}
/** 将梯度距离格式化为区间文案。 */
export function formatDistanceRange(tier: RadiusTierDto) {
return `${tier.minDistance} ~ ${tier.maxDistance} km`;
}
/** 生成梯度 ID便于前端新增记录标识。 */
export function createTierId() {
return `tier-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 生成区域 ID便于前端新增记录标识。 */
export function createZoneId() {
return `zone-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 根据序号获取梯度颜色,超出时循环取值。 */
export function getTierColorByIndex(index: number) {
if (TIER_COLOR_PALETTE.length === 0) return '#1677ff';
return TIER_COLOR_PALETTE[index % TIER_COLOR_PALETTE.length] || '#1677ff';
}

View File

@@ -0,0 +1,172 @@
import type { Ref } from 'vue';
/**
* 文件职责:半径梯度编辑动作。
* 1. 管理梯度抽屉开关与表单赋值。
* 2. 处理梯度新增、编辑、删除。
*/
import type { RadiusTierDto } from '#/api/store-delivery';
import type {
DeliveryDrawerMode,
RadiusTierFormState,
} from '#/views/store/delivery/types';
import { message } from 'ant-design-vue';
interface CreateTierActionsOptions {
createTierId: () => string;
getTierColorByIndex: (index: number) => string;
isTierDrawerOpen: Ref<boolean>;
radiusTiers: Ref<RadiusTierDto[]>;
sortRadiusTiers: (source: RadiusTierDto[]) => RadiusTierDto[];
tierDrawerMode: Ref<DeliveryDrawerMode>;
tierForm: RadiusTierFormState;
}
export function createTierActions(options: CreateTierActionsOptions) {
/** 打开新增/编辑梯度抽屉,并填充表单。 */
function openTierDrawer(mode: DeliveryDrawerMode, tier?: RadiusTierDto) {
options.tierDrawerMode.value = mode;
if (mode === 'edit' && tier) {
options.tierForm.id = tier.id;
options.tierForm.minDistance = tier.minDistance;
options.tierForm.maxDistance = tier.maxDistance;
options.tierForm.deliveryFee = tier.deliveryFee;
options.tierForm.etaMinutes = tier.etaMinutes;
options.tierForm.minOrderAmount = tier.minOrderAmount;
options.tierForm.color = tier.color;
options.isTierDrawerOpen.value = true;
return;
}
const sorted = options.sortRadiusTiers(options.radiusTiers.value);
const lastTier = sorted.at(-1);
const nextMin = Number(lastTier?.maxDistance ?? 0);
options.tierForm.id = '';
options.tierForm.minDistance = nextMin;
options.tierForm.maxDistance = nextMin + 1;
options.tierForm.deliveryFee = Number(lastTier?.deliveryFee ?? 5);
options.tierForm.etaMinutes = Number(lastTier?.etaMinutes ?? 30);
options.tierForm.minOrderAmount = Number(lastTier?.minOrderAmount ?? 20);
options.tierForm.color = options.getTierColorByIndex(
options.radiusTiers.value.length,
);
options.isTierDrawerOpen.value = true;
}
/** 切换梯度抽屉可见性。 */
function setTierDrawerOpen(value: boolean) {
options.isTierDrawerOpen.value = value;
}
/** 更新梯度表单最小距离。 */
function setTierMinDistance(value: number) {
options.tierForm.minDistance = Math.max(0, Number(value || 0));
}
/** 更新梯度表单最大距离。 */
function setTierMaxDistance(value: number) {
options.tierForm.maxDistance = Math.max(0, Number(value || 0));
}
/** 更新梯度表单配送费。 */
function setTierDeliveryFee(value: number) {
options.tierForm.deliveryFee = Math.max(0, Number(value || 0));
}
/** 更新梯度表单预计送达时间。 */
function setTierEtaMinutes(value: number) {
options.tierForm.etaMinutes = Math.max(1, Number(value || 1));
}
/** 更新梯度表单起送金额。 */
function setTierMinOrderAmount(value: number) {
options.tierForm.minOrderAmount = Math.max(0, Number(value || 0));
}
/** 更新梯度表单颜色。 */
function setTierColor(value: string) {
options.tierForm.color = value || '#1677ff';
}
/** 提交梯度表单并更新列表。 */
function handleTierSubmit() {
// 1. 校验区间与字段合法性。
if (options.tierForm.maxDistance <= options.tierForm.minDistance) {
message.error('结束距离必须大于起始距离');
return;
}
if (
options.tierForm.deliveryFee < 0 ||
options.tierForm.minOrderAmount < 0
) {
message.error('金额字段不能小于 0');
return;
}
// 2. 校验与现有梯度区间冲突。
const hasOverlap = options.radiusTiers.value.some((item) => {
if (item.id === options.tierForm.id) return false;
const isDisjoint =
options.tierForm.maxDistance <= item.minDistance ||
options.tierForm.minDistance >= item.maxDistance;
return !isDisjoint;
});
if (hasOverlap) {
message.error('距离区间与已有梯度重叠,请调整后重试');
return;
}
// 3. 组装记录并写回列表。
const record: RadiusTierDto = {
id: options.tierForm.id || options.createTierId(),
minDistance: options.tierForm.minDistance,
maxDistance: options.tierForm.maxDistance,
deliveryFee: options.tierForm.deliveryFee,
etaMinutes: options.tierForm.etaMinutes,
minOrderAmount: options.tierForm.minOrderAmount,
color: options.tierForm.color,
};
options.radiusTiers.value =
options.tierDrawerMode.value === 'edit' && options.tierForm.id
? options.sortRadiusTiers(
options.radiusTiers.value.map((item) =>
item.id === options.tierForm.id ? record : item,
),
)
: options.sortRadiusTiers([...options.radiusTiers.value, record]);
options.isTierDrawerOpen.value = false;
message.success(
options.tierDrawerMode.value === 'edit' ? '梯度已更新' : '梯度已添加',
);
}
/** 删除指定梯度。 */
function handleDeleteTier(tierId: string) {
if (options.radiusTiers.value.length <= 1) {
message.warning('至少保留一个梯度');
return;
}
options.radiusTiers.value = options.radiusTiers.value.filter(
(item) => item.id !== tierId,
);
message.success('梯度已删除');
}
return {
handleDeleteTier,
handleTierSubmit,
openTierDrawer,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
};
}

View File

@@ -0,0 +1,154 @@
import type { Ref } from 'vue';
/**
* 文件职责:多边形区域编辑动作。
* 1. 管理区域抽屉开关与表单赋值。
* 2. 处理区域新增、编辑、删除。
*/
import type { PolygonZoneDto } from '#/api/store-delivery';
import type {
DeliveryDrawerMode,
PolygonZoneFormState,
} from '#/views/store/delivery/types';
import { message } from 'ant-design-vue';
interface CreateZoneActionsOptions {
createZoneId: () => string;
getTierColorByIndex: (index: number) => string;
isZoneDrawerOpen: Ref<boolean>;
polygonZones: Ref<PolygonZoneDto[]>;
sortPolygonZones: (source: PolygonZoneDto[]) => PolygonZoneDto[];
zoneDrawerMode: Ref<DeliveryDrawerMode>;
zoneForm: PolygonZoneFormState;
}
export function createZoneActions(options: CreateZoneActionsOptions) {
/** 打开新增/编辑区域抽屉,并填充表单。 */
function openZoneDrawer(mode: DeliveryDrawerMode, zone?: PolygonZoneDto) {
options.zoneDrawerMode.value = mode;
if (mode === 'edit' && zone) {
options.zoneForm.id = zone.id;
options.zoneForm.name = zone.name;
options.zoneForm.deliveryFee = zone.deliveryFee;
options.zoneForm.minOrderAmount = zone.minOrderAmount;
options.zoneForm.etaMinutes = zone.etaMinutes;
options.zoneForm.priority = zone.priority;
options.zoneForm.color = zone.color;
options.isZoneDrawerOpen.value = true;
return;
}
const nextPriority = options.polygonZones.value.length + 1;
options.zoneForm.id = '';
options.zoneForm.name = '';
options.zoneForm.deliveryFee = 5;
options.zoneForm.minOrderAmount = 20;
options.zoneForm.etaMinutes = 30;
options.zoneForm.priority = nextPriority;
options.zoneForm.color = options.getTierColorByIndex(nextPriority - 1);
options.isZoneDrawerOpen.value = true;
}
/** 切换区域抽屉可见性。 */
function setZoneDrawerOpen(value: boolean) {
options.isZoneDrawerOpen.value = value;
}
/** 更新区域名称。 */
function setZoneName(value: string) {
options.zoneForm.name = value;
}
/** 更新区域配送费。 */
function setZoneDeliveryFee(value: number) {
options.zoneForm.deliveryFee = Math.max(0, Number(value || 0));
}
/** 更新区域起送金额。 */
function setZoneMinOrderAmount(value: number) {
options.zoneForm.minOrderAmount = Math.max(0, Number(value || 0));
}
/** 更新区域预计送达时间。 */
function setZoneEtaMinutes(value: number) {
options.zoneForm.etaMinutes = Math.max(1, Number(value || 1));
}
/** 更新区域优先级。 */
function setZonePriority(value: number) {
options.zoneForm.priority = Math.max(1, Math.floor(Number(value || 1)));
}
/** 更新区域标识色。 */
function setZoneColor(value: string) {
options.zoneForm.color = value || '#1677ff';
}
/** 提交区域表单并更新列表。 */
function handleZoneSubmit() {
// 1. 必填校验。
const normalizedName = options.zoneForm.name.trim();
if (!normalizedName) {
message.error('请输入区域名称');
return;
}
// 2. 优先级冲突校验。
const hasPriorityConflict = options.polygonZones.value.some((item) => {
if (item.id === options.zoneForm.id) return false;
return item.priority === options.zoneForm.priority;
});
if (hasPriorityConflict) {
message.error('优先级已存在,请调整后重试');
return;
}
// 3. 写回列表。
const record: PolygonZoneDto = {
id: options.zoneForm.id || options.createZoneId(),
name: normalizedName,
deliveryFee: options.zoneForm.deliveryFee,
minOrderAmount: options.zoneForm.minOrderAmount,
etaMinutes: options.zoneForm.etaMinutes,
priority: options.zoneForm.priority,
color: options.zoneForm.color,
};
options.polygonZones.value =
options.zoneDrawerMode.value === 'edit' && options.zoneForm.id
? options.sortPolygonZones(
options.polygonZones.value.map((item) =>
item.id === options.zoneForm.id ? record : item,
),
)
: options.sortPolygonZones([...options.polygonZones.value, record]);
options.isZoneDrawerOpen.value = false;
message.success(
options.zoneDrawerMode.value === 'edit' ? '区域已更新' : '区域已添加',
);
}
/** 删除指定区域。 */
function handleDeleteZone(zoneId: string) {
options.polygonZones.value = options.polygonZones.value.filter(
(item) => item.id !== zoneId,
);
message.success('区域已删除');
}
return {
handleDeleteZone,
handleZoneSubmit,
openZoneDrawer,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
};
}

View File

@@ -0,0 +1,327 @@
import type {
DeliveryDrawerMode,
DeliverySettingsSnapshot,
PolygonZoneFormState,
RadiusTierFormState,
} from '../types';
import type { StoreListItemDto } from '#/api/store';
/**
* 文件职责:配送设置页面主编排。
* 1. 维护页面状态与抽屉状态。
* 2. 组装数据加载、复制、梯度、区域动作。
* 3. 对外暴露视图层可直接消费的状态与方法。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
DEFAULT_DELIVERY_MODE,
DEFAULT_GENERAL_SETTINGS,
DEFAULT_POLYGON_ZONES,
DEFAULT_RADIUS_TIERS,
DELIVERY_MODE_OPTIONS,
TIER_COLOR_PALETTE,
} from './delivery-page/constants';
import { createCopyActions } from './delivery-page/copy-actions';
import { createDataActions } from './delivery-page/data-actions';
import {
cloneGeneralSettings,
clonePolygonZones,
cloneRadiusTiers,
createTierId,
createZoneId,
formatCurrency,
formatDistanceRange,
getTierColorByIndex,
sortPolygonZones,
sortRadiusTiers,
} from './delivery-page/helpers';
import { createTierActions } from './delivery-page/tier-actions';
import { createZoneActions } from './delivery-page/zone-actions';
export function useStoreDeliveryPage() {
// 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false);
const isSettingsLoading = ref(false);
const isSaving = ref(false);
const isCopySubmitting = ref(false);
// 2. 页面主业务数据。
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const deliveryMode = ref<DeliveryMode>(DEFAULT_DELIVERY_MODE);
const radiusTiers = ref<RadiusTierDto[]>(
cloneRadiusTiers(DEFAULT_RADIUS_TIERS),
);
const polygonZones = ref<PolygonZoneDto[]>(
clonePolygonZones(DEFAULT_POLYGON_ZONES),
);
const generalSettings = reactive<DeliveryGeneralSettingsDto>(
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
// 3. 页面弹窗与抽屉状态。
const isCopyModalOpen = ref(false);
const copyTargetStoreIds = ref<string[]>([]);
const snapshot = ref<DeliverySettingsSnapshot | null>(null);
const isTierDrawerOpen = ref(false);
const tierDrawerMode = ref<DeliveryDrawerMode>('create');
const tierForm = reactive<RadiusTierFormState>({
id: '',
minDistance: 0,
maxDistance: 1,
deliveryFee: 5,
etaMinutes: 30,
minOrderAmount: 20,
color: getTierColorByIndex(0),
});
const isZoneDrawerOpen = ref(false);
const zoneDrawerMode = ref<DeliveryDrawerMode>('create');
const zoneForm = reactive<PolygonZoneFormState>({
id: '',
name: '',
deliveryFee: 5,
minOrderAmount: 20,
etaMinutes: 30,
priority: 1,
color: getTierColorByIndex(0),
});
// 4. 页面衍生视图数据。
const storeOptions = computed(() =>
stores.value.map((store) => ({ label: store.name, value: store.id })),
);
const selectedStoreName = computed(
() =>
stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'',
);
const copyCandidates = computed(() =>
stores.value.filter((store) => store.id !== selectedStoreId.value),
);
const isCopyAllChecked = computed(
() =>
copyCandidates.value.length > 0 &&
copyTargetStoreIds.value.length === copyCandidates.value.length,
);
const isCopyIndeterminate = computed(
() =>
copyTargetStoreIds.value.length > 0 &&
copyTargetStoreIds.value.length < copyCandidates.value.length,
);
const isRadiusMode = computed(() => deliveryMode.value === 'radius');
const isPageLoading = computed(() => isSettingsLoading.value);
const tierDrawerTitle = computed(() =>
tierDrawerMode.value === 'edit' ? '编辑梯度' : '添加梯度',
);
const zoneDrawerTitle = computed(() =>
zoneDrawerMode.value === 'edit' ? '编辑区域' : '新增区域',
);
// 5. 数据域动作装配。
const {
loadStoreSettings,
loadStores,
resetFromSnapshot,
saveCurrentSettings,
} = createDataActions({
generalSettings,
isSaving,
isSettingsLoading,
isStoreLoading,
mode: deliveryMode,
polygonZones,
radiusTiers,
selectedStoreId,
snapshot,
stores,
});
const {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
const {
handleDeleteTier,
handleTierSubmit,
openTierDrawer,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
} = createTierActions({
createTierId,
getTierColorByIndex,
isTierDrawerOpen,
radiusTiers,
sortRadiusTiers,
tierDrawerMode,
tierForm,
});
const {
handleDeleteZone,
handleZoneSubmit,
openZoneDrawer,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
} = createZoneActions({
createZoneId,
getTierColorByIndex,
isZoneDrawerOpen,
polygonZones,
sortPolygonZones,
zoneDrawerMode,
zoneForm,
});
// 6. 页面字段更新方法。
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setDeliveryMode(value: DeliveryMode) {
deliveryMode.value = value;
}
function setFreeDeliveryThreshold(value: null | number) {
if (value === null || value === undefined) {
generalSettings.freeDeliveryThreshold = null;
return;
}
generalSettings.freeDeliveryThreshold = Math.max(0, Number(value || 0));
}
function setMaxDeliveryDistance(value: number) {
generalSettings.maxDeliveryDistance = Math.max(0, Number(value || 0));
}
function setHourlyCapacityLimit(value: number) {
generalSettings.hourlyCapacityLimit = Math.max(
1,
Math.floor(Number(value || 1)),
);
}
function setEtaAdjustmentMinutes(value: number) {
generalSettings.etaAdjustmentMinutes = Math.max(
0,
Math.floor(Number(value || 0)),
);
}
// 7. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
deliveryMode.value = DEFAULT_DELIVERY_MODE;
radiusTiers.value = cloneRadiusTiers(DEFAULT_RADIUS_TIERS);
polygonZones.value = clonePolygonZones(DEFAULT_POLYGON_ZONES);
Object.assign(
generalSettings,
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
snapshot.value = null;
return;
}
await loadStoreSettings(storeId);
});
// 8. 页面首屏初始化。
onMounted(loadStores);
return {
DELIVERY_MODE_OPTIONS,
copyCandidates,
copyTargetStoreIds,
deliveryMode,
formatCurrency,
formatDistanceRange,
generalSettings,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteTier,
handleDeleteZone,
handleTierSubmit,
handleZoneSubmit,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isPageLoading,
isRadiusMode,
isSaving,
isStoreLoading,
isTierDrawerOpen,
isZoneDrawerOpen,
openCopyModal,
openTierDrawer,
openZoneDrawer,
polygonZones,
radiusTiers,
resetFromSnapshot,
saveCurrentSettings,
selectedStoreId,
selectedStoreName,
setDeliveryMode,
setEtaAdjustmentMinutes,
setFreeDeliveryThreshold,
setHourlyCapacityLimit,
setMaxDeliveryDistance,
setSelectedStoreId,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
storeOptions,
tierColorPalette: TIER_COLOR_PALETTE,
tierDrawerMode,
tierDrawerTitle,
tierForm,
toggleCopyStore,
zoneDrawerMode,
zoneDrawerTitle,
zoneForm,
};
}

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
/**
* 文件职责:配送设置页面主视图。
* 1. 组合配送模式、梯度/区域、通用设置子组件。
* 2. 承接门店维度切换与复制弹窗。
*/
import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import DeliveryCommonSettingsCard from './components/DeliveryCommonSettingsCard.vue';
import DeliveryModeCard from './components/DeliveryModeCard.vue';
import DeliveryTierDrawer from './components/DeliveryTierDrawer.vue';
import DeliveryZoneDrawer from './components/DeliveryZoneDrawer.vue';
import PolygonZoneSection from './components/PolygonZoneSection.vue';
import RadiusTierSection from './components/RadiusTierSection.vue';
import { useStoreDeliveryPage } from './composables/useStoreDeliveryPage';
const {
DELIVERY_MODE_OPTIONS,
copyCandidates,
copyTargetStoreIds,
deliveryMode,
formatCurrency,
formatDistanceRange,
generalSettings,
handleCopyCheckAll,
handleCopySubmit,
handleDeleteTier,
handleDeleteZone,
handleTierSubmit,
handleZoneSubmit,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isPageLoading,
isRadiusMode,
isSaving,
isStoreLoading,
isTierDrawerOpen,
isZoneDrawerOpen,
openCopyModal,
openTierDrawer,
openZoneDrawer,
polygonZones,
radiusTiers,
resetFromSnapshot,
saveCurrentSettings,
selectedStoreId,
selectedStoreName,
setDeliveryMode,
setEtaAdjustmentMinutes,
setFreeDeliveryThreshold,
setHourlyCapacityLimit,
setMaxDeliveryDistance,
setSelectedStoreId,
setTierColor,
setTierDeliveryFee,
setTierDrawerOpen,
setTierEtaMinutes,
setTierMaxDistance,
setTierMinDistance,
setTierMinOrderAmount,
setZoneColor,
setZoneDeliveryFee,
setZoneDrawerOpen,
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePriority,
storeOptions,
tierColorPalette,
tierDrawerTitle,
tierForm,
toggleCopyStore,
zoneDrawerTitle,
zoneForm,
} = useStoreDeliveryPage();
</script>
<template>
<Page title="配送设置" content-class="space-y-4 page-store-delivery">
<StoreScopeToolbar
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0"
@update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal"
/>
<template v-if="storeOptions.length === 0">
<Card :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
</template>
<template v-else>
<Spin :spinning="isPageLoading">
<DeliveryModeCard
:mode="deliveryMode"
:mode-options="DELIVERY_MODE_OPTIONS"
:radius-tiers="radiusTiers"
@change-mode="setDeliveryMode"
/>
<RadiusTierSection
v-if="isRadiusMode"
:tiers="radiusTiers"
:format-currency="formatCurrency"
:format-distance-range="formatDistanceRange"
:is-saving="isSaving"
@add="openTierDrawer('create')"
@edit="(tier) => openTierDrawer('edit', tier)"
@delete="handleDeleteTier"
/>
<PolygonZoneSection
v-else
:zones="polygonZones"
:format-currency="formatCurrency"
:is-saving="isSaving"
@add="openZoneDrawer('create')"
@edit="(zone) => openZoneDrawer('edit', zone)"
@delete="handleDeleteZone"
/>
<DeliveryCommonSettingsCard
:settings="generalSettings"
:is-saving="isSaving"
:on-set-free-delivery-threshold="setFreeDeliveryThreshold"
:on-set-max-delivery-distance="setMaxDeliveryDistance"
:on-set-hourly-capacity-limit="setHourlyCapacityLimit"
:on-set-eta-adjustment-minutes="setEtaAdjustmentMinutes"
@reset="resetFromSnapshot"
@save="saveCurrentSettings"
/>
</Spin>
</template>
<DeliveryTierDrawer
:open="isTierDrawerOpen"
:title="tierDrawerTitle"
:form="tierForm"
:color-palette="tierColorPalette"
:on-set-min-distance="setTierMinDistance"
:on-set-max-distance="setTierMaxDistance"
:on-set-delivery-fee="setTierDeliveryFee"
:on-set-eta-minutes="setTierEtaMinutes"
:on-set-min-order-amount="setTierMinOrderAmount"
:on-set-color="setTierColor"
@update:open="setTierDrawerOpen"
@submit="handleTierSubmit"
/>
<DeliveryZoneDrawer
:open="isZoneDrawerOpen"
:title="zoneDrawerTitle"
:form="zoneForm"
:color-palette="tierColorPalette"
:on-set-name="setZoneName"
:on-set-delivery-fee="setZoneDeliveryFee"
:on-set-min-order-amount="setZoneMinOrderAmount"
:on-set-eta-minutes="setZoneEtaMinutes"
:on-set-priority="setZonePriority"
:on-set-color="setZoneColor"
@update:open="setZoneDrawerOpen"
@submit="handleZoneSubmit"
/>
<CopyToStoresModal
v-model:open="isCopyModalOpen"
:copy-candidates="copyCandidates"
:target-store-ids="copyTargetStoreIds"
:is-all-checked="isCopyAllChecked"
:is-indeterminate="isCopyIndeterminate"
:is-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
title="复制配送设置到其他门店"
confirm-text="确认复制"
@check-all="handleCopyCheckAll"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,10 @@
/* 文件职责:配送设置页面基础骨架样式。 */
.page-store-delivery {
max-width: 980px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
}

View File

@@ -0,0 +1,38 @@
/* 文件职责:通用配送设置区块样式。 */
.page-store-delivery {
.general-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 24px;
}
.general-field label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #4b5563;
}
.field-input-row {
display: flex;
gap: 6px;
align-items: center;
}
.field-input {
width: 140px;
}
.field-hint {
margin-top: 4px;
font-size: 11px;
color: #9ca3af;
}
.general-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
}

View File

@@ -0,0 +1,85 @@
/* 文件职责:配送设置抽屉与表单样式。 */
.delivery-tier-drawer-wrap,
.delivery-zone-drawer-wrap {
.ant-drawer-body {
padding: 16px 20px 90px;
}
.ant-drawer-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
}
.drawer-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.drawer-form-block {
margin-bottom: 14px;
}
.drawer-form-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.drawer-form-label.required::before {
margin-right: 4px;
color: #ef4444;
content: '*';
}
.distance-range-row {
display: flex;
gap: 8px;
align-items: center;
}
.distance-separator {
color: #9ca3af;
}
.drawer-input-with-unit {
display: flex;
gap: 6px;
align-items: center;
}
.drawer-input {
width: 120px;
}
.color-palette {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.color-dot {
position: relative;
width: 22px;
height: 22px;
cursor: pointer;
border: none;
border-radius: 50%;
}
.color-dot.active::after {
position: absolute;
inset: -3px;
content: '';
border: 2px solid #111827;
border-radius: 50%;
}
.drawer-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}

View File

@@ -0,0 +1,8 @@
/* 文件职责:配送设置页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './mode.less';
@import './tier.less';
@import './zone.less';
@import './common.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -0,0 +1,167 @@
/* 文件职责:配送模式切换与地图占位样式。 */
.page-store-delivery {
.delivery-mode-switch {
display: flex;
gap: 2px;
width: fit-content;
padding: 3px;
margin-bottom: 16px;
background: #f8f9fb;
border-radius: 8px;
}
.mode-switch-item {
padding: 6px 18px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
transition: all 0.2s ease;
}
.mode-switch-item.active {
font-weight: 600;
color: #1677ff;
background: #fff;
box-shadow: 0 2px 6px rgb(15 23 42 / 8%);
}
.delivery-map-area {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 320px;
overflow: hidden;
background: linear-gradient(180deg, #f0f5ff 0%, #f7faff 100%);
border: 1px dashed #adc6ff;
border-radius: 10px;
}
.map-grid {
position: absolute;
background: #d6e4ff;
}
.grid-h {
right: 0;
left: 0;
height: 1px;
}
.grid-v {
top: 0;
bottom: 0;
width: 1px;
}
.map-grid-h-1 {
top: 25%;
}
.map-grid-h-2 {
top: 50%;
}
.map-grid-h-3 {
top: 75%;
}
.map-grid-v-1 {
left: 25%;
}
.map-grid-v-2 {
left: 50%;
}
.map-grid-v-3 {
left: 75%;
}
.map-pin {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
font-size: 20px;
line-height: 1;
color: #1677ff;
transform: translate(-50%, -100%);
}
.radius-circle {
position: absolute;
top: 50%;
left: 50%;
border-style: dashed;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.radius-label {
position: absolute;
bottom: -16px;
left: 50%;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
transform: translateX(-50%);
}
.radius-1 {
width: 100px;
height: 100px;
background: rgb(82 196 26 / 8%);
border-color: #52c41a;
border-width: 2px;
}
.radius-1 .radius-label {
color: #52c41a;
}
.radius-2 {
width: 180px;
height: 180px;
background: rgb(250 173 20 / 5%);
border-color: #faad14;
border-width: 2px;
}
.radius-2 .radius-label {
color: #faad14;
}
.radius-3 {
width: 260px;
height: 260px;
background: rgb(255 77 79 / 4%);
border-color: #ff4d4f;
border-width: 2px;
}
.radius-3 .radius-label {
color: #ff4d4f;
}
.polygon-hint {
z-index: 3;
color: #3f87ff;
text-align: center;
}
.polygon-hint-title {
margin-bottom: 6px;
font-size: 16px;
font-weight: 600;
}
.polygon-hint-desc {
font-size: 13px;
opacity: 0.8;
}
}

View File

@@ -0,0 +1,47 @@
/* 文件职责:配送设置页面响应式规则。 */
.page-store-delivery {
@media (max-width: 992px) {
.tier-card {
grid-template-columns: 40px 1fr 1fr;
row-gap: 10px;
}
.tier-actions {
grid-column: span 3;
justify-content: flex-start;
}
}
@media (max-width: 768px) {
.general-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.delivery-map-area {
height: 260px;
}
.radius-1 {
width: 86px;
height: 86px;
}
.radius-2 {
width: 150px;
height: 150px;
}
.radius-3 {
width: 214px;
height: 214px;
}
}
}
@media (max-width: 768px) {
.drawer-form-grid {
grid-template-columns: 1fr;
gap: 0;
}
}

View File

@@ -0,0 +1,62 @@
/* 文件职责:半径梯度区块样式。 */
.page-store-delivery {
.tier-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tier-card {
display: grid;
grid-template-columns: 40px 1fr 1fr 1fr 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: #f8f9fb;
border-radius: 10px;
box-shadow: 0 2px 8px rgb(15 23 42 / 6%);
transition: all 0.2s ease;
}
.tier-card:hover {
box-shadow: 0 6px 16px rgb(15 23 42 / 10%);
}
.tier-num {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 12px;
font-weight: 600;
color: #fff;
border-radius: 50%;
}
.tier-field label {
display: block;
margin-bottom: 3px;
font-size: 11px;
color: #9ca3af;
}
.tier-field .value {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.tier-actions {
display: flex;
gap: 4px;
align-items: center;
justify-content: flex-end;
}
.delivery-tip {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
}

View File

@@ -0,0 +1,59 @@
/* 文件职责:多边形区域表格样式。 */
.page-store-delivery {
.zone-table-wrap {
overflow-x: auto;
}
.zone-table {
width: 100%;
min-width: 760px;
font-size: 13px;
border-collapse: collapse;
}
.zone-table th {
padding: 10px 12px;
font-weight: 600;
color: #6b7280;
text-align: left;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.zone-table td {
padding: 10px 12px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.zone-table tr:last-child td {
border-bottom: none;
}
.zone-table tr:hover td {
background: #f6faff;
}
.zone-op-column {
width: 140px;
}
.zone-op-cell {
min-width: 120px;
}
.zone-actions {
display: flex;
gap: 4px;
align-items: center;
}
.zone-color {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 6px;
vertical-align: middle;
border-radius: 3px;
}
}

View File

@@ -0,0 +1,40 @@
/**
* 文件职责:配送设置页面类型定义。
* 1. 声明页面表单态类型。
* 2. 声明页面快照与抽屉模式类型。
*/
import type {
DeliveryGeneralSettingsDto,
DeliveryMode,
PolygonZoneDto,
RadiusTierDto,
} from '#/api/store-delivery';
export type DeliveryDrawerMode = 'create' | 'edit';
export interface RadiusTierFormState {
color: string;
deliveryFee: number;
etaMinutes: number;
id: string;
maxDistance: number;
minDistance: number;
minOrderAmount: number;
}
export interface PolygonZoneFormState {
color: string;
deliveryFee: number;
etaMinutes: number;
id: string;
minOrderAmount: number;
name: string;
priority: number;
}
export interface DeliverySettingsSnapshot {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
}