feat: 完成门店配置拆分并新增配送与自提设置页面
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
195
apps/web-antd/src/views/store/delivery/index.vue
Normal file
195
apps/web-antd/src/views/store/delivery/index.vue
Normal 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>
|
||||
10
apps/web-antd/src/views/store/delivery/styles/base.less
Normal file
10
apps/web-antd/src/views/store/delivery/styles/base.less
Normal file
@@ -0,0 +1,10 @@
|
||||
/* 文件职责:配送设置页面基础骨架样式。 */
|
||||
.page-store-delivery {
|
||||
max-width: 980px;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
}
|
||||
38
apps/web-antd/src/views/store/delivery/styles/common.less
Normal file
38
apps/web-antd/src/views/store/delivery/styles/common.less
Normal 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;
|
||||
}
|
||||
}
|
||||
85
apps/web-antd/src/views/store/delivery/styles/drawer.less
Normal file
85
apps/web-antd/src/views/store/delivery/styles/drawer.less
Normal 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;
|
||||
}
|
||||
8
apps/web-antd/src/views/store/delivery/styles/index.less
Normal file
8
apps/web-antd/src/views/store/delivery/styles/index.less
Normal 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';
|
||||
167
apps/web-antd/src/views/store/delivery/styles/mode.less
Normal file
167
apps/web-antd/src/views/store/delivery/styles/mode.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
62
apps/web-antd/src/views/store/delivery/styles/tier.less
Normal file
62
apps/web-antd/src/views/store/delivery/styles/tier.less
Normal 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;
|
||||
}
|
||||
}
|
||||
59
apps/web-antd/src/views/store/delivery/styles/zone.less
Normal file
59
apps/web-antd/src/views/store/delivery/styles/zone.less
Normal 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;
|
||||
}
|
||||
}
|
||||
40
apps/web-antd/src/views/store/delivery/types.ts
Normal file
40
apps/web-antd/src/views/store/delivery/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user