From 722db28d066b0394a12ddeaf147ba121d2993bf2 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Mon, 27 Jul 2026 19:55:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E9=AB=98=E5=BE=B7?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E9=97=A8=E5=BA=97=E5=88=86=E5=B8=83=EF=BC=8C?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E5=8C=BA=E5=88=86=E7=B1=BB=E5=9E=8B+?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E5=8C=BA=E5=88=86=E5=9C=BA=E6=99=AF=EF=BC=8C?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E6=A8=A1=E5=BC=8F=E7=AD=9B=E9=80=89=EF=BC=8C?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 13 ++ client/index.html | 1 + client/package-lock.json | 51 +++++ client/package.json | 3 + client/src/components/StoreMap.tsx | 248 +++++++++++++++++++++++++ client/src/pages/SiteSelectionPage.tsx | 103 +++++----- deploy.sh | 46 +++++ netlify.toml | 15 ++ 8 files changed, 434 insertions(+), 46 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 client/src/components/StoreMap.tsx create mode 100755 deploy.sh create mode 100644 netlify.toml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f70df4b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/flutter/ephemeral": true, + "**/Flutter/ephemeral": true, + "**/.symlinks": true, + "**/.plugin_symlinks": true + } +} \ No newline at end of file diff --git a/client/index.html b/client/index.html index 0d2350c..3354d21 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,7 @@ 马兰拉面数字化运营管理平台 +
diff --git a/client/package-lock.json b/client/package-lock.json index 1575ca1..88b5040 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,14 +13,17 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "leaflet": "^1.9.4", "lucide-react": "^0.408.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.24.1", "recharts": "^2.12.7", "tailwind-merge": "^2.4.0" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^26.1.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -815,6 +818,17 @@ "node": ">= 8" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.3.tgz", @@ -1361,6 +1375,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmmirror.com/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.1.1.tgz", @@ -2420,6 +2451,12 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2895,6 +2932,20 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/client/package.json b/client/package.json index f310162..39e94f0 100644 --- a/client/package.json +++ b/client/package.json @@ -13,14 +13,17 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "leaflet": "^1.9.4", "lucide-react": "^0.408.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.24.1", "recharts": "^2.12.7", "tailwind-merge": "^2.4.0" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^26.1.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/client/src/components/StoreMap.tsx b/client/src/components/StoreMap.tsx new file mode 100644 index 0000000..e9decba --- /dev/null +++ b/client/src/components/StoreMap.tsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useState } from 'react' +import { MapContainer, TileLayer, Popup, Tooltip, useMap, Marker } from 'react-leaflet' +import L from 'leaflet' +import { formatCurrency } from '@/lib/utils' + +// 修复 Leaflet 默认 marker icon(本组件使用 CircleMarker,不需要默认 icon,但预防性修复) +delete (L.Icon.Default.prototype as any)._getIconUrl +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', +}) + +const SCENE_COLORS: Record = { + '办公园区': '#3b82f6', + '商场商业体': '#a855f7', + '社区居民': '#22c55e', + '街边综合': '#dc2626', + '交通枢纽': '#f97316', + '校园档口': '#06b6d4', + '特殊业态': '#6b7280', +} + +export interface StoreMapItem { + store_code: string + store_name: string + site_scene: string + latitude_gcj02: number | null + longitude_gcj02: number | null + received?: number | null + monthly_received_per_sqm?: number | null + area_sqm?: number | null + business_address?: string | null + district?: string | null + action_priority?: string | null + problem_count?: number | null +} + +interface StoreMapProps { + stores: StoreMapItem[] + height?: number +} + +/** + * 自动适配地图视野到所有标记点 + */ +function FitBounds({ points }: { points: [number, number][] }) { + const map = useMap() + useEffect(() => { + if (points.length === 0) return + if (points.length === 1) { + map.setView(points[0], 13) + } else { + const bounds = L.latLngBounds(points) + map.fitBounds(bounds, { padding: [30, 30] }) + } + }, [points, map]) + return null +} + +/** 门店类型 → 形状 */ +type StoreCategory = 'benchmark' | 'normal' | 'p1' | 'p0' + +type ShapeType = 'circle' | 'triangle' | 'square' | 'diamond' + +const CATEGORY_SHAPES: { key: StoreCategory; label: string; shape: ShapeType; match: (s: StoreMapItem) => boolean }[] = [ + { key: 'benchmark', label: '标杆门店', shape: 'circle', match: (s) => !s.action_priority?.startsWith('P0') && !s.action_priority?.startsWith('P1') && (s.problem_count ?? 99) <= 1 }, + { key: 'normal', label: '正常门店', shape: 'diamond', match: (s) => !s.action_priority?.startsWith('P0') && !s.action_priority?.startsWith('P1') && (s.problem_count ?? 99) > 1 }, + { key: 'p1', label: 'P1 重点整改', shape: 'triangle', match: (s) => !!s.action_priority?.startsWith('P1') }, + { key: 'p0', label: 'P0 紧急整改', shape: 'square', match: (s) => !!s.action_priority?.startsWith('P0') }, +] + +/** 创建 SVG 形状图标 */ +function createShapeIcon(shape: ShapeType, color: string, size: number): L.DivIcon { + const half = size / 2 + let svg = '' + if (shape === 'circle') { + svg = `` + } else if (shape === 'triangle') { + svg = `` + } else if (shape === 'square') { + svg = `` + } else { + svg = `` + } + return L.divIcon({ + className: 'store-shape-marker', + html: `${svg}`, + iconSize: [size, size], + iconAnchor: [half, half], + }) +} + +/** 形状图例 */ +function ShapeGlyph({ shape, color }: { shape: ShapeType; color: string }) { + if (shape === 'circle') return + if (shape === 'triangle') return + if (shape === 'square') return + return +} + +/** + * 门店分布地图组件 + * 使用 Leaflet + 高德地图瓦片渲染门店地理分布 + * 数据库存储 GCJ-02 坐标,高德地图原生支持 GCJ-02,无需坐标转换 + * 支持排除多选叠加:点击按钮排除该类型,可叠加排除多个类型 + */ +export function StoreMap({ stores, height = 400 }: StoreMapProps) { + const [excluded, setExcluded] = useState>(new Set()) + + const toggleExclude = (key: StoreCategory) => { + setExcluded((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + // 至少保留一个有数据可显示的类型 + const activeKeys = CATEGORY_SHAPES.filter((f) => stores.some((s) => s.latitude_gcj02 != null && f.match(s))) + const remaining = activeKeys.filter((f) => !next.has(f.key) && f.key !== key) + if (remaining.length === 0) return prev + next.add(key) + } + return next + }) + } + + const points = useMemo(() => { + return stores + .filter((s) => s.latitude_gcj02 != null && s.longitude_gcj02 != null) + .filter((s) => { + // 排除被选中的类型 + return !CATEGORY_SHAPES.some((f) => excluded.has(f.key) && f.match(s)) + }) + .map((s) => { + return { store: s, lat: Number(s.latitude_gcj02), lng: Number(s.longitude_gcj02) } + }) + }, [stores, excluded]) + + const latLngs = useMemo(() => points.map((p) => [p.lat, p.lng] as [number, number]), [points]) + + if (points.length === 0) { + return ( +
+ 暂无门店坐标数据 +
+ ) + } + + return ( +
+ {/* 排除按钮 — 形状图例 */} +
+ 点击排除: + {CATEGORY_SHAPES.map((f) => { + const count = stores.filter((s) => s.latitude_gcj02 != null && f.match(s)).length + if (count === 0) return null + const isExcluded = excluded.has(f.key) + return ( + + ) + })} + {excluded.size > 0 && ( + + )} +
+ + {/* 场景颜色图例 */} +
+ {Object.entries(SCENE_COLORS).map(([scene, color]) => { + const count = stores.filter((s) => s.latitude_gcj02 != null && s.site_scene === scene).length + if (count === 0) return null + return ( + + + {scene} ({count}) + + ) + })} +
+ +
+ + + + {points.map(({ store, lat, lng }) => { + const cat = CATEGORY_SHAPES.find((f) => f.match(store)) + const shape = cat?.shape || 'circle' + const color = SCENE_COLORS[store.site_scene] || '#999' + const size = store.received ? Math.max(12, Math.min(24, Math.sqrt(Number(store.received)) / 15)) : 14 + const icon = createShapeIcon(shape, color, size) + return ( + + +
+

{store.store_name}

+

场景: {store.site_scene}

+

类型: {cat?.label || '未知'}

+ {store.district &&

区域: {store.district}

} + {store.business_address &&

地址: {store.business_address}

} + {store.area_sqm &&

面积: {store.area_sqm}㎡

} + {store.received != null &&

实收: {formatCurrency(Number(store.received))}

} + {store.monthly_received_per_sqm != null && ( +

坪效: {formatCurrency(Number(store.monthly_received_per_sqm))}

+ )} + {store.action_priority && ( +

优先级: {store.action_priority}

+ )} + {store.problem_count != null &&

问题数: {store.problem_count}

} +
+
+ + {store.store_name} + +
+ ) + })} +
+
+
+ ) +} diff --git a/client/src/pages/SiteSelectionPage.tsx b/client/src/pages/SiteSelectionPage.tsx index 09b8449..4d31b74 100644 --- a/client/src/pages/SiteSelectionPage.tsx +++ b/client/src/pages/SiteSelectionPage.tsx @@ -9,10 +9,11 @@ import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' import { useState, useMemo } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, ReferenceLine, Cell } from 'recharts' +import { StoreMap } from '@/components/StoreMap' const PAGE_SIZE = 15 const SCENE_COLORS: Record = { - '办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#f59e0b', '交通枢纽': '#ef4444', '校园档口': '#06b6d4', '特殊业态': '#6b7280', + '办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#dc2626', '交通枢纽': '#f97316', '校园档口': '#06b6d4', '特殊业态': '#6b7280', } const RISK_COLORS: Record = { '高风险:距离近且至少一家经营承压': '#ef4444', '中风险:需核查客群和配送圈重叠': '#eab308', '观察': '#22c55e' } @@ -94,54 +95,64 @@ export function SiteSelectionPage() {

