feat(project): align store delivery pages with live APIs and geocode status
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('未配置腾讯地图 Key(VITE_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;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,4 +56,10 @@
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.zone-shape-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user