feat(project): align store delivery pages with live APIs and geocode status

This commit is contained in:
2026-02-19 17:15:19 +08:00
parent 435626ca55
commit 3b96b3f92d
62 changed files with 6813 additions and 250 deletions

View File

@@ -2,19 +2,20 @@
/**
* 文件职责:配送模式区块。
* 1. 展示当前启用配送模式,并提供独立切换入口。
* 2. 展示配置编辑视图切换与半径/区域两种预览
* 2. 展示配置编辑视图切换与半径中心点输入
*/
import type { DeliveryMode, RadiusTierDto } from '#/api/store-delivery';
import type { DeliveryMode } from '#/api/store-delivery';
import { computed } from 'vue';
import { Card } from 'ant-design-vue';
import { Card, InputNumber } from 'ant-design-vue';
interface Props {
activeMode: DeliveryMode;
configMode: DeliveryMode;
modeOptions: Array<{ label: string; value: DeliveryMode }>;
radiusTiers: RadiusTierDto[];
radiusCenterLatitude: null | number;
radiusCenterLongitude: null | number;
}
const props = defineProps<Props>();
@@ -22,6 +23,8 @@ const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'changeActiveMode', mode: DeliveryMode): void;
(event: 'changeConfigMode', mode: DeliveryMode): void;
(event: 'changeRadiusCenterLatitude', value: null | number): void;
(event: 'changeRadiusCenterLongitude', value: null | number): void;
}>();
const activeModeLabel = computed(() => {
@@ -31,19 +34,13 @@ const activeModeLabel = computed(() => {
);
});
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');
function toNumber(value: null | number | string) {
if (value === null || value === undefined || value === '') {
return null;
}
return labels;
});
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
</script>
<template>
@@ -87,34 +84,49 @@ const radiusLabels = computed(() => {
</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.configMode === '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 v-if="props.configMode === 'radius'" class="radius-center-panel">
<div class="radius-center-title">半径配送中心点</div>
<div class="radius-center-grid">
<div class="radius-center-field">
<label>纬度</label>
<InputNumber
:value="props.radiusCenterLatitude ?? undefined"
:min="-90"
:max="90"
:precision="7"
:step="0.000001"
:controls="false"
placeholder="如39.9042000"
class="field-input"
@update:value="
(value) => emit('changeRadiusCenterLatitude', toNumber(value))
"
/>
</div>
<div class="radius-center-field">
<label>经度</label>
<InputNumber
:value="props.radiusCenterLongitude ?? undefined"
:min="-180"
:max="180"
:precision="7"
:step="0.000001"
:controls="false"
placeholder="如116.4074000"
class="field-input"
@update:value="
(value) => emit('changeRadiusCenterLongitude', toNumber(value))
"
/>
</div>
</div>
<div class="radius-center-hint">
请输入配送中心点经纬度半径梯度将基于该点计算可配送范围
</div>
</div>
<div v-else class="polygon-mode-hint">
当前为按区域配送多边形配置视图无需设置半径中心点
</div>
</Card>
</template>

View File

@@ -0,0 +1,731 @@
<script setup lang="ts">
import type { LngLatTuple } from '../composables/delivery-page/geojson';
/**
* 文件职责:配送区域地图绘制弹窗。
* 1. 使用腾讯地图 GeometryEditor 提供标准的多边形绘制交互。
* 2. 每个区域仅保留一块独立多边形,确认后回传 GeoJSON。
*/
import {
computed,
nextTick,
onBeforeUnmount,
ref,
shallowRef,
watch,
} from 'vue';
import { Alert, Button, message, Modal, Space } from 'ant-design-vue';
import {
parsePolygonGeoJson,
stringifyPolygonGeoJson,
} from '../composables/delivery-page/geojson';
import { geocodeAddressToLngLat } from '../composables/useTencentGeocoder';
import { loadTencentMapSdk } from '../composables/useTencentMapLoader';
interface Props {
fallbackCityText: string;
initialCenterAddress: string;
initialCenterLatitude: null | number;
initialCenterLongitude: null | number;
initialGeoJson: string;
open: boolean;
zoneColor: string;
}
type LatLngLiteral = { lat: number; lng: number };
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'confirm', geoJson: string): void;
(event: 'update:open', value: boolean): void;
}>();
const DEFAULT_CENTER: LngLatTuple = [116.397_428, 39.909_23];
const MAP_MODAL_Z_INDEX = 10_000;
const MAP_DEBUG_PREFIX = '[TenantUI-DeliveryMap]';
const mapContainerRef = ref<HTMLDivElement | null>(null);
const mapInstance = shallowRef<any>(null);
const polygonLayer = shallowRef<any>(null);
const geometryEditor = shallowRef<any>(null);
const isMapLoading = ref(false);
const mapError = ref('');
const isDrawing = ref(false);
const polygonCount = ref(0);
const latestGeoJson = ref('');
const hasPolygon = computed(() => polygonCount.value > 0);
const canStartDrawing = computed(
() =>
!isMapLoading.value &&
!mapError.value &&
!!mapInstance.value &&
!!geometryEditor.value,
);
function logMapDebug(step: string, payload?: unknown) {
if (!import.meta.env.DEV) return;
if (payload === undefined) {
console.warn(MAP_DEBUG_PREFIX, step);
return;
}
console.warn(MAP_DEBUG_PREFIX, step, payload);
}
function refreshMapViewport(center?: LngLatTuple) {
const TMap = window.TMap;
if (!mapInstance.value) return;
// 弹窗二次打开时,地图容器尺寸可能在过渡动画中变化,手动触发重排避免灰底图。
if (typeof mapInstance.value.resize === 'function') {
mapInstance.value.resize();
}
if (
center &&
TMap?.LatLng &&
typeof mapInstance.value.setCenter === 'function'
) {
mapInstance.value.setCenter(new TMap.LatLng(center[1], center[0]));
}
forceMapRepaint(center);
}
function forceMapRepaint(center?: LngLatTuple) {
const TMap = window.TMap;
const map = mapInstance.value;
if (!map) return;
if (center && TMap?.LatLng && typeof map.panTo === 'function') {
map.panTo(new TMap.LatLng(center[1], center[0]));
}
if (typeof map.getZoom === 'function' && typeof map.setZoom === 'function') {
const currentZoom = Number(map.getZoom());
if (Number.isFinite(currentZoom)) {
const bumpedZoom = Math.min(20, Math.max(3, currentZoom + 1));
map.setZoom(bumpedZoom);
window.setTimeout(() => {
if (!props.open || map !== mapInstance.value) return;
map.setZoom(currentZoom);
}, 100);
}
}
}
async function waitForMapContainerReady() {
const maxAttempts = 20;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
await nextTick();
const container = mapContainerRef.value;
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
return container;
}
await new Promise((resolve) => window.setTimeout(resolve, 40));
}
return mapContainerRef.value;
}
function toLayerPoint([lng, lat]: LngLatTuple): any | LatLngLiteral {
const TMap = window.TMap;
if (TMap?.LatLng) {
return new TMap.LatLng(lat, lng);
}
return { lat, lng };
}
function resolvePoint(point: any): LngLatTuple | null {
if (!point) return null;
const lng =
typeof point.getLng === 'function' ? point.getLng() : Number(point.lng);
const lat =
typeof point.getLat === 'function' ? point.getLat() : Number(point.lat);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) return null;
return [lng, lat];
}
function normalizePolygon(points: LngLatTuple[]) {
if (points.length < 3) return [] as LngLatTuple[];
const normalized = points.map(([lng, lat]) => [lng, lat] as LngLatTuple);
const first = normalized[0];
const last = normalized[normalized.length - 1];
if (!first || !last) return [] as LngLatTuple[];
if (first[0] !== last[0] || first[1] !== last[1]) {
normalized.push([first[0], first[1]]);
}
return normalized;
}
function unwrapPolygonPath(paths: any): any[] {
if (!Array.isArray(paths) || paths.length === 0) return [];
return Array.isArray(paths[0]) ? (paths[0] as any[]) : (paths as any[]);
}
function buildGeometry(points: LngLatTuple[], id = 'polygon-1') {
return {
id,
styleId: 'highlight',
// MultiPolygon 单个区域也使用“二维 paths外环”结构避免编辑器回填后丢失图形。
paths: [points.map((point) => toLayerPoint(point))],
};
}
function setRawGeometries(geometries: any[]) {
if (
!polygonLayer.value ||
typeof polygonLayer.value.setGeometries !== 'function'
) {
return;
}
polygonLayer.value.setGeometries(geometries);
polygonCount.value = geometries.length;
}
function setPolygonToLayer(points: LngLatTuple[] | null) {
if (!points) {
setRawGeometries([]);
return;
}
const ring = normalizePolygon(points);
if (ring.length < 4) {
setRawGeometries([]);
return;
}
setRawGeometries([buildGeometry(ring)]);
}
function getMapCenterFromGeoJson(raw: string): LngLatTuple | null {
const parsed = parsePolygonGeoJson(raw);
const firstPoint = parsed[0]?.[0];
if (!firstPoint) return null;
// 0,0 常见于后端空坐标默认值,作为无效点处理。
if (firstPoint[0] === 0 && firstPoint[1] === 0) return null;
return firstPoint ?? null;
}
function getMapCenterFromCoordinates() {
const lng = Number(props.initialCenterLongitude);
const lat = Number(props.initialCenterLatitude);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) return null;
// 0,0 常见于后端空坐标默认值,作为无效点处理。
if (lng === 0 && lat === 0) return null;
return [lng, lat] as LngLatTuple;
}
async function resolveInitialCenter() {
const centerFromGeoJson = getMapCenterFromGeoJson(props.initialGeoJson);
if (centerFromGeoJson) {
return { center: centerFromGeoJson, shouldResolveAsync: false };
}
const centerFromCoordinates = getMapCenterFromCoordinates();
if (centerFromCoordinates) {
return { center: centerFromCoordinates, shouldResolveAsync: false };
}
// 优先保证地图首屏可见,地址编码异步纠偏中心点,避免白屏等待。
return { center: DEFAULT_CENTER, shouldResolveAsync: true };
}
async function geocodeAddressWithTimeout(address: string, timeoutMs: number) {
const timeoutPromise = new Promise<LngLatTuple | null>((resolve) => {
window.setTimeout(() => resolve(null), timeoutMs);
});
return Promise.race([geocodeAddressToLngLat(address), timeoutPromise]);
}
async function resolveAsyncCenterByAddress() {
const centerFromStoreAddress = await geocodeAddressWithTimeout(
props.initialCenterAddress,
1800,
);
if (centerFromStoreAddress) return centerFromStoreAddress;
const centerFromMerchantCity = await geocodeAddressWithTimeout(
props.fallbackCityText,
1800,
);
if (centerFromMerchantCity) return centerFromMerchantCity;
return null;
}
function createHighlightPolygonStyle(TMap: any) {
return new TMap.PolygonStyle({
color: 'rgba(24, 144, 255, 0.28)',
showBorder: true,
borderWidth: 2,
borderColor: '#1d4ed8',
});
}
function setEditorActionMode(mode: 'DRAW' | 'INTERACT') {
const TMap = window.TMap;
if (!TMap?.tools?.constants?.EDITOR_ACTION || !geometryEditor.value) return;
const actionMode = TMap.tools.constants.EDITOR_ACTION[mode];
if (actionMode === undefined) return;
if (
mode === 'DRAW' &&
typeof geometryEditor.value.setActiveOverlay === 'function'
) {
geometryEditor.value.setActiveOverlay('polygon-group');
}
geometryEditor.value.setActionMode(actionMode);
}
function geometryToPolygon(geometry: any) {
const pathList = unwrapPolygonPath(geometry?.paths);
if (!Array.isArray(pathList) || pathList.length < 3) return null;
const points = pathList
.map((point) => resolvePoint(point))
.filter((point): point is LngLatTuple => !!point);
const ring = normalizePolygon(points);
if (ring.length < 4) return null;
return ring;
}
function syncGeoJsonFromGeometry(eventGeometry?: any) {
// 与 AdminUI 对齐:优先使用事件 geometry避免依赖 overlay 的异步落盘时序。
const fromEvent = geometryToPolygon(eventGeometry);
let fromLayer: LngLatTuple[] | null = null;
if (!fromEvent) {
const layerGeometries =
typeof polygonLayer.value?.getGeometries === 'function'
? polygonLayer.value.getGeometries()
: polygonLayer.value?.geometries;
const firstGeometry = Array.isArray(layerGeometries)
? layerGeometries[0]
: null;
fromLayer = geometryToPolygon(firstGeometry);
}
const polygon = fromEvent ?? fromLayer;
if (!polygon) {
latestGeoJson.value = '';
polygonCount.value = 0;
return false;
}
latestGeoJson.value = stringifyPolygonGeoJson([polygon]);
polygonCount.value = 1;
return true;
}
function onDrawComplete(geometry?: any) {
const synced = syncGeoJsonFromGeometry(geometry);
if (!synced && typeof window !== 'undefined') {
// draw_complete 可能先于图层状态可读,延后一拍兜底读取。
window.setTimeout(() => {
const recovered = syncGeoJsonFromGeometry();
if (!recovered) {
message.warning('未识别到有效区域,请重新绘制');
return;
}
isDrawing.value = false;
setEditorActionMode('INTERACT');
}, 0);
return;
}
if (!synced) {
message.warning('未识别到有效区域,请重新绘制');
return;
}
isDrawing.value = false;
setEditorActionMode('INTERACT');
}
function onAdjustComplete(geometry?: any) {
void syncGeoJsonFromGeometry(geometry);
}
function onDeleteComplete() {
latestGeoJson.value = '';
polygonCount.value = 0;
isDrawing.value = false;
setEditorActionMode('INTERACT');
}
function destroyEditor() {
if (
geometryEditor.value &&
typeof geometryEditor.value.destroy === 'function'
) {
geometryEditor.value.destroy();
}
geometryEditor.value = null;
}
function destroyMapResources() {
destroyEditor();
if (polygonLayer.value && typeof polygonLayer.value.setMap === 'function') {
polygonLayer.value.setMap(null);
}
polygonLayer.value = null;
if (mapInstance.value && typeof mapInstance.value.destroy === 'function') {
mapInstance.value.destroy();
}
mapInstance.value = null;
}
function createEditor() {
const TMap = window.TMap;
if (!TMap?.tools?.GeometryEditor || !mapInstance.value || !polygonLayer.value)
return;
destroyEditor();
geometryEditor.value = new TMap.tools.GeometryEditor({
map: mapInstance.value,
overlayList: [
{
id: 'polygon-group',
overlay: polygonLayer.value,
selectedStyleId: 'highlight',
drawingStyleId: 'highlight',
},
],
activeOverlayId: 'polygon-group',
actionMode: TMap.tools.constants.EDITOR_ACTION.INTERACT,
selectable: true,
snappable: true,
});
geometryEditor.value.on('draw_complete', onDrawComplete);
geometryEditor.value.on('adjust_complete', onAdjustComplete);
geometryEditor.value.on('delete_complete', onDeleteComplete);
}
function fitMapToPolygon(points: LngLatTuple[]) {
const TMap = window.TMap;
if (
!TMap?.LatLngBounds ||
!mapInstance.value ||
typeof mapInstance.value.fitBounds !== 'function'
) {
return;
}
const ring = normalizePolygon(points);
if (ring.length < 4) return;
const bounds = new TMap.LatLngBounds();
ring.forEach(([lng, lat]) => {
bounds.extend(new TMap.LatLng(lat, lng));
});
mapInstance.value.fitBounds(bounds, { padding: 50 });
}
function loadInitialPolygon() {
const initialPolygons = parsePolygonGeoJson(props.initialGeoJson);
const first = initialPolygons[0];
if (!first) {
setRawGeometries([]);
latestGeoJson.value = '';
polygonCount.value = 0;
return;
}
setPolygonToLayer(first);
latestGeoJson.value = stringifyPolygonGeoJson([first]);
polygonCount.value = 1;
fitMapToPolygon(first);
}
async function ensureMapReady() {
if (!props.open) return;
mapError.value = '';
isMapLoading.value = true;
logMapDebug('ensureMapReady:start', { open: props.open });
try {
const TMap = await loadTencentMapSdk();
logMapDebug('ensureMapReady:sdk-loaded', {
hasMapClass: !!TMap?.Map,
hasGeometryEditor: !!TMap?.tools?.GeometryEditor,
});
const container = await waitForMapContainerReady();
if (!container) {
throw new Error('地图容器尚未就绪,请关闭后重试');
}
logMapDebug('ensureMapReady:container-ready', {
width: container.clientWidth,
height: container.clientHeight,
});
const {
center: [centerLng, centerLat],
shouldResolveAsync,
} = await resolveInitialCenter();
logMapDebug('ensureMapReady:center-resolved', {
centerLng,
centerLat,
shouldResolveAsync,
});
if (!mapInstance.value) {
mapInstance.value = new TMap.Map(container, {
center: new TMap.LatLng(centerLat, centerLng),
zoom: 12,
pitch: 0,
rotation: 0,
});
logMapDebug('ensureMapReady:map-created');
} else if (typeof mapInstance.value.setCenter === 'function') {
mapInstance.value.setCenter(new TMap.LatLng(centerLat, centerLng));
logMapDebug('ensureMapReady:map-recentered');
}
if (!polygonLayer.value) {
polygonLayer.value = new TMap.MultiPolygon({
map: mapInstance.value,
styles: {
highlight: createHighlightPolygonStyle(TMap),
},
geometries: [],
});
logMapDebug('ensureMapReady:polygon-layer-created');
}
if (!TMap.tools?.GeometryEditor) {
throw new Error('腾讯地图绘图工具库未加载');
}
createEditor();
logMapDebug('ensureMapReady:editor-created', {
editorReady: !!geometryEditor.value,
});
loadInitialPolygon();
setEditorActionMode('INTERACT');
window.setTimeout(() => {
refreshMapViewport([centerLng, centerLat]);
}, 80);
window.setTimeout(() => {
refreshMapViewport([centerLng, centerLat]);
}, 360);
window.setTimeout(() => {
refreshMapViewport([centerLng, centerLat]);
}, 900);
if (shouldResolveAsync) {
void resolveAsyncCenterByAddress().then((asyncCenter) => {
if (!props.open || !mapInstance.value || !asyncCenter) return;
const [asyncLng, asyncLat] = asyncCenter;
if (typeof mapInstance.value.setCenter === 'function') {
mapInstance.value.setCenter(new TMap.LatLng(asyncLat, asyncLng));
}
refreshMapViewport(asyncCenter);
});
}
} catch (error) {
console.error('腾讯地图初始化失败', error);
logMapDebug('ensureMapReady:failed', error);
mapError.value =
error instanceof Error ? error.message : '腾讯地图加载失败,请稍后重试';
} finally {
isMapLoading.value = false;
logMapDebug('ensureMapReady:finished', {
mapReady: !!mapInstance.value,
editorReady: !!geometryEditor.value,
mapError: mapError.value,
});
}
}
async function startDrawing() {
logMapDebug('startDrawing:clicked', {
mapError: mapError.value,
isMapLoading: isMapLoading.value,
hasMap: !!mapInstance.value,
hasEditor: !!geometryEditor.value,
polygonCount: polygonCount.value,
});
if (mapError.value) return;
if (isMapLoading.value) {
message.info('地图加载中,请稍后再试');
return;
}
if (!mapInstance.value) {
logMapDebug('startDrawing:ensureMapReady');
await ensureMapReady();
}
if (!geometryEditor.value) {
createEditor();
}
if (!geometryEditor.value) {
message.error('绘图器尚未就绪,请稍后重试');
logMapDebug('startDrawing:editor-not-ready');
return;
}
if (polygonCount.value > 0) {
setRawGeometries([]);
latestGeoJson.value = '';
polygonCount.value = 0;
}
setEditorActionMode('DRAW');
isDrawing.value = true;
message.info('请在地图上依次点击,双击完成区域');
logMapDebug('startDrawing:entered-draw-mode');
}
function clearPolygon() {
if (!geometryEditor.value) {
createEditor();
}
setRawGeometries([]);
latestGeoJson.value = '';
polygonCount.value = 0;
setEditorActionMode('INTERACT');
isDrawing.value = false;
}
function handleConfirm() {
if (!latestGeoJson.value) {
syncGeoJsonFromGeometry();
}
if (!latestGeoJson.value) {
message.error('请先绘制配送区域');
return;
}
emit('confirm', latestGeoJson.value);
emit('update:open', false);
}
function handleClose() {
emit('update:open', false);
}
function getModalContainer(): HTMLElement {
return document.body;
}
let openLifecycleToken = 0;
function scheduleViewportRefresh(token: number) {
// 连续两次重排,兼容不同机器上的弹窗动画时机差异。
window.setTimeout(() => {
if (!props.open || token !== openLifecycleToken) return;
refreshMapViewport();
}, 80);
window.setTimeout(() => {
if (!props.open || token !== openLifecycleToken) return;
refreshMapViewport();
}, 260);
}
watch(
() => props.open,
async (open) => {
const token = ++openLifecycleToken;
logMapDebug('watch:open-changed', { open, token });
if (!open) {
isDrawing.value = false;
latestGeoJson.value = '';
polygonCount.value = 0;
destroyMapResources();
logMapDebug('watch:closed-resources-destroyed', { token });
return;
}
await nextTick();
if (!props.open || token !== openLifecycleToken) return;
await ensureMapReady();
if (!props.open || token !== openLifecycleToken) return;
scheduleViewportRefresh(token);
logMapDebug('watch:open-init-finished', { token });
},
{ immediate: true },
);
onBeforeUnmount(() => {
destroyMapResources();
});
</script>
<template>
<Modal
:open="props.open"
title="绘制配送区域"
width="980px"
:mask-closable="false"
:z-index="MAP_MODAL_Z_INDEX"
:get-container="getModalContainer"
:destroy-on-close="true"
@cancel="handleClose"
>
<div class="delivery-map-modal">
<div class="delivery-map-toolbar">
<Space size="small" wrap>
<Button
size="small"
type="primary"
:disabled="!canStartDrawing"
@click="startDrawing"
>
开始绘制
</Button>
<Button size="small" danger ghost @click="clearPolygon">清空</Button>
</Space>
<div class="delivery-map-status">
<span
class="delivery-map-status-pill"
:class="{ drawing: isDrawing }"
>
{{ isDrawing ? '绘制中(双击结束)' : '已暂停' }}
</span>
<span class="delivery-map-status-text">
已绘制 {{ polygonCount }} 单区域模式新绘制会覆盖旧区域
</span>
</div>
</div>
<Alert
v-if="mapError"
type="error"
:message="mapError"
show-icon
class="delivery-map-alert"
/>
<div
ref="mapContainerRef"
class="delivery-map-canvas"
:class="{ loading: isMapLoading }"
></div>
</div>
<template #footer>
<Button @click="handleClose">取消</Button>
<Button type="primary" :disabled="!hasPolygon" @click="handleConfirm">
确认使用该区域
</Button>
</template>
</Modal>
</template>

