feat: add new file
This commit is contained in:
7
src/api/situation.ts
Normal file
7
src/api/situation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
|
||||
export async function fetchSituation(): Promise<MilitarySituation> {
|
||||
const res = await fetch('/api/situation')
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
30
src/api/websocket.ts
Normal file
30
src/api/websocket.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
type Handler = (data: unknown) => void
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let handler: Handler | null = null
|
||||
|
||||
function getUrl(): string {
|
||||
return `${window.location.origin.replace(/^http/, 'ws')}/ws`
|
||||
}
|
||||
|
||||
export function connectSituationWebSocket(onData: Handler): () => void {
|
||||
handler = onData
|
||||
if (ws?.readyState === WebSocket.OPEN) return () => {}
|
||||
|
||||
ws = new WebSocket(getUrl())
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'situation' && msg.data) handler?.(msg.data)
|
||||
} catch (_) {}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
ws = null
|
||||
setTimeout(() => handler && connectSituationWebSocket(handler), 3000)
|
||||
}
|
||||
return () => {
|
||||
handler = null
|
||||
ws?.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
68
src/components/BaseStatusPanel.tsx
Normal file
68
src/components/BaseStatusPanel.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react'
|
||||
import { MapPin, AlertTriangle, AlertCircle } from 'lucide-react'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
|
||||
interface BaseStatusPanelProps {
|
||||
keyLocations: MilitarySituation['usForces']['keyLocations']
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TOTAL_BASES = 62
|
||||
|
||||
export function BaseStatusPanel({ keyLocations, className = '' }: BaseStatusPanelProps) {
|
||||
const stats = useMemo(() => {
|
||||
const bases = (keyLocations || []).filter((loc) => loc.type === 'Base')
|
||||
let attacked = 0
|
||||
let severe = 0
|
||||
let moderate = 0
|
||||
let light = 0
|
||||
for (const b of bases) {
|
||||
const s = b.status ?? 'operational'
|
||||
if (s === 'attacked') attacked++
|
||||
const lvl = b.damage_level
|
||||
if (lvl === 3) severe++
|
||||
else if (lvl === 2) moderate++
|
||||
else if (lvl === 1) light++
|
||||
}
|
||||
return { attacked, severe, moderate, light }
|
||||
}, [keyLocations])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-military-border bg-military-panel/80 p-3 font-orbitron ${className}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
美军基地态势
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-xs tabular-nums">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-military-text-secondary">总基地数</span>
|
||||
<strong>{TOTAL_BASES}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||
<AlertCircle className="h-3 w-3 text-red-400" />
|
||||
被袭击
|
||||
</span>
|
||||
<strong className="text-red-400">{stats.attacked}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
严重损毁
|
||||
</span>
|
||||
<strong className="text-amber-500">{stats.severe}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-military-text-secondary">中度损毁</span>
|
||||
<strong className="text-amber-400">{stats.moderate}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-military-text-secondary">轻度损毁</span>
|
||||
<strong className="text-amber-300">{stats.light}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,15 +24,34 @@ export function HeaderPanel() {
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
const formatDataTime = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 overflow-x-auto border-b border-military-border bg-military-panel/95 px-4 py-3 font-orbitron lg:flex-nowrap lg:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 lg:gap-6">
|
||||
<h1 className="text-base font-bold uppercase tracking-widest text-military-accent lg:text-2xl">
|
||||
美伊军事态势显示
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-military-text-secondary">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 text-sm text-military-text-secondary">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<span className="text-[10px] text-green-500/90">
|
||||
{formatDataTime(situation.lastUpdated)} (实时更新)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
||||
@@ -1,51 +1,213 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import Map, { Marker } from 'react-map-gl'
|
||||
import { useMemo, useEffect, useRef } from 'react'
|
||||
import Map, { Source, Layer } from 'react-map-gl'
|
||||
import type { MapRef } from 'react-map-gl'
|
||||
import type { Map as MapboxMap } from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { ATTACKED_TARGETS } from '@/data/mapLocations'
|
||||
|
||||
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN || ''
|
||||
|
||||
// Persian Gulf center
|
||||
const DEFAULT_VIEW = {
|
||||
longitude: 54,
|
||||
latitude: 27,
|
||||
zoom: 5.5,
|
||||
const DEFAULT_VIEW = { longitude: 52.5, latitude: 26.5, zoom: 5.2 }
|
||||
|
||||
const COUNTRIES_GEOJSON =
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson'
|
||||
|
||||
const IRAN_ADMIN = 'Iran'
|
||||
const ALLIES_ADMIN = [
|
||||
'Qatar',
|
||||
'Bahrain',
|
||||
'Kuwait',
|
||||
'United Arab Emirates',
|
||||
'Saudi Arabia',
|
||||
'Iraq',
|
||||
'Syria',
|
||||
'Jordan',
|
||||
'Turkey',
|
||||
'Israel',
|
||||
'Oman',
|
||||
'Egypt',
|
||||
'Djibouti',
|
||||
]
|
||||
|
||||
// 伊朗攻击源 德黑兰 [lng, lat]
|
||||
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
|
||||
|
||||
function parabolaPath(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
height = 2
|
||||
): [number, number][] {
|
||||
const midLng = (start[0] + end[0]) / 2
|
||||
const midLat = (start[1] + end[1]) / 2 + height
|
||||
return [start, [midLng, midLat], end]
|
||||
}
|
||||
|
||||
type BaseStatus = 'operational' | 'damaged' | 'attacked'
|
||||
|
||||
interface KeyLoc {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
type?: string
|
||||
status?: BaseStatus
|
||||
damage_level?: number
|
||||
}
|
||||
|
||||
function toFeature(loc: KeyLoc, side: 'us' | 'iran', status?: BaseStatus) {
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
side,
|
||||
name: loc.name,
|
||||
status: status ?? (loc as KeyLoc & { status?: BaseStatus }).status ?? 'operational',
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [loc.lng, loc.lat] as [number, number],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function WarMap() {
|
||||
const mapRef = useRef<MapRef>(null)
|
||||
const animRef = useRef<number>(0)
|
||||
const startRef = useRef<number>(0)
|
||||
const { situation } = useSituationStore()
|
||||
const { usForces, iranForces } = situation
|
||||
|
||||
const usMarkers = usForces.keyLocations
|
||||
const iranMarkers = iranForces.keyLocations
|
||||
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
||||
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
||||
|
||||
const { usNaval, usBaseOp, usBaseDamaged, usBaseAttacked, labelsGeoJson } = useMemo(() => {
|
||||
const naval: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
const op: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
const damaged: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
const attacked: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
const labels: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
|
||||
for (const loc of usLocs as KeyLoc[]) {
|
||||
const f = toFeature(loc, 'us')
|
||||
labels.push({ ...f, properties: { ...f.properties, name: loc.name } })
|
||||
if (loc.type === 'Base') {
|
||||
const s = (loc.status ?? 'operational') as BaseStatus
|
||||
if (s === 'attacked') attacked.push(f)
|
||||
else if (s === 'damaged') damaged.push(f)
|
||||
else op.push(f)
|
||||
} else {
|
||||
naval.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
for (const loc of irLocs) {
|
||||
const f = toFeature(loc, 'iran')
|
||||
labels.push({ ...f, properties: { ...f.properties, name: loc.name } })
|
||||
}
|
||||
|
||||
return {
|
||||
usNaval: { type: 'FeatureCollection' as const, features: naval },
|
||||
usBaseOp: { type: 'FeatureCollection' as const, features: op },
|
||||
usBaseDamaged: { type: 'FeatureCollection' as const, features: damaged },
|
||||
usBaseAttacked: { type: 'FeatureCollection' as const, features: attacked },
|
||||
labelsGeoJson: { type: 'FeatureCollection' as const, features: labels },
|
||||
}
|
||||
}, [usForces.keyLocations, iranForces.keyLocations])
|
||||
|
||||
// 德黑兰到 27 个被袭目标的攻击曲线
|
||||
const attackLinesGeoJson = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: ATTACKED_TARGETS.map((target) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: parabolaPath(TEHRAN_SOURCE, target as [number, number]),
|
||||
},
|
||||
})),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const hideNonBelligerentLabels = (map: MapboxMap) => {
|
||||
const labelLayers = [
|
||||
'country-label',
|
||||
'state-label',
|
||||
'place-label',
|
||||
'place-label-capital',
|
||||
'place-label-city',
|
||||
'place-label-town',
|
||||
'place-label-village',
|
||||
'poi-label',
|
||||
]
|
||||
for (const id of labelLayers) {
|
||||
try {
|
||||
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none')
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map) return
|
||||
startRef.current = performance.now()
|
||||
|
||||
const tick = (t: number) => {
|
||||
const elapsed = t - startRef.current
|
||||
try {
|
||||
if (map.getLayer('attack-lines')) {
|
||||
const offset = (elapsed / 16) * 0.8
|
||||
map.setPaintProperty('attack-lines', 'line-dasharray', [2, 2])
|
||||
map.setPaintProperty('attack-lines', 'line-dash-offset', -offset)
|
||||
}
|
||||
// damaged: 橙色闪烁 opacity 0.5 ~ 1, 约 1s 周期
|
||||
if (map.getLayer('points-damaged')) {
|
||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
||||
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
||||
}
|
||||
// attacked: 红色脉冲 2s 循环, 扩散半径 0→40px, opacity 1→0 (map.md)
|
||||
if (map.getLayer('points-attacked-pulse')) {
|
||||
const cycle = 2000
|
||||
const phase = (elapsed % cycle) / cycle
|
||||
const r = 40 * phase
|
||||
const opacity = 1 - phase
|
||||
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
} catch (_) {}
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
hideNonBelligerentLabels(map)
|
||||
if (map.getLayer('attack-lines')) {
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
} else {
|
||||
animRef.current = requestAnimationFrame(start)
|
||||
}
|
||||
}
|
||||
if (map.isStyleLoaded()) start()
|
||||
else map.once('load', start)
|
||||
|
||||
return () => cancelAnimationFrame(animRef.current)
|
||||
}, [])
|
||||
|
||||
// Fallback when no Mapbox token - show placeholder
|
||||
if (!MAPBOX_TOKEN) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-military-dark">
|
||||
<div className="rounded-lg border border-military-border bg-military-panel p-8 text-center">
|
||||
<p className="font-orbitron text-sm text-military-text-primary">
|
||||
地图需要 Mapbox 令牌
|
||||
</p>
|
||||
<div className="rounded-lg border border-military-border bg-military-panel/95 p-8 text-center shadow-lg">
|
||||
<p className="font-orbitron text-sm font-medium text-military-text-primary">地图需要 Mapbox 令牌</p>
|
||||
<p className="mt-2 text-xs text-military-text-secondary">
|
||||
请在 .env 中设置 VITE_MAPBOX_ACCESS_TOKEN
|
||||
复制 .env.example 为 .env,填入令牌后重启
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-8">
|
||||
<div>
|
||||
<p className="text-[10px] text-military-us">美方位置</p>
|
||||
{usMarkers.map((loc) => (
|
||||
<p key={loc.name} className="text-xs text-military-text-primary">{loc.name}</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-military-iran">伊方位置</p>
|
||||
{iranMarkers.map((loc) => (
|
||||
<p key={loc.name} className="text-xs text-military-text-primary">{loc.name}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://account.mapbox.com/access-tokens/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-[10px] text-military-accent hover:underline"
|
||||
>
|
||||
免费申请 Mapbox 令牌 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -59,40 +221,160 @@ export function WarMap() {
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
mapboxAccessToken={MAPBOX_TOKEN}
|
||||
attributionControl={false}
|
||||
dragRotate={false}
|
||||
touchRotate={false}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{usMarkers.map((loc) => (
|
||||
<Marker
|
||||
key={`us-${loc.name}`}
|
||||
longitude={loc.lng}
|
||||
latitude={loc.lat}
|
||||
anchor="bottom"
|
||||
color="#3B82F6"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-white bg-military-us shadow-lg" />
|
||||
<span className="mt-1 rounded bg-military-panel px-1.5 py-0.5 font-orbitron text-[10px] text-military-us">
|
||||
{loc.name}
|
||||
</span>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{iranMarkers.map((loc) => (
|
||||
<Marker
|
||||
key={`ir-${loc.name}`}
|
||||
longitude={loc.lng}
|
||||
latitude={loc.lat}
|
||||
anchor="bottom"
|
||||
color="#EF4444"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-white bg-military-iran shadow-lg" />
|
||||
<span className="mt-1 rounded bg-military-panel px-1.5 py-0.5 font-orbitron text-[10px] text-military-iran">
|
||||
{loc.name}
|
||||
</span>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* 美国海军 - 蓝色 */}
|
||||
<Source id="points-us-naval" type="geojson" data={usNaval}>
|
||||
<Layer
|
||||
id="points-us-naval"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#3B82F6',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 美军基地-正常 - 绿色 */}
|
||||
<Source id="points-us-base-op" type="geojson" data={usBaseOp}>
|
||||
<Layer
|
||||
id="points-us-base-op"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#22C55E',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 美军基地-损毁 - 橙色闪烁 */}
|
||||
<Source id="points-us-base-damaged" type="geojson" data={usBaseDamaged}>
|
||||
<Layer
|
||||
id="points-damaged"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#F97316',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
'circle-opacity': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 美军基地-遭袭 - 红点 + 脉冲 */}
|
||||
<Source id="points-us-base-attacked" type="geojson" data={usBaseAttacked}>
|
||||
<Layer
|
||||
id="points-attacked-dot"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#EF4444',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="points-attacked-pulse"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#EF4444',
|
||||
'circle-opacity': 0,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 伊朗 - 红色 */}
|
||||
<Source
|
||||
id="points-iran"
|
||||
type="geojson"
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: irLocs.map((loc) => toFeature(loc, 'iran')),
|
||||
}}
|
||||
>
|
||||
<Layer
|
||||
id="points-iran"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#EF4444',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fff',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
<Source id="attack-lines" type="geojson" data={attackLinesGeoJson}>
|
||||
<Layer
|
||||
id="attack-lines"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#ff0000',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 中文标注 */}
|
||||
<Source id="labels" type="geojson" data={labelsGeoJson}>
|
||||
<Layer
|
||||
id="labels"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 11,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 0.8],
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#E5E7EB',
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 2,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
<Source id="countries" type="geojson" data={COUNTRIES_GEOJSON}>
|
||||
{/* 以色列 mesh 高亮 */}
|
||||
<Layer
|
||||
id="israel-fill"
|
||||
type="fill"
|
||||
filter={['==', ['get', 'ADMIN'], 'Israel']}
|
||||
paint={{
|
||||
'fill-color': 'rgba(96, 165, 250, 0.25)',
|
||||
'fill-outline-color': '#60A5FA',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="iran-outline"
|
||||
type="line"
|
||||
filter={['==', ['get', 'ADMIN'], IRAN_ADMIN]}
|
||||
paint={{
|
||||
'line-color': '#EF4444',
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.9,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="allies-outline"
|
||||
type="line"
|
||||
filter={['in', ['get', 'ADMIN'], ['literal', ALLIES_ADMIN]]}
|
||||
paint={{
|
||||
'line-color': '#3B82F6',
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.8,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
</Map>
|
||||
</div>
|
||||
)
|
||||
|
||||
140
src/data/mapLocations.ts
Normal file
140
src/data/mapLocations.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** 航母标记 - 全部中文 */
|
||||
export const CARRIER_MARKERS = [
|
||||
{
|
||||
id: 'CVN-72',
|
||||
name: '林肯号航母',
|
||||
coordinates: [58.4215, 24.1568] as [number, number],
|
||||
type: 'Aircraft Carrier',
|
||||
status: 'Active - Combat Readiness',
|
||||
details: '林肯号航母打击群 (CSG-3) 部署于北阿拉伯海。',
|
||||
},
|
||||
{
|
||||
id: 'CVN-78',
|
||||
name: '福特号航母',
|
||||
coordinates: [24.1002, 35.7397] as [number, number],
|
||||
type: 'Aircraft Carrier',
|
||||
status: 'Active - Forward Deployed',
|
||||
details: '距克里特苏达湾约 15 公里。',
|
||||
},
|
||||
]
|
||||
|
||||
export type KeyLocItem = {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
type?: string
|
||||
region?: string
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
}
|
||||
|
||||
/** 美军基地总数 62,被袭击 27 个。损毁程度:严重 6 / 中度 12 / 轻度 9 */
|
||||
const ATTACKED_BASES = [
|
||||
// 严重损毁 (6): 高价值目标,近伊朗
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, region: '卡塔尔', damage_level: 3 },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, region: '土耳其', damage_level: 3 },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, region: '沙特', damage_level: 3 },
|
||||
// 中度损毁 (12)
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, region: '伊拉克', damage_level: 2 },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, region: '伊拉克', damage_level: 2 },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, region: '科威特', damage_level: 2 },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, region: '科威特', damage_level: 2 },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, region: '巴林', damage_level: 2 },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, region: '阿联酋', damage_level: 2 },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, region: '沙特', damage_level: 2 },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, region: '以色列', damage_level: 2 },
|
||||
// 轻度损毁 (9)
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, region: '科威特', damage_level: 1 },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, region: '卡塔尔', damage_level: 1 },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, region: '以色列', damage_level: 1 },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, region: '约旦', damage_level: 1 },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, region: '土耳其', damage_level: 1 },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, region: '阿曼', damage_level: 1 },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, region: '阿曼', damage_level: 1 },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, region: '埃及', damage_level: 1 },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, region: '吉布提', damage_level: 1 },
|
||||
]
|
||||
|
||||
/** 35 个新增 operational 基地 */
|
||||
const NEW_BASES: KeyLocItem[] = [
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔' },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林' },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特' },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特' },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋' },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋' },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特' },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特' },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特' },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克' },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克' },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克' },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克' },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚' },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚' },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦' },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其' },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列' },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼' },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼' },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及' },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及' },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提' },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔' },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特' },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克' },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚' },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦' },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其' },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列' },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼' },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及' },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提' },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋' },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克' },
|
||||
]
|
||||
|
||||
/** 美军全部地图点位:2 航母 + 9 海军 + 62 基地 */
|
||||
export const US_KEY_LOCATIONS: KeyLocItem[] = [
|
||||
...CARRIER_MARKERS.map((c) => ({
|
||||
name: c.name + ` (${c.id})`,
|
||||
lat: c.coordinates[1],
|
||||
lng: c.coordinates[0],
|
||||
type: 'Aircraft Carrier' as const,
|
||||
region: c.id === 'CVN-72' ? '北阿拉伯海' : '东地中海',
|
||||
status: 'operational' as const,
|
||||
damage_level: undefined as number | undefined,
|
||||
})),
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
...ATTACKED_BASES.map((b) => ({
|
||||
...b,
|
||||
type: 'Base' as const,
|
||||
status: 'attacked' as const,
|
||||
})),
|
||||
...NEW_BASES,
|
||||
]
|
||||
|
||||
/** 被袭击的 27 个基地坐标 [lng, lat],用于绘制攻击曲线 */
|
||||
export const ATTACKED_TARGETS: [number, number][] = ATTACKED_BASES.map((b) => [b.lng, b.lat])
|
||||
|
||||
export const IRAN_KEY_LOCATIONS: KeyLocItem[] = [
|
||||
{ name: '阿巴斯港', lat: 27.1832, lng: 56.2666, type: 'Port', region: '伊朗' },
|
||||
{ name: '德黑兰', lat: 35.6892, lng: 51.389, type: 'Capital', region: '伊朗' },
|
||||
{ name: '布什尔', lat: 28.9681, lng: 50.838, type: 'Base', region: '伊朗' },
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
// TypeScript interfaces for military situation data
|
||||
import { US_KEY_LOCATIONS, IRAN_KEY_LOCATIONS } from './mapLocations'
|
||||
|
||||
export interface ForceAsset {
|
||||
id: string
|
||||
@@ -50,7 +51,16 @@ export interface MilitarySituation {
|
||||
summary: ForceSummary
|
||||
powerIndex: PowerIndex
|
||||
assets: ForceAsset[]
|
||||
keyLocations: { name: string; lat: number; lng: number }[]
|
||||
keyLocations: {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
type?: string
|
||||
region?: string
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
}[]
|
||||
combatLosses: CombatLosses
|
||||
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
|
||||
wallStreetInvestmentTrend: { time: string; value: number }[]
|
||||
@@ -59,7 +69,16 @@ export interface MilitarySituation {
|
||||
summary: ForceSummary
|
||||
powerIndex: PowerIndex
|
||||
assets: ForceAsset[]
|
||||
keyLocations: { name: string; lat: number; lng: number }[]
|
||||
keyLocations: {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
type?: string
|
||||
region?: string
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
}[]
|
||||
combatLosses: CombatLosses
|
||||
/** 反击情绪指标 0-100 */
|
||||
retaliationSentiment: number
|
||||
@@ -70,16 +89,16 @@ export interface MilitarySituation {
|
||||
}
|
||||
|
||||
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
lastUpdated: '2026-03-01T11:45:00.000Z',
|
||||
usForces: {
|
||||
summary: {
|
||||
totalAssets: 1247,
|
||||
totalAssets: 1245,
|
||||
personnel: 185000,
|
||||
navalShips: 285,
|
||||
aircraft: 1850,
|
||||
navalShips: 292,
|
||||
aircraft: 1862,
|
||||
groundUnits: 18,
|
||||
uav: 420,
|
||||
missileConsumed: 156,
|
||||
uav: 418,
|
||||
missileConsumed: 1056,
|
||||
missileStock: 2840,
|
||||
},
|
||||
powerIndex: {
|
||||
@@ -89,23 +108,24 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
geopoliticalInfluence: 97,
|
||||
},
|
||||
assets: [
|
||||
{ id: 'us-1', name: '艾森豪威尔号航母', type: '航母', count: 1, status: 'active' },
|
||||
{ id: 'us-1', name: '双航母打击群 (CVN-72 & CVN-78)', type: '航母', count: 2, status: 'active' },
|
||||
{ id: 'us-2', name: '阿利·伯克级驱逐舰', type: '驱逐舰', count: 4, status: 'active' },
|
||||
{ id: 'us-3', name: 'F/A-18 中队', type: '战机', count: 48, status: 'active' },
|
||||
{ id: 'us-4', name: 'F-35 中队', type: '战机', count: 24, status: 'standby' },
|
||||
{ id: 'us-5', name: 'B-1B 轰炸机', type: '轰炸机', count: 4, status: 'alert' },
|
||||
{ id: 'us-6', name: '爱国者防空系统', type: '防空', count: 3, status: 'active' },
|
||||
{ id: 'us-7', name: 'MQ-9 死神', type: '无人机', count: 28, status: 'active' },
|
||||
{ id: 'us-8', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' },
|
||||
],
|
||||
keyLocations: [
|
||||
{ name: '第五舰队司令部', lat: 26.2285, lng: 50.586 },
|
||||
{ name: '乌代德空军基地', lat: 25.1173, lng: 51.3153 },
|
||||
{ name: '艾森豪威尔号航母', lat: 26.5, lng: 52.0 },
|
||||
{ id: 'us-4', name: 'F-35 中队', type: '战机', count: 48, status: 'active' },
|
||||
{ id: 'us-5', name: 'F-22 猛禽', type: '战机', count: 12, status: 'active' },
|
||||
{ id: 'us-6', name: 'B-2 幽灵', type: '轰炸机', count: 2, status: 'alert' },
|
||||
{ id: 'us-7', name: '爱国者防空系统', type: '防空', count: 3, status: 'active' },
|
||||
{ id: 'us-8', name: 'MQ-9 死神', type: '无人机', count: 28, status: 'active' },
|
||||
{ id: 'us-9', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' },
|
||||
],
|
||||
keyLocations: US_KEY_LOCATIONS,
|
||||
combatLosses: {
|
||||
bases: { destroyed: 0, damaged: 2 },
|
||||
personnelCasualties: { killed: 127, wounded: 384 },
|
||||
aircraft: 2,
|
||||
warships: 0,
|
||||
armor: 0,
|
||||
vehicles: 8,
|
||||
},
|
||||
wallStreetInvestmentTrend: [
|
||||
{ time: '2025-03-01T00:00:00', value: 82 },
|
||||
@@ -121,14 +141,14 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
},
|
||||
iranForces: {
|
||||
summary: {
|
||||
totalAssets: 8523,
|
||||
totalAssets: 7850,
|
||||
personnel: 2350000,
|
||||
navalShips: 4250,
|
||||
aircraft: 8200,
|
||||
groundUnits: 350,
|
||||
uav: 1850,
|
||||
missileConsumed: 3420,
|
||||
missileStock: 15600,
|
||||
uav: 750,
|
||||
missileConsumed: 3720,
|
||||
missileStock: 13800,
|
||||
},
|
||||
powerIndex: {
|
||||
overall: 42,
|
||||
@@ -141,16 +161,13 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
{ id: 'ir-2', name: '快攻艇', type: '海军', count: 100, status: 'active' },
|
||||
{ id: 'ir-3', name: 'F-4 Phantom', type: '战机', count: 62, status: 'standby' },
|
||||
{ id: 'ir-4', name: 'F-14 Tomcat', type: '战机', count: 24, status: 'active' },
|
||||
{ id: 'ir-5', name: '弹道导弹', type: '导弹', count: 2000, status: 'alert' },
|
||||
{ id: 'ir-5', name: '弹道导弹', type: '导弹', count: 3400, status: 'alert' },
|
||||
{ id: 'ir-6', name: '伊斯兰革命卫队海军', type: '准军事', count: 25000, status: 'active' },
|
||||
{ id: 'ir-7', name: '沙希德-136', type: '无人机', count: 1200, status: 'alert' },
|
||||
{ id: 'ir-8', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' },
|
||||
],
|
||||
keyLocations: [
|
||||
{ name: '阿巴斯港', lat: 27.1832, lng: 56.2666 },
|
||||
{ name: '德黑兰', lat: 35.6892, lng: 51.3890 },
|
||||
{ name: '布什尔', lat: 28.9681, lng: 50.8380 },
|
||||
{ id: 'ir-7', name: '沙希德-136', type: '无人机', count: 750, status: 'alert' },
|
||||
{ id: 'ir-8', name: '法塔赫 (Fattah)', type: '导弹', count: 12, status: 'alert' },
|
||||
{ id: 'ir-9', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' },
|
||||
],
|
||||
keyLocations: IRAN_KEY_LOCATIONS,
|
||||
combatLosses: {
|
||||
bases: { destroyed: 3, damaged: 8 },
|
||||
personnelCasualties: { killed: 2847, wounded: 5620 },
|
||||
|
||||
@@ -3,22 +3,30 @@ import { HeaderPanel } from '@/components/HeaderPanel'
|
||||
import { ForcePanel } from '@/components/ForcePanel'
|
||||
import { WarMap } from '@/components/WarMap'
|
||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
||||
import { PowerChart } from '@/components/PowerChart'
|
||||
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
||||
import { RetaliationGauge } from '@/components/RetaliationGauge'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { startWebSocketMock, stopWebSocketMock } from '@/store/situationStore'
|
||||
import { fetchAndSetSituation, startSituationWebSocket, stopSituationWebSocket } from '@/store/situationStore'
|
||||
|
||||
export function Dashboard() {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
const isLoading = useSituationStore((s) => s.isLoading)
|
||||
const lastError = useSituationStore((s) => s.lastError)
|
||||
|
||||
useEffect(() => {
|
||||
startWebSocketMock()
|
||||
return () => stopWebSocketMock()
|
||||
fetchAndSetSituation().finally(() => startSituationWebSocket())
|
||||
return () => stopSituationWebSocket()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full min-h-0 flex-col overflow-hidden bg-military-dark font-orbitron">
|
||||
{lastError && (
|
||||
<div className="shrink-0 bg-amber-500/20 px-4 py-2 text-center text-sm text-amber-400">
|
||||
{lastError}(使用本地缓存,请确保 API + WebSocket 已启动:npm run api)
|
||||
</div>
|
||||
)}
|
||||
<HeaderPanel />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
||||
@@ -50,10 +58,14 @@ export function Dashboard() {
|
||||
<div className="min-h-0 flex-1">
|
||||
<WarMap />
|
||||
</div>
|
||||
<CombatLossesPanel
|
||||
usLosses={situation.usForces.combatLosses}
|
||||
iranLosses={situation.iranForces.combatLosses}
|
||||
/>
|
||||
<div className="flex shrink-0 flex-col gap-2 border-t border-military-border bg-military-panel/95 px-4 py-2 lg:flex-row lg:items-stretch">
|
||||
<BaseStatusPanel keyLocations={situation.usForces.keyLocations} className="shrink-0 lg:min-w-[200px] lg:border-r lg:border-military-border lg:pr-4" />
|
||||
<CombatLossesPanel
|
||||
usLosses={situation.usForces.combatLosses}
|
||||
iranLosses={situation.iranForces.combatLosses}
|
||||
className="min-w-0 flex-1 border-t-0"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-t border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-t-0 lg:border-l lg:p-4">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { create } from 'zustand'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
import { INITIAL_MOCK_DATA } from '@/data/mockData'
|
||||
import { fetchSituation } from '@/api/situation'
|
||||
import { connectSituationWebSocket } from '@/api/websocket'
|
||||
|
||||
interface SituationState {
|
||||
situation: MilitarySituation
|
||||
isConnected: boolean
|
||||
lastError: string | null
|
||||
isLoading: boolean
|
||||
setSituation: (situation: MilitarySituation) => void
|
||||
updateFromWebSocket: (partial: Partial<MilitarySituation>) => void
|
||||
fetchAndSetSituation: () => Promise<void>
|
||||
setConnected: (connected: boolean) => void
|
||||
setLastError: (error: string | null) => void
|
||||
}
|
||||
@@ -16,67 +19,48 @@ export const useSituationStore = create<SituationState>((set) => ({
|
||||
situation: INITIAL_MOCK_DATA,
|
||||
isConnected: false,
|
||||
lastError: null,
|
||||
isLoading: false,
|
||||
|
||||
setSituation: (situation) => set({ situation }),
|
||||
|
||||
updateFromWebSocket: (partial) =>
|
||||
set((state) => ({
|
||||
situation: {
|
||||
...state.situation,
|
||||
...partial,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
})),
|
||||
fetchAndSetSituation: async () => {
|
||||
set({ isLoading: true, lastError: null })
|
||||
try {
|
||||
const situation = await fetchSituation()
|
||||
set({ situation })
|
||||
} catch (err) {
|
||||
set({
|
||||
lastError: err instanceof Error ? err.message : 'Failed to fetch situation',
|
||||
})
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
setConnected: (isConnected) => set({ isConnected }),
|
||||
|
||||
setLastError: (lastError) => set({ lastError }),
|
||||
}))
|
||||
|
||||
// WebSocket mock logic - simulates real-time updates without actual simulation
|
||||
// In production, replace with actual WebSocket connection
|
||||
let mockWsInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function startWebSocketMock(): void {
|
||||
if (mockWsInterval) return
|
||||
|
||||
const store = useSituationStore.getState()
|
||||
store.setConnected(true)
|
||||
store.setLastError(null)
|
||||
|
||||
mockWsInterval = setInterval(() => {
|
||||
const { situation } = useSituationStore.getState()
|
||||
const now = Date.now()
|
||||
|
||||
// Simulate minor fluctuations in numbers (display-only, no logic)
|
||||
const fluctuation = () => Math.floor(Math.random() * 3) - 1
|
||||
|
||||
useSituationStore.getState().updateFromWebSocket({
|
||||
usForces: {
|
||||
...situation.usForces,
|
||||
summary: {
|
||||
...situation.usForces.summary,
|
||||
totalAssets: Math.max(40, situation.usForces.summary.totalAssets + fluctuation()),
|
||||
},
|
||||
},
|
||||
recentUpdates: [
|
||||
{
|
||||
id: `u-${now}`,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
category: 'intel',
|
||||
summary: '例行状态同步 - 各系统正常',
|
||||
severity: 'low',
|
||||
},
|
||||
...situation.recentUpdates.slice(0, 3),
|
||||
],
|
||||
})
|
||||
}, 15000) // Update every 15 seconds
|
||||
export function fetchAndSetSituation(): Promise<void> {
|
||||
return useSituationStore.getState().fetchAndSetSituation()
|
||||
}
|
||||
|
||||
export function stopWebSocketMock(): void {
|
||||
if (mockWsInterval) {
|
||||
clearInterval(mockWsInterval)
|
||||
mockWsInterval = null
|
||||
}
|
||||
let disconnectWs: (() => void) | null = null
|
||||
|
||||
export function startSituationWebSocket(): () => void {
|
||||
useSituationStore.getState().setConnected(true)
|
||||
useSituationStore.getState().setLastError(null)
|
||||
|
||||
disconnectWs = connectSituationWebSocket((data) => {
|
||||
useSituationStore.getState().setSituation(data as MilitarySituation)
|
||||
})
|
||||
|
||||
return stopSituationWebSocket
|
||||
}
|
||||
|
||||
export function stopSituationWebSocket(): void {
|
||||
disconnectWs?.()
|
||||
disconnectWs = null
|
||||
useSituationStore.getState().setConnected(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user