@@ -0,0 +1,316 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
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'
const PAGE_SIZE = 15
const SCENE_COLORS : Record < string , string > = {
'办公园区' : '#3b82f6' , '商场商业体' : '#a855f7' , '社区居民' : '#22c55e' , '街边综合' : '#f59e0b' , '交通枢纽' : '#ef4444' , '校园档口' : '#06b6d4' , '特殊业态' : '#6b7280' ,
}
const RISK_COLORS : Record < string , string > = { '高风险:距离近且至少一家经营承压' : '#ef4444' , '中风险:需核查客群和配送圈重叠' : '#eab308' , '观察' : '#22c55e' }
type SiteTab = 'benchmark' | 'replication' | 'overlap' | 'district'
const TABS : { key : SiteTab ; label : string } [ ] = [
{ key : 'benchmark' , label : '分段基准' } ,
{ key : 'replication' , label : '复制评分' } ,
{ key : 'overlap' , label : '重叠风险' } ,
{ key : 'district' , label : '区域基准' } ,
]
export function SiteSelectionPage() {
const navigate = useNavigate ( )
const [ tab , setTab ] = useState < SiteTab > ( 'benchmark' )
const [ sceneFilter , setSceneFilter ] = useState ( '' )
const [ replPage , setReplPage ] = useState ( 1 )
const [ overlapPage , setOverlapPage ] = useState ( 1 )
const [ profilePage , setProfilePage ] = useState ( 1 )
const { data : profileData , isLoading : profileLoading } = useQuery ( {
queryKey : [ 'site-selection/profile' ] ,
queryFn : ( ) = > api . get ( '/site-selection/profile' ) ,
} )
const { data : segData , isLoading : segLoading } = useQuery ( {
queryKey : [ 'site-selection/segment-benchmark' ] ,
queryFn : ( ) = > api . get ( '/site-selection/segment-benchmark' ) ,
} )
const { data : replData , isLoading : replLoading } = useQuery ( {
queryKey : [ 'site-selection/replication' ] ,
queryFn : ( ) = > api . get ( '/site-selection/replication' ) ,
} )
const { data : overlapData , isLoading : overlapLoading } = useQuery ( {
queryKey : [ 'site-selection/overlap-risk' ] ,
queryFn : ( ) = > api . get ( '/site-selection/overlap-risk' ) ,
} )
const { data : districtData , isLoading : districtLoading } = useQuery ( {
queryKey : [ 'site-selection/district-benchmark' ] ,
queryFn : ( ) = > api . get ( '/site-selection/district-benchmark' ) ,
} )
const profileRows = ( ( profileData as any ) ? . data || [ ] ) . filter ( ( r : any ) = > ! sceneFilter || r . site_scene === sceneFilter )
const segRows = ( segData as any ) ? . data || [ ]
const replRows = ( replData as any ) ? . data || [ ]
const overlapRows = ( overlapData as any ) ? . data || [ ]
const districtRows = ( districtData as any ) ? . data || [ ]
const pagedRepl = useMemo ( ( ) = > replRows . slice ( ( replPage - 1 ) * PAGE_SIZE , replPage * PAGE_SIZE ) , [ replRows , replPage ] )
const pagedOverlap = useMemo ( ( ) = > overlapRows . slice ( ( overlapPage - 1 ) * PAGE_SIZE , overlapPage * PAGE_SIZE ) , [ overlapRows , overlapPage ] )
const pagedProfile = useMemo ( ( ) = > profileRows . slice ( ( profilePage - 1 ) * PAGE_SIZE , profilePage * PAGE_SIZE ) , [ profileRows , profilePage ] )
const pageLoading = profileLoading || segLoading || replLoading || overlapLoading || districtLoading
if ( pageLoading ) {
return < LoadingSpinner text = "加载选址分析数据..." / >
}
const totalStores = profileRows . length
const totalScenes = Object . keys ( profileRows . reduce ( ( acc : any , r : any ) = > { acc [ r . site_scene ] = true ; return acc } , { } ) ) . length
const highRiskCount = overlapRows . filter ( ( r : any ) = > r . overlap_risk ? . startsWith ( '高风险' ) ) . length
const topReplication = replRows . filter ( ( r : any ) = > r . replication_recommendation === '优先提炼选址原型' ) . length
const scatterData = profileRows . map ( ( r : any ) = > ( {
store_name : r.store_name ,
area_sqm : Number ( r . area_sqm || 0 ) ,
received_per_sqm : Number ( r . monthly_received_per_sqm || 0 ) ,
received : Number ( r . received || 0 ) ,
site_scene : r.site_scene ,
} ) )
return (
< div className = "space-y-4" >
< div >
< h1 className = "text-xl font-bold" > 门 店 选 址 分 析 < / h1 >
< 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 >
{ /* 面积×坪效散点图 */ }
< 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' } / >
) ) }
< / 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 >
{ /* Tab 切换 */ }
< div className = "flex gap-2 border-b" >
{ TABS . map ( ( t ) = > (
< button
key = { t . key }
onClick = { ( ) = > setTab ( t . key ) }
className = { ` px-4 py-2 text-sm font-medium transition-colors ${
tab === t . key ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'
} ` }
>
{ t . label }
< / button >
) ) }
< / div >
{ /* 分段基准 */ }
{ tab === 'benchmark' && (
< div className = "space-y-4" >
< CollapsibleSection title = { ` 场景 × 面积分段基准 ( ${ segRows . length } ) ` } subtitle = "各场景各面积段的经营基准,按坪效降序" >
< DataTable
columns = { [
{ key : 'site_scene' , label : '场景' } ,
{ key : 'area_band' , label : '面积段' } ,
{ key : 'store_count' , label : '门店数' , align : 'center' } ,
{ key : 'avg_area_sqm' , label : '平均面积' , align : 'right' , render : ( r ) = > ` ${ r . avg_area_sqm } ㎡ ` } ,
{ key : 'avg_received' , label : '平均实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_received ) } ,
{ key : 'median_received' , label : '中位实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . median_received ) } ,
{ key : 'avg_received_per_sqm' , label : '平均坪效' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_received_per_sqm ) } ,
{ key : 'median_received_per_sqm' , label : '中位坪效' , align : 'right' , render : ( r ) = > formatCurrency ( r . median_received_per_sqm ) } ,
{ key : 'avg_bill_value' , label : '客单价' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_bill_value ) } ,
{ key : 'avg_repeat_rate_pct' , label : '复购率' , align : 'right' , render : ( r ) = > formatPercent ( r . avg_repeat_rate_pct ) } ,
] }
data = { segRows }
/ >
< / CollapsibleSection >
< CollapsibleSection title = "门店画像明细" subtitle = "全部标准门店选址画像,可按场景筛选" >
< div className = "mb-3 flex gap-2" >
{ [ '' , '办公园区' , '商场商业体' , '社区居民' , '街边综合' ] . map ( ( f ) = > (
< button
key = { f || 'all' }
onClick = { ( ) = > { setSceneFilter ( f ) ; setProfilePage ( 1 ) } }
className = { ` rounded-md border px-3 py-1.5 text-sm ${ sceneFilter === f ? 'border-primary bg-primary text-primary-foreground' : '' } ` }
>
{ f || '全部' }
< / button >
) ) }
< / div >
< Pagination page = { profilePage } pageSize = { PAGE_SIZE } total = { profileRows . length } onPageChange = { setProfilePage } / >
< div className = "mt-3" >
< DataTable
columns = { [
{ key : 'store_name' , label : '门店' } ,
{ key : 'site_scene' , label : '场景' , render : ( r ) = > < span className = "rounded px-2 py-0.5 text-xs" style = { { background : ( SCENE_COLORS [ r . site_scene ] || '#999' ) + '20' , color : SCENE_COLORS [ r . site_scene ] || '#999' } } > { r . site_scene } < / span > } ,
{ key : 'area_band' , label : '面积段' } ,
{ key : 'area_sqm' , label : '面积(㎡)' , align : 'right' , render : ( r ) = > r . area_sqm ? ` ${ r . area_sqm } ㎡ ` : '-' } ,
{ key : 'received' , label : '实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . received ) } ,
{ key : 'monthly_received_per_sqm' , label : '坪效' , align : 'right' , render : ( r ) = > formatCurrency ( r . monthly_received_per_sqm ) } ,
{ key : 'avg_bill_value' , label : '客单价' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_bill_value ) } ,
{ key : 'repeat_rate_pct' , label : '复购率' , align : 'right' , render : ( r ) = > formatPercent ( r . repeat_rate_pct ) } ,
{ key : 'actual_food_cost_rate_pct' , label : '成本率' , align : 'right' , render : ( r ) = > formatPercent ( r . actual_food_cost_rate_pct ) } ,
] }
data = { pagedProfile }
onRowClick = { ( r ) = > navigate ( ` /stores/ ${ r . store_code } ` ) }
/ >
< / div >
< / CollapsibleSection >
< / div >
) }
{ /* 复制评分 */ }
{ tab === 'replication' && (
< CollapsibleSection title = { ` 门店复制评分 ( ${ replRows . length } ) ` } subtitle = "综合坪效、日均、复购、优惠纪律、成本、平台、执行七维评分" >
< Pagination page = { replPage } pageSize = { PAGE_SIZE } total = { replRows . length } onPageChange = { setReplPage } / >
< div className = "mt-3" >
< DataTable
columns = { [
{ key : 'store_name' , label : '门店' } ,
{ key : 'site_scene' , label : '场景' } ,
{ key : 'area_sqm' , label : '面积' , align : 'right' , render : ( r ) = > r . area_sqm ? ` ${ r . area_sqm } ㎡ ` : '-' } ,
{ key : 'received' , label : '实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . received ) } ,
{ key : 'monthly_received_per_sqm' , label : '坪效' , align : 'right' , render : ( r ) = > formatCurrency ( r . monthly_received_per_sqm ) } ,
{ key : 'nearest_store_name' , label : '最近门店' } ,
{ key : 'nearest_distance_km' , label : '距离(km)' , align : 'right' , render : ( r ) = > ` ${ r . nearest_distance_km } km ` } ,
{ key : 'site_replication_score' , label : '复制评分' , align : 'right' , render : ( r ) = > {
const v = Number ( r . site_replication_score || 0 )
return < span className = { v >= 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : '' } > { v . toFixed ( 2 ) } < / span >
} } ,
{ key : 'replication_recommendation' , label : '复制建议' , render : ( r ) = > {
const rec = r . replication_recommendation
const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
return < span className = { ` rounded px-2 py-0.5 text-xs ${ cls } ` } > { rec } < / span >
} } ,
{ key : 'spatial_recommendation' , label : '空间建议' , render : ( r ) = > < span className = "text-xs text-muted-foreground" > { r . spatial_recommendation } < / span > } ,
] }
data = { pagedRepl }
onRowClick = { ( r ) = > navigate ( ` /stores/ ${ r . store_code } ` ) }
/ >
< / div >
< / CollapsibleSection >
) }
{ /* 重叠风险 */ }
{ tab === 'overlap' && (
< div className = "space-y-4" >
< div className = "grid grid-cols-3 gap-3" >
< MetricCard title = "高度重叠(<1km)" value = { overlapRows . filter ( ( r : any ) = > r . distance_km < 1 ) . length } format = "number" description = "需做顾客来源和配送圈验证" / >
< MetricCard title = "较高重叠(1-2km)" value = { overlapRows . filter ( ( r : any ) = > r . distance_km >= 1 && r . distance_km < 2 ) . length } format = "number" description = "检查道路阻隔和商圈边界" / >
< MetricCard title = "观察(2-3km)" value = { overlapRows . filter ( ( r : any ) = > r . distance_km >= 2 ) . length } format = "number" description = "不能仅用直线距离判断" / >
< / div >
< CollapsibleSection title = { ` 空间重叠风险 ( ${ overlapRows . length } 对) ` } subtitle = "3公里内门店两两距离与重叠风险等级" >
< Pagination page = { overlapPage } pageSize = { PAGE_SIZE } total = { overlapRows . length } onPageChange = { setOverlapPage } / >
< div className = "mt-3" >
< DataTable
columns = { [
{ key : 'store_name_a' , label : '门店A' } ,
{ key : 'store_name_b' , label : '门店B' } ,
{ key : 'distance_km' , label : '距离(km)' , align : 'right' , render : ( r ) = > ` ${ Number ( r . distance_km ) . toFixed ( 2 ) } km ` } ,
{ key : 'received_a' , label : 'A实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . received_a ) } ,
{ key : 'received_b' , label : 'B实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . received_b ) } ,
{ key : 'overlap_risk' , label : '风险等级' , render : ( r ) = > {
const risk = r . overlap_risk
const color = RISK_COLORS [ risk ] || '#999'
return < span className = "rounded px-2 py-0.5 text-xs font-medium" style = { { background : color + '20' , color } } > { risk } < / span >
} } ,
] }
data = { pagedOverlap }
/ >
< / div >
< / CollapsibleSection >
< / div >
) }
{ /* 区域基准 */ }
{ tab === 'district' && (
< div className = "space-y-4" >
< CollapsibleSection title = "区域基准柱状图" subtitle = "各区域平均实收对比" >
< ResponsiveContainer width = "100%" height = { 280 } >
< BarChart data = { districtRows . slice ( 0 , 15 ) } margin = { { left : 20 } } >
< CartesianGrid strokeDasharray = "3 3" / >
< XAxis dataKey = "district" tick = { { fontSize : 10 } } angle = { - 30 } textAnchor = "end" height = { 60 } / >
< YAxis tick = { { fontSize : 10 } } tickFormatter = { ( v ) = > v >= 10000 ? ` ${ ( v / 10000 ) . toFixed ( 0 ) } 万 ` : v } / >
< Tooltip formatter = { ( v : any ) = > formatCurrency ( v ) } / >
< Bar dataKey = "avg_received" fill = "#3b82f6" name = "平均实收" / >
< Bar dataKey = "median_received" fill = "#22c55e" name = "中位实收" / >
< / BarChart >
< / ResponsiveContainer >
< / CollapsibleSection >
< CollapsibleSection title = { ` 区域基准明细 ( ${ districtRows . length } ) ` } subtitle = "按区域汇总的经营基准" >
< DataTable
columns = { [
{ key : 'district' , label : '区域' } ,
{ key : 'store_count' , label : '门店数' , align : 'center' } ,
{ key : 'avg_area_sqm' , label : '平均面积' , align : 'right' , render : ( r ) = > ` ${ r . avg_area_sqm } ㎡ ` } ,
{ key : 'total_received' , label : '总实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . total_received ) } ,
{ key : 'avg_received' , label : '平均实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_received ) } ,
{ key : 'median_received' , label : '中位实收' , align : 'right' , render : ( r ) = > formatCurrency ( r . median_received ) } ,
{ key : 'avg_received_per_sqm' , label : '平均坪效' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_received_per_sqm ) } ,
{ key : 'avg_bill_value' , label : '客单价' , align : 'right' , render : ( r ) = > formatCurrency ( r . avg_bill_value ) } ,
{ key : 'avg_repeat_rate_pct' , label : '复购率' , align : 'right' , render : ( r ) = > formatPercent ( r . avg_repeat_rate_pct ) } ,
{ key : 'p0_count' , label : 'P0' , align : 'center' , render : ( r ) = > r . p0_count > 0 ? < span className = "font-bold text-red-600" > { r . p0_count } < / span > : '0' } ,
{ key : 'p1_count' , label : 'P1' , align : 'center' , render : ( r ) = > r . p1_count > 0 ? < span className = "font-bold text-yellow-600" > { r . p1_count } < / span > : '0' } ,
] }
data = { districtRows }
/ >
< / CollapsibleSection >
< / div >
) }
< / div >
)
}