View File

@@ -6,8 +6,13 @@
*/
import type { PolygonZoneFormState } from '../types';
import { computed, ref } from 'vue';
import { Button, Drawer, Input, InputNumber } from 'ant-design-vue';
import { countPolygonsInGeoJson } from '../composables/delivery-page/geojson';
import DeliveryPolygonMapModal from './DeliveryPolygonMapModal.vue';
interface Props {
colorPalette: string[];
form: PolygonZoneFormState;
@@ -16,7 +21,12 @@ interface Props {
onSetEtaMinutes: (value: number) => void;
onSetMinOrderAmount: (value: number) => void;
onSetName: (value: string) => void;
onSetPolygonGeoJson: (value: string) => void;
onSetPriority: (value: number) => void;
initialCenterAddress: string;
initialCenterLatitude: null | number;
initialCenterLongitude: null | number;
fallbackCityText: string;
open: boolean;
title: string;
}
@@ -37,6 +47,31 @@ function readInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
const isMapModalOpen = ref(false);
const polygonCount = computed(() =>
countPolygonsInGeoJson(props.form.polygonGeoJson),
);
const formattedPolygonGeoJson = computed(() => {
const raw = props.form.polygonGeoJson?.trim() ?? '';
if (!raw) return '';
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
});
function openMapModal() {
isMapModalOpen.value = true;
}
function handleMapConfirm(geoJson: string) {
props.onSetPolygonGeoJson(geoJson);
}
</script>
<template>
@@ -45,6 +80,7 @@ function readInputValue(event: Event) {
:open="props.open"
:title="props.title"
:width="460"
:z-index="4000"
:mask-closable="true"
@update:open="(value) => emit('update:open', value)"
>
@@ -150,6 +186,28 @@ function readInputValue(event: Event) {
</div>
</div>
<div class="drawer-form-block">
<label class="drawer-form-label required">区域范围</label>
<div class="zone-map-actions">
<Button type="primary" ghost @click="openMapModal">
{{ polygonCount > 0 ? '重新绘制区域' : '绘制配送区域' }}
</Button>
</div>
<div class="zone-map-summary">
{{
polygonCount > 0 ? `已绘制 ${polygonCount} 块区域` : '暂未绘制区域'
}}
</div>
<div class="zone-geojson-preview">
<Input.TextArea
:value="formattedPolygonGeoJson"
:auto-size="{ minRows: 7, maxRows: 11 }"
readonly
placeholder="绘制完成后,这里会显示格式化坐标 GeoJSON 数据"
/>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
@@ -159,4 +217,15 @@ function readInputValue(event: Event) {
</div>
</template>
</Drawer>
<DeliveryPolygonMapModal
v-model:open="isMapModalOpen"
:initial-geo-json="props.form.polygonGeoJson"
:initial-center-latitude="props.initialCenterLatitude"
:initial-center-longitude="props.initialCenterLongitude"
:initial-center-address="props.initialCenterAddress"
:fallback-city-text="props.fallbackCityText"
:zone-color="props.form.color"
@confirm="handleMapConfirm"
/>
</template>

View File

@@ -8,6 +8,8 @@ import type { PolygonZoneDto } from '#/api/store-delivery';
import { Button, Card, Empty, Popconfirm } from 'ant-design-vue';
import { countPolygonsInGeoJson } from '../composables/delivery-page/geojson';
interface Props {
formatCurrency: (value: number) => string;
isSaving: boolean;
@@ -21,6 +23,10 @@ const emit = defineEmits<{
(event: 'delete', zoneId: string): void;
(event: 'edit', zone: PolygonZoneDto): void;
}>();
function getPolygonCount(geoJson: string) {
return countPolygonsInGeoJson(geoJson);
}
</script>
<template>
@@ -54,6 +60,9 @@ const emit = defineEmits<{
:style="{ background: zone.color }"
></span>
{{ zone.name }}
<span class="zone-shape-count">
{{ getPolygonCount(zone.polygonGeoJson) }}
</span>
</td>
<td>{{ props.formatCurrency(zone.deliveryFee) }}</td>
<td>{{ props.formatCurrency(zone.minOrderAmount) }}</td>

View File

@@ -58,6 +58,22 @@ export const DEFAULT_RADIUS_TIERS: RadiusTierDto[] = [
},
];
function createPolygonGeoJson(coordinates: Array<[number, number]>) {
return JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [coordinates],
},
properties: {},
},
],
});
}
export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
{
id: 'zone-core',
@@ -67,6 +83,13 @@ export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
minOrderAmount: 15,
etaMinutes: 20,
priority: 1,
polygonGeoJson: createPolygonGeoJson([
[116.389, 39.907],
[116.397, 39.907],
[116.397, 39.913],
[116.389, 39.913],
[116.389, 39.907],
]),
},
{
id: 'zone-cbd',
@@ -76,6 +99,13 @@ export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
minOrderAmount: 20,
etaMinutes: 35,
priority: 2,
polygonGeoJson: createPolygonGeoJson([
[116.456, 39.914],
[116.468, 39.914],
[116.468, 39.923],
[116.456, 39.923],
[116.456, 39.914],
]),
},
{
id: 'zone-slt',
@@ -85,6 +115,13 @@ export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
minOrderAmount: 25,
etaMinutes: 40,
priority: 3,
polygonGeoJson: createPolygonGeoJson([
[116.445, 39.928],
[116.455, 39.928],
[116.455, 39.936],
[116.445, 39.936],
[116.445, 39.928],
]),
},
];

