第一次上传

This commit is contained in:
xxk
2026-06-11 10:31:24 +08:00
commit cfef094568
1523 changed files with 210650 additions and 0 deletions
@@ -0,0 +1,307 @@
<template>
<div class="relative bg-white py-4 dark:bg-gray-950">
<div class="text-center">
<h2 class="text-title-sm font-semibold text-gray-950 dark:text-white lg:text-title-md">全国IP分布热点图</h2>
<p v-if="currentId !== initialMapId" class="mt-3 text-sm text-sky-600 dark:text-sky-300">{{ currentName }}</p>
</div>
<div class="mt-10 grid items-center gap-8 lg:grid-cols-[290px_1fr_220px]">
<div class="lg:pt-4">
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200">节点遍布全国150+城市</h3>
<ul class="mt-6 grid gap-3 text-base leading-7 text-gray-600 dark:text-gray-300">
<li v-for="item in summaryItems" :key="item" class="flex gap-2">
<span class="mt-3 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-500 dark:bg-gray-400"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
<div>
<div class="relative min-h-[420px]">
<svg class="h-full min-h-[420px] w-full drop-shadow-[0_20px_36px_rgba(14,165,233,0.16)]" :viewBox="`0 0 ${svgWidth} ${svgHeight}`" role="img" aria-label="IP 节点热力地图">
<path
v-for="region in regions"
:key="region.key"
:d="region.path"
:fill="region.fill"
class="cursor-pointer stroke-white stroke-[1.3] transition hover:opacity-80 dark:stroke-gray-950"
@click="drillDown(region)"
/>
<text
v-for="region in labelRegions"
:key="`${region.key}-label`"
:x="region.labelX"
:y="region.labelY"
class="pointer-events-none select-none fill-gray-700 text-[11px] font-semibold dark:fill-gray-200"
text-anchor="middle"
>
{{ region.name }}
</text>
</svg>
<div v-if="loading" class="absolute inset-0 grid place-items-center bg-white/70 text-sm font-semibold text-sky-700 backdrop-blur dark:bg-gray-950/70 dark:text-sky-200">
正在加载地图...
</div>
<div v-if="error" class="absolute inset-x-4 bottom-4 rounded-xl bg-error-50 px-4 py-3 text-sm text-error-600 dark:bg-error-500/15 dark:text-error-300">
{{ error }}
</div>
</div>
<div class="mt-6 flex flex-wrap items-center justify-center gap-5 text-sm text-gray-700 dark:text-gray-300">
<span class="inline-flex items-center gap-2">
<i class="h-4 w-6 rounded bg-[#1f7df2]"></i>
已开通
</span>
<span class="inline-flex items-center gap-2">
<i class="h-4 w-6 rounded bg-[#d8edff]"></i>
暂未开通
</span>
</div>
</div>
<div class="flex h-full flex-col justify-end gap-4 pb-12">
<strong class="text-xl font-semibold text-gray-600 dark:text-gray-300">全国IP分布情况</strong>
<span class="text-sm text-gray-400 dark:text-gray-500">点击地图区域可进入下一级</span>
<button
v-if="currentId !== initialMapId"
type="button"
class="h-10 w-fit rounded-lg border border-sky-200 bg-white px-4 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/20 dark:bg-white/[0.06] dark:text-sky-200"
@click="resetMap"
>
返回全国
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
type Position = [number, number]
type Ring = Position[]
type PolygonCoordinates = Ring[]
type MultiPolygonCoordinates = PolygonCoordinates[]
type GeoFeature = {
type: 'Feature'
properties?: {
name?: string
adcode?: number | string
center?: Position
centroid?: Position
childrenNum?: number
}
geometry?: {
type: 'Polygon' | 'MultiPolygon'
coordinates: PolygonCoordinates | MultiPolygonCoordinates
}
}
type GeoJson = {
type: 'FeatureCollection'
features: GeoFeature[]
}
type RegionPath = {
key: string
name: string
adcode: string
path: string
fill: string
labelX: number
labelY: number
}
const initialMapId = '100000_full'
const svgWidth = 760
const svgHeight = 500
const padding = 24
const openedColor = '#1f7df2'
const unopenedColor = '#d8edff'
const openedProvinceCodes = new Set([
'110000',
'120000',
'130000',
'140000',
'210000',
'220000',
'230000',
'310000',
'320000',
'330000',
'340000',
'350000',
'360000',
'370000',
'410000',
'420000',
'430000',
'440000',
'450000',
'460000',
'500000',
'510000',
'520000',
'530000',
])
const summaryItems = ['日更IP量超300万+', '可用率95%以上', '支持HTTP/HTTPS']
const currentId = ref(initialMapId)
const currentName = ref('全国 IP 节点热力图')
const geoJson = ref<GeoJson | null>(null)
const loading = ref(false)
const error = ref('')
const regions = computed<RegionPath[]>(() => {
if (!geoJson.value?.features?.length) return []
return buildRegionPaths(geoJson.value.features)
})
const labelRegions = computed(() => {
if (currentId.value === initialMapId) return []
return regions.value.length <= 45 ? regions.value : regions.value.slice(0, 45)
})
onMounted(() => {
void loadMap(initialMapId, '全国 IP 节点热力图', false)
})
async function drillDown(region: RegionPath) {
if (!region.adcode) {
await resetMap()
return
}
await loadMap(`${region.adcode}_full`, `${region.name} IP 节点热力图`, true)
}
async function resetMap() {
await loadMap(initialMapId, '全国 IP 节点热力图', false)
}
async function loadMap(id: string, name: string, fallbackToRoot: boolean) {
loading.value = true
error.value = ''
try {
const response = await fetch(mapUrl(id))
if (!response.ok) throw new Error(`地图数据请求失败:${response.status}`)
const payload = await response.json()
const data = normalizeGeoJson(payload)
if (!data?.features?.length) throw new Error('暂无下级地图数据')
geoJson.value = data
currentId.value = id
currentName.value = name
} catch (err) {
if (fallbackToRoot) {
await loadMap(initialMapId, '全国 IP 节点热力图', false)
error.value = '当前区域暂无下级地图,已返回全国。'
return
}
error.value = err instanceof Error ? err.message : '地图数据加载失败'
} finally {
loading.value = false
}
}
function mapUrl(id: string) {
const normalizedId = id.endsWith('_full') ? id : `${id}_full`
const base = window.location.hostname.endsWith('qiyunip.com') ? '' : 'https://www.qiyunip.com'
return `${base}/api/map/getjson?id=${encodeURIComponent(normalizedId)}`
}
function normalizeGeoJson(payload: unknown): GeoJson | null {
const value = payload as { type?: string; features?: GeoFeature[]; data?: unknown }
if (value?.type === 'FeatureCollection' && Array.isArray(value.features)) return value as GeoJson
const data = value?.data as { type?: string; features?: GeoFeature[] } | undefined
if (data?.type === 'FeatureCollection' && Array.isArray(data.features)) return data as GeoJson
return null
}
function buildRegionPaths(features: GeoFeature[]) {
const bbox = getFeatureBounds(features)
const scale = Math.min(
(svgWidth - padding * 2) / Math.max(bbox.maxX - bbox.minX, 1),
(svgHeight - padding * 2) / Math.max(bbox.maxY - bbox.minY, 1),
)
const offsetX = (svgWidth - (bbox.maxX - bbox.minX) * scale) / 2
const offsetY = (svgHeight - (bbox.maxY - bbox.minY) * scale) / 2
const project = ([lng, lat]: Position) => ({
x: offsetX + (lng - bbox.minX) * scale,
y: offsetY + (bbox.maxY - lat) * scale,
})
return features
.map((feature, index) => {
const name = feature.properties?.name || '未知区域'
const adcode = feature.properties?.adcode ? String(feature.properties.adcode) : ''
const center = feature.properties?.centroid || feature.properties?.center || getFeatureCenter(feature)
const label = project(center)
return {
key: `${adcode || name}-${index}`,
name,
adcode,
path: geometryToPath(feature, project),
fill: isOpenedRegion(adcode, index) ? openedColor : unopenedColor,
labelX: label.x,
labelY: label.y,
}
})
.filter((item) => item.path)
}
function geometryToPath(feature: GeoFeature, project: (position: Position) => { x: number; y: number }) {
const geometry = feature.geometry
if (!geometry) return ''
if (geometry.type === 'Polygon') return polygonToPath(geometry.coordinates as PolygonCoordinates, project)
if (geometry.type === 'MultiPolygon') {
return (geometry.coordinates as MultiPolygonCoordinates).map((polygon) => polygonToPath(polygon, project)).join(' ')
}
return ''
}
function polygonToPath(polygon: PolygonCoordinates, project: (position: Position) => { x: number; y: number }) {
return polygon
.map((ring) =>
ring
.map((position, index) => {
const point = project(position)
return `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)},${point.y.toFixed(2)}`
})
.join(' ') + ' Z',
)
.join(' ')
}
function getFeatureBounds(features: GeoFeature[]) {
const positions = features.flatMap((feature) => getFeaturePositions(feature))
return positions.reduce(
(bbox, [lng, lat]) => ({
minX: Math.min(bbox.minX, lng),
minY: Math.min(bbox.minY, lat),
maxX: Math.max(bbox.maxX, lng),
maxY: Math.max(bbox.maxY, lat),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
)
}
function getFeaturePositions(feature: GeoFeature) {
const geometry = feature.geometry
if (!geometry) return []
if (geometry.type === 'Polygon') return (geometry.coordinates as PolygonCoordinates).flat()
return (geometry.coordinates as MultiPolygonCoordinates).flat(2)
}
function getFeatureCenter(feature: GeoFeature): Position {
const positions = getFeaturePositions(feature)
if (!positions.length) return [105, 35]
const sum = positions.reduce((acc, [lng, lat]) => ({ lng: acc.lng + lng, lat: acc.lat + lat }), { lng: 0, lat: 0 })
return [sum.lng / positions.length, sum.lat / positions.length]
}
function isOpenedRegion(adcode: string, index: number) {
if (!adcode) return false
if (currentId.value === initialMapId) return openedProvinceCodes.has(adcode)
return index % 3 !== 0
}
</script>