基于全部现有数据的选址决策支持 · 2026年4月 · 91家经营门店

- {/* 概览指标 */} -
- - - - -
+ {/* 门店分布地图(左) + 指标+面积坪效分布(右) 左右并排 */} +
+ {/* 左:门店分布地图 */} + + + - {/* 面积×坪效散点图 */} - - - - - - v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} /> - - { - if (!payload || !payload.length) return null - const d = payload[0].payload - return ( -
-

{d.store_name}

-

场景: {d.site_scene}

-

面积: {d.area_sqm}㎡

-

月坪效: {formatCurrency(d.received_per_sqm)}

-

实收: {formatCurrency(d.received)}

-
- ) - }} - /> - - {scatterData.map((entry: any, i: number) => ( - + {/* 右:指标 + 面积坪效散点图 */} +
+ {/* 概览指标 — 两行靠左 */} +
+ + + + +
+ + + + + + + v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} /> + + { + if (!payload || !payload.length) return null + const d = payload[0].payload + return ( +
+

{d.store_name}

+

场景: {d.site_scene}

+

面积: {d.area_sqm}㎡

+

月坪效: {formatCurrency(d.received_per_sqm)}

+

实收: {formatCurrency(d.received)}

+
+ ) + }} + /> + + {scatterData.map((entry: any, i: number) => ( + + ))} + +
+
+
+ {Object.entries(SCENE_COLORS).map(([scene, color]) => ( + + + {scene} + ))} - - - -
- {Object.entries(SCENE_COLORS).map(([scene, color]) => ( - - - {scene} - - ))} +
+
-
+
{/* Tab 切换 */}
diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..55b79b5 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================ +# 一键部署前端到 dm.all8ai.top 服务器 +# 用法: bash deploy.sh +# ============================================================ + +set -e + +# 服务器配置 +SERVER_IP="152.136.182.184" +SERVER_USER="ubuntu" +REMOTE_PATH="/var/www/dm.all8ai.top" + +# 项目路径 +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLIENT_DIR="$PROJECT_DIR/client" + +echo "==========================================" +echo " 部署前端到 dm.all8ai.top" +echo "==========================================" +echo "" + +# 1. 构建前端 +echo "[1/3] 构建前端..." +cd "$CLIENT_DIR" +npm run build +echo "✓ 构建完成" +echo "" + +# 2. 清理服务器旧文件 +echo "[2/3] 清理服务器旧文件..." +ssh "$SERVER_USER@$SERVER_IP" "rm -rf $REMOTE_PATH/assets" +echo "✓ 旧文件已清理" +echo "" + +# 3. 上传新构建产物 +echo "[3/3] 上传构建产物..." +scp -r "$CLIENT_DIR/dist/"* "$SERVER_USER@$SERVER_IP:$REMOTE_PATH/" +echo "✓ 上传完成" +echo "" + +# 验证 +echo "==========================================" +echo " 部署成功!" +echo " 访问: https://dm.all8ai.top" +echo "==========================================" diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..5b019f9 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,15 @@ +[build] + base = "client" + command = "npm run build" + publish = "dist" + +[[redirects]] + from = "/api/*" + to = "https://dm.all8ai.top/api/:splat" + status = 200 + force = true + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200