feat: 集成高德地图门店分布,形状区分类型+颜色区分场景,排除模式筛选,一键部署脚本
This commit is contained in:
Vendored
+13
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>马兰拉面数字化运营管理平台</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+51
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'办公园区': '#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 = `<circle cx="${half}" cy="${half}" r="${half - 1}" fill="${color}" fill-opacity="0.85" stroke="${color}" stroke-width="2"/>`
|
||||
} else if (shape === 'triangle') {
|
||||
svg = `<polygon points="${half},1 ${size - 1},${size - 1} 1,${size - 1}" fill="${color}" fill-opacity="0.85" stroke="${color}" stroke-width="2"/>`
|
||||
} else if (shape === 'square') {
|
||||
svg = `<rect x="2" y="2" width="${size - 4}" height="${size - 4}" fill="${color}" fill-opacity="0.85" stroke="${color}" stroke-width="2"/>`
|
||||
} else {
|
||||
svg = `<polygon points="${half},1 ${size - 1},${half} ${half},${size - 1} 1,${half}" fill="${color}" fill-opacity="0.85" stroke="${color}" stroke-width="2"/>`
|
||||
}
|
||||
return L.divIcon({
|
||||
className: 'store-shape-marker',
|
||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3));">${svg}</svg>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [half, half],
|
||||
})
|
||||
}
|
||||
|
||||
/** 形状图例 */
|
||||
function ShapeGlyph({ shape, color }: { shape: ShapeType; color: string }) {
|
||||
if (shape === 'circle') return <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: color }} />
|
||||
if (shape === 'triangle') return <span className="inline-block h-0 w-0" style={{ borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderBottom: `8px solid ${color}` }} />
|
||||
if (shape === 'square') return <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: color }} />
|
||||
return <span className="inline-block h-2.5 w-2.5 rotate-45 rounded-sm" style={{ background: color }} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店分布地图组件
|
||||
* 使用 Leaflet + 高德地图瓦片渲染门店地理分布
|
||||
* 数据库存储 GCJ-02 坐标,高德地图原生支持 GCJ-02,无需坐标转换
|
||||
* 支持排除多选叠加:点击按钮排除该类型,可叠加排除多个类型
|
||||
*/
|
||||
export function StoreMap({ stores, height = 400 }: StoreMapProps) {
|
||||
const [excluded, setExcluded] = useState<Set<StoreCategory>>(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 (
|
||||
<div className="flex items-center justify-center rounded border bg-muted/30 text-sm text-muted-foreground" style={{ height }}>
|
||||
暂无门店坐标数据
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 排除按钮 — 形状图例 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">点击排除:</span>
|
||||
{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 (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => toggleExclude(f.key)}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-all ${
|
||||
isExcluded ? 'opacity-40 line-through' : 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<ShapeGlyph shape={f.shape} color="#6b7280" />
|
||||
{f.label} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{excluded.size > 0 && (
|
||||
<button
|
||||
onClick={() => setExcluded(new Set())}
|
||||
className="rounded-md border px-2.5 py-1 text-xs font-medium text-primary hover:bg-primary/10"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 场景颜色图例 */}
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{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 (
|
||||
<span key={scene} className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded-full" style={{ background: color }} />
|
||||
{scene} ({count})
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ height, borderRadius: '0.5rem', overflow: 'hidden' }}>
|
||||
<MapContainer
|
||||
center={[39.9, 116.4]}
|
||||
zoom={11}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© 高德地图'
|
||||
url="https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}"
|
||||
subdomains={['1', '2', '3', '4']}
|
||||
/>
|
||||
<FitBounds points={latLngs} />
|
||||
{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 (
|
||||
<Marker
|
||||
key={store.store_code}
|
||||
position={[lat, lng]}
|
||||
icon={icon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] text-xs">
|
||||
<p className="mb-1 font-bold text-sm">{store.store_name}</p>
|
||||
<p>场景: <span className="font-medium" style={{ color }}>{store.site_scene}</span></p>
|
||||
<p>类型: {cat?.label || '未知'}</p>
|
||||
{store.district && <p>区域: {store.district}</p>}
|
||||
{store.business_address && <p>地址: {store.business_address}</p>}
|
||||
{store.area_sqm && <p>面积: {store.area_sqm}㎡</p>}
|
||||
{store.received != null && <p>实收: {formatCurrency(Number(store.received))}</p>}
|
||||
{store.monthly_received_per_sqm != null && (
|
||||
<p>坪效: {formatCurrency(Number(store.monthly_received_per_sqm))}</p>
|
||||
)}
|
||||
{store.action_priority && (
|
||||
<p>优先级: <span className="font-medium" style={{ color: store.action_priority.startsWith('P0') ? '#ef4444' : store.action_priority.startsWith('P1') ? '#f97316' : '#22c55e' }}>{store.action_priority}</span></p>
|
||||
)}
|
||||
{store.problem_count != null && <p>问题数: {store.problem_count}</p>}
|
||||
</div>
|
||||
</Popup>
|
||||
<Tooltip direction="top" offset={[0, -8]}>
|
||||
{store.store_name}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#f59e0b', '交通枢纽': '#ef4444', '校园档口': '#06b6d4', '特殊业态': '#6b7280',
|
||||
'办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#dc2626', '交通枢纽': '#f97316', '校园档口': '#06b6d4', '特殊业态': '#6b7280',
|
||||
}
|
||||
const RISK_COLORS: Record<string, string> = { '高风险:距离近且至少一家经营承压': '#ef4444', '中风险:需核查客群和配送圈重叠': '#eab308', '观察': '#22c55e' }
|
||||
|
||||
@@ -94,54 +95,64 @@ export function SiteSelectionPage() {
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">基于全部现有数据的选址决策支持 · 2026年4月 · 91家经营门店</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="标准门店数" value={totalStores} format="number" description="有面积和经营数据的标准门店" />
|
||||
<MetricCard title="场景类型" value={totalScenes} format="number" description="办公/社区/商场/街边/交通枢纽等" />
|
||||
<MetricCard title="高风险重叠对" value={highRiskCount} format="number" description="距离<1km且至少一家经营承压" />
|
||||
<MetricCard title="优先提炼原型" value={topReplication} format="number" description="复制评分≥75且问题数≤1" />
|
||||
</div>
|
||||
{/* 门店分布地图(左) + 指标+面积坪效分布(右) 左右并排 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* 左:门店分布地图 */}
|
||||
<CollapsibleSection title="门店分布地图" subtitle="按场景类型标注门店地理位置,点击查看详情">
|
||||
<StoreMap stores={profileRows} height={660} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 面积×坪效散点图 */}
|
||||
<CollapsibleSection title="面积 × 坪效分布" subtitle="每个点代表一家门店,颜色区分场景">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart margin={{ left: 20, right: 20, top: 10, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" dataKey="area_sqm" name="面积(㎡)" tick={{ fontSize: 10 }} label={{ value: '面积(㎡)', position: 'bottom', offset: 0, fontSize: 11 }} />
|
||||
<YAxis type="number" dataKey="received_per_sqm" name="月坪效(元/㎡)" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} />
|
||||
<ZAxis type="number" dataKey="received" range={[40, 400]} name="实收" />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
content={({ payload }: any) => {
|
||||
if (!payload || !payload.length) return null
|
||||
const d = payload[0].payload
|
||||
return (
|
||||
<div className="rounded border bg-white p-2 text-xs shadow">
|
||||
<p className="font-bold">{d.store_name}</p>
|
||||
<p>场景: {d.site_scene}</p>
|
||||
<p>面积: {d.area_sqm}㎡</p>
|
||||
<p>月坪效: {formatCurrency(d.received_per_sqm)}</p>
|
||||
<p>实收: {formatCurrency(d.received)}</p>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Scatter data={scatterData}>
|
||||
{scatterData.map((entry: any, i: number) => (
|
||||
<Cell key={i} fill={SCENE_COLORS[entry.site_scene] || '#999'} />
|
||||
{/* 右:指标 + 面积坪效散点图 */}
|
||||
<div className="flex flex-col gap-4 lg:h-[760px]">
|
||||
{/* 概览指标 — 两行靠左 */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard title="标准门店数" value={totalStores} format="number" description="有面积和经营数据的标准门店" />
|
||||
<MetricCard title="场景类型" value={totalScenes} format="number" description="办公/社区/商场/街边/交通枢纽等" />
|
||||
<MetricCard title="高风险重叠对" value={highRiskCount} format="number" description="距离<1km且至少一家经营承压" />
|
||||
<MetricCard title="优先提炼原型" value={topReplication} format="number" description="复制评分≥75且问题数≤1" />
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="面积 × 坪效分布" subtitle="每个点代表一家门店,颜色区分场景">
|
||||
<ResponsiveContainer width="100%" height={550}>
|
||||
<ScatterChart margin={{ left: 20, right: 20, top: 10, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" dataKey="area_sqm" name="面积(㎡)" tick={{ fontSize: 10 }} label={{ value: '面积(㎡)', position: 'bottom', offset: 0, fontSize: 11 }} />
|
||||
<YAxis type="number" dataKey="received_per_sqm" name="月坪效(元/㎡)" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} />
|
||||
<ZAxis type="number" dataKey="received" range={[40, 400]} name="实收" />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
content={({ payload }: any) => {
|
||||
if (!payload || !payload.length) return null
|
||||
const d = payload[0].payload
|
||||
return (
|
||||
<div className="rounded border bg-white p-2 text-xs shadow">
|
||||
<p className="font-bold">{d.store_name}</p>
|
||||
<p>场景: {d.site_scene}</p>
|
||||
<p>面积: {d.area_sqm}㎡</p>
|
||||
<p>月坪效: {formatCurrency(d.received_per_sqm)}</p>
|
||||
<p>实收: {formatCurrency(d.received)}</p>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Scatter data={scatterData}>
|
||||
{scatterData.map((entry: any, i: number) => (
|
||||
<Cell key={i} fill={SCENE_COLORS[entry.site_scene] || '#999'} />
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{Object.entries(SCENE_COLORS).map(([scene, color]) => (
|
||||
<span key={scene} className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded-full" style={{ background: color }} />
|
||||
{scene}
|
||||
</span>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{Object.entries(SCENE_COLORS).map(([scene, color]) => (
|
||||
<span key={scene} className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded-full" style={{ background: color }} />
|
||||
{scene}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-2 border-b">
|
||||
|
||||
@@ -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 "=========================================="
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user