feat: add new file

This commit is contained in:
Daniel
2026-03-01 19:23:48 +08:00
parent d705fd6c83
commit c07fc681dd
24 changed files with 2711 additions and 166 deletions

7
src/api/situation.ts Normal file
View 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
View 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
}
}

View 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>
)
}

View File

@@ -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">

View File

@@ -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
View 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: '伊朗' },
]

View File

@@ -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 },

View File

@@ -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">

View File

@@ -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)
}