View File

@@ -44,6 +44,8 @@ interface CreateDataActionsOptions {
isSettingsLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
mode: Ref<DeliveryMode>;
radiusCenterLatitude: Ref<null | number>;
radiusCenterLongitude: Ref<null | number>;
polygonZones: Ref<PolygonZoneDto[]>;
radiusTiers: Ref<RadiusTierDto[]>;
selectedStoreId: Ref<string>;
@@ -64,6 +66,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
function applySnapshot(snapshot: DeliverySettingsSnapshot) {
options.mode.value = snapshot.mode;
options.editingMode.value = snapshot.mode;
options.radiusCenterLatitude.value = snapshot.radiusCenterLatitude;
options.radiusCenterLongitude.value = snapshot.radiusCenterLongitude;
options.radiusTiers.value = sortRadiusTiers(snapshot.radiusTiers);
options.polygonZones.value = sortPolygonZones(snapshot.polygonZones);
syncGeneralSettings(snapshot.generalSettings);
@@ -73,6 +77,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
function buildCurrentSnapshot() {
return createSettingsSnapshot({
mode: options.mode.value,
radiusCenterLatitude: options.radiusCenterLatitude.value,
radiusCenterLongitude: options.radiusCenterLongitude.value,
radiusTiers: options.radiusTiers.value,
polygonZones: options.polygonZones.value,
generalSettings: options.generalSettings,
@@ -83,6 +89,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
function applyDefaultSettings() {
options.mode.value = DEFAULT_DELIVERY_MODE;
options.editingMode.value = DEFAULT_DELIVERY_MODE;
options.radiusCenterLatitude.value = null;
options.radiusCenterLongitude.value = null;
options.radiusTiers.value = sortRadiusTiers(DEFAULT_RADIUS_TIERS);
options.polygonZones.value = sortPolygonZones(DEFAULT_POLYGON_ZONES);
syncGeneralSettings(cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS));
@@ -98,6 +106,9 @@ export function createDataActions(options: CreateDataActionsOptions) {
options.mode.value = result.mode ?? DEFAULT_DELIVERY_MODE;
options.editingMode.value = options.mode.value;
options.radiusCenterLatitude.value = result.radiusCenterLatitude ?? null;
options.radiusCenterLongitude.value =
result.radiusCenterLongitude ?? null;
options.radiusTiers.value = sortRadiusTiers(
result.radiusTiers?.length ? result.radiusTiers : DEFAULT_RADIUS_TIERS,
);
@@ -177,6 +188,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
await saveStoreDeliverySettingsApi({
storeId: options.selectedStoreId.value,
mode: options.mode.value,
radiusCenterLatitude: options.radiusCenterLatitude.value,
radiusCenterLongitude: options.radiusCenterLongitude.value,
radiusTiers: cloneRadiusTiers(options.radiusTiers.value),
polygonZones: clonePolygonZones(options.polygonZones.value),
generalSettings: cloneGeneralSettings(options.generalSettings),

View File

@@ -0,0 +1,102 @@
/**
* 文件职责:配送区域 GeoJSON 工具。
* 1. 负责 Polygon FeatureCollection 与坐标数组互转。
* 2. 提供绘制结果统计能力,供 UI 展示已绘制数量。
*/
export type LngLatTuple = [number, number];
type JsonRecord = Record<string, unknown>;
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isLngLatTuple(value: unknown): value is LngLatTuple {
if (!Array.isArray(value) || value.length < 2) return false;
const [lng, lat] = value;
if (!isFiniteNumber(lng) || !isFiniteNumber(lat)) return false;
return lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90;
}
function closePolygonRing(points: LngLatTuple[]) {
if (points.length < 3) return [];
const normalized = points.map(([lng, lat]) => [lng, lat] as LngLatTuple);
const first = normalized[0];
const last = normalized[normalized.length - 1];
if (!first || !last) return [];
if (first[0] !== last[0] || first[1] !== last[1]) {
normalized.push([first[0], first[1]]);
}
return normalized;
}
/**
* 将多边形坐标序列转换为 FeatureCollection 字符串。
*/
export function stringifyPolygonGeoJson(polygons: LngLatTuple[][]) {
const features = polygons
.map((points) => closePolygonRing(points))
.filter((ring) => ring.length >= 4)
.map((ring) => ({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ring],
},
properties: {},
}));
return JSON.stringify({
type: 'FeatureCollection',
features,
});
}
/**
* 从 FeatureCollection 中解析外环点位,供地图回显与编辑。
*/
export function parsePolygonGeoJson(raw: string) {
if (!raw) return [] as LngLatTuple[][];
try {
const parsed = JSON.parse(raw) as JsonRecord;
if (parsed.type !== 'FeatureCollection') return [] as LngLatTuple[][];
const features = parsed.features;
if (!Array.isArray(features)) return [] as LngLatTuple[][];
const polygons: LngLatTuple[][] = [];
for (const feature of features) {
const featureRecord =
feature && typeof feature === 'object'
? (feature as JsonRecord)
: undefined;
const geometry =
featureRecord?.geometry && typeof featureRecord.geometry === 'object'
? (featureRecord.geometry as JsonRecord)
: undefined;
if (!geometry || geometry.type !== 'Polygon') continue;
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates) || coordinates.length === 0) continue;
const outerRing = coordinates[0];
if (!Array.isArray(outerRing)) continue;
const points = outerRing.filter((point) => isLngLatTuple(point)) as LngLatTuple[];
if (points.length >= 4) {
polygons.push(points.map(([lng, lat]) => [lng, lat]));
}
}
return polygons;
} catch {
return [] as LngLatTuple[][];
}
}
/**
* 统计 GeoJSON 内有效 Polygon 数量。
*/
export function countPolygonsInGeoJson(raw: string) {
return parsePolygonGeoJson(raw).length;
}

View File

@@ -33,10 +33,14 @@ export function createSettingsSnapshot(payload: {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
polygonZones: PolygonZoneDto[];
radiusCenterLatitude: null | number;
radiusCenterLongitude: null | number;
radiusTiers: RadiusTierDto[];
}): DeliverySettingsSnapshot {
return {
mode: payload.mode,
radiusCenterLatitude: payload.radiusCenterLatitude,
radiusCenterLongitude: payload.radiusCenterLongitude,
radiusTiers: cloneRadiusTiers(payload.radiusTiers),
polygonZones: clonePolygonZones(payload.polygonZones),
generalSettings: cloneGeneralSettings(payload.generalSettings),

View File

@@ -13,6 +13,8 @@ import type {
import { message } from 'ant-design-vue';
import { countPolygonsInGeoJson } from './geojson';
interface CreateZoneActionsOptions {
createZoneId: () => string;
getTierColorByIndex: (index: number) => string;
@@ -36,6 +38,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
options.zoneForm.etaMinutes = zone.etaMinutes;
options.zoneForm.priority = zone.priority;
options.zoneForm.color = zone.color;
options.zoneForm.polygonGeoJson = zone.polygonGeoJson ?? '';
options.isZoneDrawerOpen.value = true;
return;
}
@@ -48,6 +51,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
options.zoneForm.etaMinutes = 30;
options.zoneForm.priority = nextPriority;
options.zoneForm.color = options.getTierColorByIndex(nextPriority - 1);
options.zoneForm.polygonGeoJson = '';
options.isZoneDrawerOpen.value = true;
}
@@ -86,6 +90,11 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
options.zoneForm.color = value || '#1677ff';
}
/** 更新区域多边形 GeoJSON。 */
function setZonePolygonGeoJson(value: string) {
options.zoneForm.polygonGeoJson = value?.trim() ?? '';
}
/** 提交区域表单并更新列表。 */
function handleZoneSubmit() {
// 1. 必填校验。
@@ -95,6 +104,11 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
return;
}
if (countPolygonsInGeoJson(options.zoneForm.polygonGeoJson) <= 0) {
message.error('请先绘制配送区域');
return;
}
// 2. 优先级冲突校验。
const hasPriorityConflict = options.polygonZones.value.some((item) => {
if (item.id === options.zoneForm.id) return false;
@@ -114,6 +128,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
etaMinutes: options.zoneForm.etaMinutes,
priority: options.zoneForm.priority,
color: options.zoneForm.color,
polygonGeoJson: options.zoneForm.polygonGeoJson,
};
options.polygonZones.value =
@@ -149,6 +164,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePolygonGeoJson,
setZonePriority,
};
}

View File

@@ -23,6 +23,8 @@ import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
import { Modal } from 'ant-design-vue';
import { getMerchantInfoApi } from '#/api/merchant';
import {
DEFAULT_DELIVERY_MODE,
DEFAULT_GENERAL_SETTINGS,
@@ -49,6 +51,17 @@ import { createTierActions } from './delivery-page/tier-actions';
import { createZoneActions } from './delivery-page/zone-actions';
export function useStoreDeliveryPage() {
function buildRegionText(
province?: string,
city?: string,
district?: string,
) {
return [province, city, district]
.map((part) => part?.trim() ?? '')
.filter(Boolean)
.join('');
}
// 1. 页面 loading / submitting 状态。
const isStoreLoading = ref(false);
const isSettingsLoading = ref(false);
@@ -62,6 +75,9 @@ export function useStoreDeliveryPage() {
const deliveryMode = ref<DeliveryMode>(DEFAULT_DELIVERY_MODE);
// 当前编辑视图模式(仅影响页面展示,不直接落库)。
const editingMode = ref<DeliveryMode>(DEFAULT_DELIVERY_MODE);
// 半径配送中心点(仅半径模式使用)。
const radiusCenterLatitude = ref<null | number>(null);
const radiusCenterLongitude = ref<null | number>(null);
const radiusTiers = ref<RadiusTierDto[]>(
cloneRadiusTiers(DEFAULT_RADIUS_TIERS),
);
@@ -71,6 +87,8 @@ export function useStoreDeliveryPage() {
const generalSettings = reactive<DeliveryGeneralSettingsDto>(
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
);
const merchantCityText = ref('');
const merchantRegisteredAddress = ref('');
// 3. 页面弹窗与抽屉状态。
const isCopyModalOpen = ref(false);
@@ -99,6 +117,7 @@ export function useStoreDeliveryPage() {
etaMinutes: 30,
priority: 1,
color: getTierColorByIndex(0),
polygonGeoJson: '',
});
// 4. 页面衍生视图数据。
@@ -111,6 +130,37 @@ export function useStoreDeliveryPage() {
stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'',
);
const selectedStore = computed(
() =>
stores.value.find((store) => store.id === selectedStoreId.value) ?? null,
);
const mapCenterLatitude = computed(
() => selectedStore.value?.latitude ?? null,
);
const mapCenterLongitude = computed(
() => selectedStore.value?.longitude ?? null,
);
const mapCenterAddress = computed(() => {
const store = selectedStore.value;
if (!store) return '';
const normalizedAddress = store.address?.trim() ?? '';
if (normalizedAddress) return normalizedAddress;
return buildRegionText(store.province, store.city, store.district);
});
const mapFallbackCityText = computed(() => {
const store = selectedStore.value;
const storeCityText = buildRegionText(
store?.province,
store?.city,
store?.district,
);
if (storeCityText) return storeCityText;
if (merchantCityText.value) return merchantCityText.value;
return merchantRegisteredAddress.value;
});
const copyCandidates = computed(() =>
stores.value.filter((store) => store.id !== selectedStoreId.value),
@@ -151,6 +201,8 @@ export function useStoreDeliveryPage() {
isSettingsLoading,
isStoreLoading,
mode: deliveryMode,
radiusCenterLatitude,
radiusCenterLongitude,
polygonZones,
radiusTiers,
selectedStoreId,
@@ -202,6 +254,7 @@ export function useStoreDeliveryPage() {
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePolygonGeoJson,
setZonePriority,
} = createZoneActions({
createZoneId,
@@ -223,6 +276,22 @@ export function useStoreDeliveryPage() {
editingMode.value = value;
}
function setRadiusCenterLatitude(value: null | number) {
if (value === null || value === undefined) {
radiusCenterLatitude.value = null;
return;
}
radiusCenterLatitude.value = Number(value);
}
function setRadiusCenterLongitude(value: null | number) {
if (value === null || value === undefined) {
radiusCenterLongitude.value = null;
return;
}
radiusCenterLongitude.value = Number(value);
}
// 切换“当前生效模式”,二次确认后保存,防止误操作。
function setDeliveryMode(value: DeliveryMode) {
if (value === deliveryMode.value) return;
@@ -274,11 +343,32 @@ export function useStoreDeliveryPage() {
);
}
async function loadMerchantLocation() {
try {
const result = await getMerchantInfoApi();
const merchant = result.merchant;
merchantCityText.value = buildRegionText(
merchant?.province,
merchant?.city,
merchant?.district,
);
merchantRegisteredAddress.value =
merchant?.registeredAddress?.trim() ?? '';
} catch (error) {
console.error(error);
merchantCityText.value = '';
merchantRegisteredAddress.value = '';
}
}
// 7. 门店切换时自动刷新配置。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
deliveryMode.value = DEFAULT_DELIVERY_MODE;
editingMode.value = DEFAULT_DELIVERY_MODE;
radiusCenterLatitude.value = null;
radiusCenterLongitude.value = null;
radiusTiers.value = cloneRadiusTiers(DEFAULT_RADIUS_TIERS);
polygonZones.value = clonePolygonZones(DEFAULT_POLYGON_ZONES);
Object.assign(
@@ -291,16 +381,21 @@ export function useStoreDeliveryPage() {
await loadStoreSettings(storeId);
});
async function loadPageContext() {
await Promise.allSettled([loadStores(), loadMerchantLocation()]);
}
// 8. 页面首屏初始化。
onMounted(loadStores);
onMounted(loadPageContext);
// 9. 路由回到当前页时刷新门店列表,避免使用旧缓存。
onActivated(loadStores);
onActivated(loadPageContext);
return {
DELIVERY_MODE_OPTIONS,
copyCandidates,
copyTargetStoreIds,
deliveryMode,
editingMode,
formatCurrency,
formatDistanceRange,
generalSettings,
@@ -320,22 +415,29 @@ export function useStoreDeliveryPage() {
isStoreLoading,
isTierDrawerOpen,
isZoneDrawerOpen,
mapCenterAddress,
mapCenterLatitude,
mapCenterLongitude,
mapFallbackCityText,
openCopyModal,
openTierDrawer,
openZoneDrawer,
polygonZones,
radiusCenterLatitude,
radiusCenterLongitude,
radiusTiers,
resetFromSnapshot,
saveCurrentSettings,
selectedStoreId,
selectedStoreName,
setDeliveryMode,
editingMode,
setEditingMode,
setEtaAdjustmentMinutes,
setFreeDeliveryThreshold,
setHourlyCapacityLimit,
setMaxDeliveryDistance,
setRadiusCenterLatitude,
setRadiusCenterLongitude,
setSelectedStoreId,
setTierColor,
setTierDeliveryFee,
@@ -350,6 +452,7 @@ export function useStoreDeliveryPage() {
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePolygonGeoJson,
setZonePriority,
storeOptions,
tierColorPalette: TIER_COLOR_PALETTE,

View File

@@ -0,0 +1,129 @@
/**
* 文件职责:按需加载腾讯地图 JS SDK。
* 1. 使用全局 callback + 单例 Promise避免重复注入脚本。
* 2. 与 AdminUI 的加载策略保持一致,降低多实例冲突风险。
*/
declare global {
interface Window {
TMap?: any;
__tenantTencentMapInit?: () => void;
}
}
const SCRIPT_ID = 'tenant-tencent-map-gljs-sdk';
const CALLBACK_NAME = '__tenantTencentMapInit';
const SCRIPT_LOAD_TIMEOUT_MS = 12_000;
const TENCENT_MAP_LIBRARIES = 'visualization,geometry,vector,tools,service';
let mapSdkPromise: null | Promise<any> = null;
let scriptLoading = false;
const pendingResolvers: Array<(value: any) => void> = [];
const pendingRejectors: Array<(error: Error) => void> = [];
function getTencentMapKey() {
return (import.meta.env.VITE_TENCENT_MAP_KEY as string | undefined)?.trim();
}
function flushSuccess(tmap: any) {
const resolvers = pendingResolvers.splice(0);
pendingRejectors.splice(0);
resolvers.forEach((resolve) => resolve(tmap));
}
function flushError(error: Error) {
const rejectors = pendingRejectors.splice(0);
pendingResolvers.splice(0);
rejectors.forEach((reject) => reject(error));
}
function buildScriptUrl(mapKey: string) {
return `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(
mapKey,
)}&libraries=${TENCENT_MAP_LIBRARIES}&callback=${CALLBACK_NAME}`;
}
export async function loadTencentMapSdk() {
if (typeof window === 'undefined') {
throw new TypeError('当前环境不支持加载地图');
}
if (window.TMap) {
return window.TMap;
}
const mapKey = getTencentMapKey();
if (!mapKey) {
throw new Error('未配置腾讯地图 KeyVITE_TENCENT_MAP_KEY');
}
if (mapSdkPromise) {
return mapSdkPromise;
}
mapSdkPromise = new Promise<any>((resolve, reject) => {
pendingResolvers.push(resolve);
pendingRejectors.push(reject);
if (scriptLoading) {
return;
}
scriptLoading = true;
const completeWithError = (error: Error) => {
scriptLoading = false;
mapSdkPromise = null;
flushError(error);
};
const timeoutHandle = window.setTimeout(() => {
completeWithError(new Error('腾讯地图 SDK 加载超时'));
}, SCRIPT_LOAD_TIMEOUT_MS);
window[CALLBACK_NAME] = () => {
window.clearTimeout(timeoutHandle);
scriptLoading = false;
if (!window.TMap) {
completeWithError(new Error('腾讯地图 SDK 加载失败'));
return;
}
flushSuccess(window.TMap);
};
const existingScript = document.querySelector<HTMLScriptElement>(
`#${SCRIPT_ID}`,
);
if (existingScript) {
existingScript.addEventListener(
'error',
() => {
window.clearTimeout(timeoutHandle);
completeWithError(new Error('腾讯地图 SDK 加载失败'));
},
{ once: true },
);
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ID;
script.type = 'text/javascript';
script.async = true;
script.defer = true;
script.src = buildScriptUrl(mapKey);
script.addEventListener(
'error',
() => {
window.clearTimeout(timeoutHandle);
completeWithError(new Error('腾讯地图 SDK 加载失败'));
},
{ once: true },
);
document.body.append(script);
});
return mapSdkPromise;
}

View File

@@ -43,10 +43,16 @@ const {
isStoreLoading,
isTierDrawerOpen,
isZoneDrawerOpen,
mapCenterAddress,
mapCenterLatitude,
mapCenterLongitude,
mapFallbackCityText,
openCopyModal,
openTierDrawer,
openZoneDrawer,
polygonZones,
radiusCenterLatitude,
radiusCenterLongitude,
radiusTiers,
resetFromSnapshot,
saveCurrentSettings,
@@ -58,6 +64,8 @@ const {
setFreeDeliveryThreshold,
setHourlyCapacityLimit,
setMaxDeliveryDistance,
setRadiusCenterLatitude,
setRadiusCenterLongitude,
setSelectedStoreId,
setTierColor,
setTierDeliveryFee,
@@ -72,6 +80,7 @@ const {
setZoneEtaMinutes,
setZoneMinOrderAmount,
setZoneName,
setZonePolygonGeoJson,
setZonePriority,
storeOptions,
tierColorPalette,
@@ -102,46 +111,51 @@ const {
<template v-else>
<Spin :spinning="isPageLoading">
<DeliveryModeCard
:active-mode="deliveryMode"
:config-mode="editingMode"
:mode-options="DELIVERY_MODE_OPTIONS"
:radius-tiers="radiusTiers"
@change-active-mode="setDeliveryMode"
@change-config-mode="setEditingMode"
/>
<div class="delivery-cards-stack">
<DeliveryModeCard
:active-mode="deliveryMode"
:config-mode="editingMode"
:radius-center-latitude="radiusCenterLatitude"
:radius-center-longitude="radiusCenterLongitude"
:mode-options="DELIVERY_MODE_OPTIONS"
@change-active-mode="setDeliveryMode"
@change-config-mode="setEditingMode"
@change-radius-center-latitude="setRadiusCenterLatitude"
@change-radius-center-longitude="setRadiusCenterLongitude"
/>
<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"
/>
<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"
/>
<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"
/>
<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"
/>
</div>
</Spin>
</template>
@@ -171,6 +185,11 @@ const {
:on-set-eta-minutes="setZoneEtaMinutes"
:on-set-priority="setZonePriority"
:on-set-color="setZoneColor"
:on-set-polygon-geo-json="setZonePolygonGeoJson"
:initial-center-latitude="mapCenterLatitude"
:initial-center-longitude="mapCenterLongitude"
:initial-center-address="mapCenterAddress"
:fallback-city-text="mapFallbackCityText"
@update:open="setZoneDrawerOpen"
@submit="handleZoneSubmit"
/>

View File

@@ -2,6 +2,12 @@
.page-store-delivery {
max-width: 980px;
.delivery-cards-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-title {
font-size: 14px;
font-weight: 600;

View File

@@ -91,3 +91,97 @@
font-size: 15px;
border-radius: 10px;
}
.zone-map-summary {
margin-top: 10px;
font-size: 12px;
color: #64748b;
}
.zone-geojson-preview {
margin-top: 10px;
}
.zone-geojson-preview .ant-input {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.55;
color: #334155;
background: #f8fafc;
border-color: #dbe5f3;
border-radius: 8px;
}
.zone-geojson-preview .ant-input::placeholder {
color: #94a3b8;
}
.zone-map-actions {
display: flex;
gap: 10px;
align-items: center;
}
.delivery-map-modal {
display: flex;
flex-direction: column;
gap: 10px;
}
.delivery-map-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
align-items: center;
justify-content: space-between;
}
.delivery-map-status {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center;
font-size: 12px;
color: #64748b;
}
.delivery-map-status-pill {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 9px;
line-height: 22px;
color: #64748b;
background: #f8fafc;
border: 1px solid #dbe5f3;
border-radius: 999px;
}
.delivery-map-status-pill.drawing {
color: #1d4ed8;
background: #eff6ff;
border-color: #bfdbfe;
}
.delivery-map-status-text {
color: #94a3b8;
}
.delivery-map-alert {
margin-bottom: 2px;
}
.delivery-map-canvas {
width: 100%;
height: 520px;
overflow: hidden;
background: #f8fafc;
border: 1px solid #dbe5f3;
border-radius: 10px;
}
.delivery-map-canvas.loading {
opacity: 0.6;
}

View File

@@ -1,4 +1,4 @@
/* 文件职责:配送模式切换与地图占位样式。 */
/* 文件职责:配送模式切换与半径中心点输入样式。 */
.page-store-delivery {
.delivery-active-mode {
padding: 14px 16px;
@@ -70,140 +70,61 @@
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;
.radius-center-panel {
padding: 14px 16px;
background: #fbfcfe;
border: 1px solid #e5eaf5;
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 {
.radius-center-title {
margin-bottom: 10px;
font-size: 13px;
opacity: 0.8;
font-weight: 600;
color: #334155;
}
.radius-center-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.radius-center-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.radius-center-field label {
font-size: 12px;
color: #64748b;
}
.radius-center-field .field-input {
width: 100%;
}
.radius-center-hint {
margin-top: 10px;
font-size: 12px;
color: #64748b;
}
.polygon-mode-hint {
padding: 12px 14px;
font-size: 12px;
color: #64748b;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 8px;
}
}
@media (max-width: 768px) {
.page-store-delivery {
.radius-center-grid {
grid-template-columns: 1fr;
}
}
}

View File

@@ -17,25 +17,6 @@
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;
}
}
}

View File

@@ -56,4 +56,10 @@
vertical-align: middle;
border-radius: 3px;
}
.zone-shape-count {
margin-left: 4px;
font-size: 12px;
color: #64748b;
}
}

View File

@@ -29,12 +29,15 @@ export interface PolygonZoneFormState {
id: string;
minOrderAmount: number;
name: string;
polygonGeoJson: string;
priority: number;
}
export interface DeliverySettingsSnapshot {
generalSettings: DeliveryGeneralSettingsDto;
mode: DeliveryMode;
radiusCenterLatitude: null | number;
radiusCenterLongitude: null | number;
polygonZones: PolygonZoneDto[];
radiusTiers: RadiusTierDto[];
}