feat: new file
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Get a free token at https://account.mapbox.com/access-tokens/
|
||||
VITE_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoiZDI5cTAiLCJhIjoiY21oaGRmcTkzMGltZzJscHR1N2FhZnY5dCJ9.7ueF2lS6-C9Mm_xon7NnIA
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# US-Iran Military Situation Display Dashboard
|
||||
|
||||
A production-ready data visualization dashboard with 1920×1080 resolution, dark-themed military-grade UI.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Build:** Vite (React + TypeScript)
|
||||
- **Styling:** Tailwind CSS
|
||||
- **State:** Zustand
|
||||
- **Charts:** echarts, echarts-for-react
|
||||
- **Maps:** react-map-gl, mapbox-gl
|
||||
- **Icons:** lucide-react
|
||||
- **Font:** Orbitron (Google Fonts)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Mapbox (optional)
|
||||
|
||||
For the interactive map, create a `.env` file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and add your Mapbox token from https://account.mapbox.com/access-tokens/
|
||||
```
|
||||
|
||||
Without a token, the map area shows a placeholder with location labels.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── HeaderPanel.tsx # Top global overview & power index comparison
|
||||
│ ├── ForcePanel.tsx # Reusable left/right panel for military forces
|
||||
│ ├── WarMap.tsx # Mapbox GL (Persian Gulf center)
|
||||
│ └── StatCard.tsx # Reusable number card
|
||||
├── store/
|
||||
│ └── situationStore.ts # Zustand store + WebSocket mock logic
|
||||
├── data/
|
||||
│ └── mockData.ts # TypeScript interfaces & initial mock data
|
||||
├── pages/
|
||||
│ └── Dashboard.tsx # Main layout (1920×1080)
|
||||
├── App.tsx
|
||||
└── index.css
|
||||
```
|
||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>美伊军事态势显示</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4304
package-lock.json
generated
Normal file
4304
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "us-iran-military-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"lucide-react": "^0.460.0",
|
||||
"mapbox-gl": "^3.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-map-gl": "^7.1.7",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.11.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
14
src/App.tsx
Normal file
14
src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen w-full bg-military-dark overflow-hidden"
|
||||
style={{ background: '#0A0F1C' }}
|
||||
>
|
||||
<Dashboard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
111
src/components/CombatLossesPanel.tsx
Normal file
111
src/components/CombatLossesPanel.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Skull,
|
||||
Bandage,
|
||||
Plane,
|
||||
Ship,
|
||||
Shield,
|
||||
Car,
|
||||
TrendingDown,
|
||||
} from 'lucide-react'
|
||||
import { formatMillions } from '@/utils/formatNumber'
|
||||
import type { CombatLosses } from '@/data/mockData'
|
||||
|
||||
interface CombatLossesPanelProps {
|
||||
usLosses: CombatLosses
|
||||
iranLosses: CombatLosses
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LOSS_ITEMS: { key: keyof Omit<CombatLosses, 'bases' | 'personnelCasualties'>; label: string; icon: typeof Plane }[] = [
|
||||
{ key: 'aircraft', label: '战机', icon: Plane },
|
||||
{ key: 'warships', label: '战舰', icon: Ship },
|
||||
{ key: 'armor', label: '装甲', icon: Shield },
|
||||
{ key: 'vehicles', label: '车辆', icon: Car },
|
||||
]
|
||||
|
||||
export function CombatLossesPanel({ usLosses, iranLosses, className = '' }: CombatLossesPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
min-w-0 shrink-0 overflow-x-auto overflow-y-hidden border-t border-military-border scrollbar-thin bg-military-panel/95 px-4 py-2 font-orbitron
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
||||
<TrendingDown className="h-2.5 w-2.5 shrink-0" />
|
||||
战损数据
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-x-4 gap-y-3 text-xs sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 [&>*]:min-w-0">
|
||||
{/* 基地 - 横向第一列 */}
|
||||
<div className="flex min-w-0 flex-col gap-0.5 overflow-hidden">
|
||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
||||
<Building2 className="h-3 w-3 shrink-0" />
|
||||
基地
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`美 毁${usLosses.bases.destroyed} 损${usLosses.bases.damaged}`}>
|
||||
<span className="shrink-0 text-military-us">美</span>
|
||||
<span className="truncate">
|
||||
毁<strong className="text-amber-400">{usLosses.bases.destroyed}</strong>
|
||||
损<strong className="text-amber-300">{usLosses.bases.damaged}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`伊 毁${iranLosses.bases.destroyed} 损${iranLosses.bases.damaged}`}>
|
||||
<span className="shrink-0 text-military-iran">伊</span>
|
||||
<span className="truncate">
|
||||
毁<strong className="text-amber-400">{iranLosses.bases.destroyed}</strong>
|
||||
损<strong className="text-amber-300">{iranLosses.bases.damaged}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 人员伤亡 */}
|
||||
<div className="flex min-w-0 flex-col gap-0.5 overflow-hidden">
|
||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
||||
<Users className="h-3 w-3 shrink-0" />
|
||||
人员伤亡
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden" title={`美 阵亡${formatMillions(usLosses.personnelCasualties.killed)} 受伤${formatMillions(usLosses.personnelCasualties.wounded)}`}>
|
||||
<span className="shrink-0 text-military-us">美</span>
|
||||
<Skull className="h-2.5 w-2.5 shrink-0 text-red-400" />
|
||||
<strong className="truncate text-red-400">{formatMillions(usLosses.personnelCasualties.killed)}</strong>
|
||||
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-400" />
|
||||
<strong className="truncate text-amber-400">{formatMillions(usLosses.personnelCasualties.wounded)}</strong>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden" title={`伊 阵亡${formatMillions(iranLosses.personnelCasualties.killed)} 受伤${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
|
||||
<span className="shrink-0 text-military-iran">伊</span>
|
||||
<Skull className="h-2.5 w-2.5 shrink-0 text-red-400" />
|
||||
<strong className="truncate text-red-400">{formatMillions(iranLosses.personnelCasualties.killed)}</strong>
|
||||
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-400" />
|
||||
<strong className="truncate text-amber-400">{formatMillions(iranLosses.personnelCasualties.wounded)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 战机 / 战舰 / 装甲 / 车辆 */}
|
||||
{LOSS_ITEMS.map(({ key, label, icon: Icon }) => (
|
||||
<div key={key} className="flex min-w-0 flex-col gap-0.5 overflow-hidden">
|
||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden">
|
||||
<span className="shrink-0 text-military-us">美</span>
|
||||
<strong className="truncate">{usLosses[key]}</strong>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden">
|
||||
<span className="shrink-0 text-military-iran">伊</span>
|
||||
<strong className="truncate">{iranLosses[key]}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
src/components/ForcePanel.tsx
Normal file
219
src/components/ForcePanel.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { StatCard } from './StatCard'
|
||||
import {
|
||||
Ship,
|
||||
Plane,
|
||||
Users,
|
||||
Target,
|
||||
Rocket,
|
||||
Shield,
|
||||
Crosshair,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
Gauge,
|
||||
} from 'lucide-react'
|
||||
import { formatMillions, formatTenThousands } from '@/utils/formatNumber'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ForceSummary, PowerIndex, ForceAsset } from '@/data/mockData'
|
||||
|
||||
const ASSET_TYPE_ICONS: Record<string, ReactNode> = {
|
||||
航母: <Ship className="h-2.5 w-2.5 shrink-0" />,
|
||||
驱逐舰: <Ship className="h-2.5 w-2.5 shrink-0" />,
|
||||
护卫舰: <Ship className="h-2.5 w-2.5 shrink-0" />,
|
||||
水面舰艇: <Ship className="h-2.5 w-2.5 shrink-0" />,
|
||||
海军: <Ship className="h-2.5 w-2.5 shrink-0" />,
|
||||
战机: <Plane className="h-2.5 w-2.5 shrink-0" />,
|
||||
轰炸机: <Plane className="h-2.5 w-2.5 shrink-0" />,
|
||||
防空: <Shield className="h-2.5 w-2.5 shrink-0" />,
|
||||
导弹: <Rocket className="h-2.5 w-2.5 shrink-0" />,
|
||||
无人机: <Plus className="h-2.5 w-2.5 shrink-0" />,
|
||||
准军事: <Crosshair className="h-2.5 w-2.5 shrink-0" />,
|
||||
}
|
||||
|
||||
interface ForcePanelProps {
|
||||
side: 'us' | 'iran'
|
||||
summary: ForceSummary
|
||||
powerIndex: PowerIndex
|
||||
assets: ForceAsset[]
|
||||
}
|
||||
|
||||
const sideConfig = {
|
||||
us: {
|
||||
title: '美国/盟军',
|
||||
accentColor: 'us',
|
||||
borderClass: 'border-military-us/30',
|
||||
},
|
||||
iran: {
|
||||
title: '伊朗军力',
|
||||
accentColor: 'iran',
|
||||
borderClass: 'border-military-iran/30',
|
||||
},
|
||||
} as const
|
||||
|
||||
function getPowerTier(value: number): { label: string; color: string } {
|
||||
if (value >= 90) return { label: '顶尖', color: 'text-violet-400' }
|
||||
if (value >= 70) return { label: '强', color: 'text-blue-400' }
|
||||
if (value >= 50) return { label: '中等', color: 'text-emerald-400' }
|
||||
if (value >= 30) return { label: '较弱', color: 'text-amber-400' }
|
||||
return { label: '弱', color: 'text-gray-400' }
|
||||
}
|
||||
|
||||
export function ForcePanel({ side, summary, powerIndex, assets }: ForcePanelProps) {
|
||||
const config = sideConfig[side]
|
||||
const statVariant = config.accentColor as 'us' | 'iran'
|
||||
const powerTier = getPowerTier(powerIndex.overall)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex min-h-0 w-full min-w-0 flex-col rounded-lg border bg-military-panel/90
|
||||
font-orbitron lg:min-w-[300px] lg:max-w-[340px] ${config.borderClass}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
shrink-0 border-b px-3 py-2 text-xs font-bold uppercase tracking-wider
|
||||
${side === 'us' ? 'border-military-us/30 text-military-us' : 'border-military-iran/30 text-military-iran'}
|
||||
`}
|
||||
>
|
||||
{config.title}
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-3 gap-1.5 p-2 [&>*]:min-w-0">
|
||||
<StatCard
|
||||
label="资产"
|
||||
value={formatMillions(summary.totalAssets)}
|
||||
variant={statVariant}
|
||||
icon={<Target className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="base"
|
||||
/>
|
||||
<StatCard
|
||||
label="人员"
|
||||
value={formatMillions(summary.personnel)}
|
||||
variant={statVariant}
|
||||
icon={<Users className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="base"
|
||||
/>
|
||||
<StatCard
|
||||
label="海军"
|
||||
value={formatTenThousands(summary.navalShips)}
|
||||
variant={statVariant}
|
||||
icon={<Ship className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="lg"
|
||||
/>
|
||||
<StatCard
|
||||
label="航空"
|
||||
value={formatTenThousands(summary.aircraft)}
|
||||
variant={statVariant}
|
||||
icon={<Plane className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="lg"
|
||||
/>
|
||||
<StatCard
|
||||
label="无人机"
|
||||
value={formatTenThousands(summary.uav)}
|
||||
variant={statVariant}
|
||||
icon={<Plus className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="lg"
|
||||
/>
|
||||
<StatCard
|
||||
label="导弹消耗"
|
||||
value={formatMillions(summary.missileConsumed)}
|
||||
variant={statVariant}
|
||||
icon={<Rocket className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="base"
|
||||
/>
|
||||
<StatCard
|
||||
label="导弹库存"
|
||||
value={formatMillions(summary.missileStock)}
|
||||
variant={statVariant}
|
||||
icon={<Rocket className="h-2.5 w-2.5 shrink-0" />}
|
||||
className="min-w-0"
|
||||
valueSize="base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-military-border px-3 py-2">
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<span className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-military-neutral">
|
||||
<Gauge className="h-2.5 w-2.5 shrink-0" />
|
||||
综合国力
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-base font-bold tabular-nums ${side === 'us' ? 'text-military-us' : 'text-military-iran'}`}>
|
||||
{powerIndex.overall}
|
||||
<span className="ml-0.5 text-[10px] font-normal text-military-neutral">/100</span>
|
||||
</span>
|
||||
<span className={`rounded bg-military-border px-1.5 py-0.5 text-[9px] font-medium ${powerTier.color}`}>
|
||||
{powerTier.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: 'military', label: '军事', value: powerIndex.militaryStrength, icon: Shield },
|
||||
{ key: 'economic', label: '经济', value: powerIndex.economicPower, icon: TrendingUp },
|
||||
{ key: 'influence', label: '影响力', value: powerIndex.geopoliticalInfluence, icon: Globe },
|
||||
].map(({ key, label, value, icon: Icon }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<Icon className="h-3 w-3 shrink-0 text-military-neutral" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex justify-between text-[10px]">
|
||||
<span className="text-military-neutral">{label}</span>
|
||||
<span className={`tabular-nums font-semibold ${side === 'us' ? 'text-military-us' : 'text-military-iran'}`}>{value}</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-military-border">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${side === 'us' ? 'bg-military-us' : 'bg-military-iran'}`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-military-border">
|
||||
<div className="shrink-0 flex items-center gap-1 border-b border-military-border bg-military-panel px-3 py-1.5 text-[10px] uppercase tracking-wider text-military-neutral">
|
||||
<Target className="h-2.5 w-2.5 shrink-0" />
|
||||
主要资产
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-3 py-1.5">
|
||||
<div className="animate-vert-marquee will-change-transform">
|
||||
{[0, 1].map((i) => (
|
||||
<ul key={i} className="space-y-1 text-xs text-military-text-primary">
|
||||
{assets.map((asset) => (
|
||||
<li
|
||||
key={`${i}-${asset.id}`}
|
||||
className="flex items-center justify-between gap-2 border-b border-military-border/50 pb-1 last:border-0"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden" title={asset.name}>
|
||||
{ASSET_TYPE_ICONS[asset.type] ?? <Target className="h-2.5 w-2.5 shrink-0" />}
|
||||
<span className="block min-w-0 break-words">{asset.name}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
ml-2 shrink-0 min-w-[2ch] text-right font-semibold tabular-nums
|
||||
${asset.status === 'alert' ? 'text-amber-400' : ''}
|
||||
${asset.status === 'standby' ? 'text-military-text-secondary' : ''}
|
||||
${asset.status === 'active' ? (side === 'us' ? 'text-military-us' : 'text-military-iran') : ''}
|
||||
`}
|
||||
>
|
||||
{asset.count}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
src/components/HeaderPanel.tsx
Normal file
92
src/components/HeaderPanel.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { StatCard } from './StatCard'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { Wifi, WifiOff, Clock } from 'lucide-react'
|
||||
|
||||
export function HeaderPanel() {
|
||||
const { situation, isConnected } = useSituationStore()
|
||||
const { usForces, iranForces } = situation
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const formatDateTime = (d: Date) =>
|
||||
d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '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>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-xs text-green-500">实时</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="h-3.5 w-3.5 text-military-text-secondary" />
|
||||
<span className="text-xs text-military-text-secondary">已断开</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-wider text-military-text-secondary">国力对比</span>
|
||||
<div className="mt-0.5 flex h-2 w-28 overflow-hidden rounded-full bg-military-border">
|
||||
<div
|
||||
className="bg-military-us transition-all"
|
||||
style={{ width: `${(usForces.powerIndex.overall / (usForces.powerIndex.overall + iranForces.powerIndex.overall)) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-military-iran transition-all"
|
||||
style={{ width: `${(iranForces.powerIndex.overall / (usForces.powerIndex.overall + iranForces.powerIndex.overall)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-between text-[9px] tabular-nums text-military-text-secondary">
|
||||
<span className="text-military-us">美 {usForces.powerIndex.overall}</span>
|
||||
<span className="text-military-iran">伊 {iranForces.powerIndex.overall}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-8 w-px shrink-0 bg-military-border" />
|
||||
<StatCard
|
||||
label="美国/盟国"
|
||||
value={usForces.powerIndex.overall}
|
||||
variant="us"
|
||||
className="border-military-us/50"
|
||||
/>
|
||||
<StatCard
|
||||
label="伊朗"
|
||||
value={iranForces.powerIndex.overall}
|
||||
variant="iran"
|
||||
className="border-military-iran/50"
|
||||
/>
|
||||
<StatCard
|
||||
label="差距"
|
||||
value={`+${usForces.powerIndex.overall - iranForces.powerIndex.overall}`}
|
||||
variant="accent"
|
||||
className="border-military-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
78
src/components/InvestmentTrendChart.tsx
Normal file
78
src/components/InvestmentTrendChart.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
interface InvestmentTrendChartProps {
|
||||
history: { time: string; value: number }[]
|
||||
color?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InvestmentTrendChart({
|
||||
history,
|
||||
color = '#3B82F6',
|
||||
className = '',
|
||||
}: InvestmentTrendChartProps) {
|
||||
const times = history.map((h) => {
|
||||
const d = new Date(h.time)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}`
|
||||
})
|
||||
const values = history.map((h) => h.value)
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 24, right: 8, top: 4, bottom: 16 },
|
||||
tooltip: { trigger: 'axis', confine: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: 'rgba(75, 85, 99, 0.6)' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitNumber: 2,
|
||||
splitLine: { lineStyle: { color: 'rgba(75, 85, 99, 0.3)' } },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: { color, width: 2 },
|
||||
itemStyle: { color },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full flex-col ${className}`}>
|
||||
<div className="mb-0.5 px-1 text-[9px] text-military-text-secondary">
|
||||
华尔街财团投入
|
||||
</div>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: 'calc(100% - 14px)', width: '100%' }}
|
||||
opts={{ renderer: 'svg' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/PowerChart.tsx
Normal file
62
src/components/PowerChart.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import type { PowerIndex } from '@/data/mockData'
|
||||
|
||||
interface PowerChartProps {
|
||||
usPower: PowerIndex
|
||||
iranPower: PowerIndex
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DIMENSIONS = ['军事', '经济', '影响力'] as const
|
||||
|
||||
export function PowerChart({ usPower, iranPower, className = '' }: PowerChartProps) {
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { show: false },
|
||||
radar: {
|
||||
indicator: DIMENSIONS.map((d) => ({
|
||||
name: d,
|
||||
max: 100,
|
||||
})),
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: ['rgba(31, 41, 55, 0.3)', 'rgba(31, 41, 55, 0.15)'],
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: 'rgba(75, 85, 99, 0.6)' } },
|
||||
splitLine: { lineStyle: { color: 'rgba(75, 85, 99, 0.6)' } },
|
||||
axisName: {
|
||||
color: '#D1D5DB',
|
||||
fontFamily: 'Orbitron, sans-serif',
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
data: [
|
||||
{
|
||||
value: [usPower.militaryStrength, usPower.economicPower, usPower.geopoliticalInfluence],
|
||||
name: '美国',
|
||||
areaStyle: { color: 'rgba(59, 130, 246, 0.25)' },
|
||||
lineStyle: { color: '#3B82F6', width: 2 },
|
||||
itemStyle: { color: '#3B82F6' },
|
||||
},
|
||||
{
|
||||
value: [iranPower.militaryStrength, iranPower.economicPower, iranPower.geopoliticalInfluence],
|
||||
name: '伊朗',
|
||||
areaStyle: { color: 'rgba(239, 68, 68, 0.25)' },
|
||||
lineStyle: { color: '#EF4444', width: 2 },
|
||||
itemStyle: { color: '#EF4444' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full ${className}`}>
|
||||
<ReactECharts option={option} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
src/components/RetaliationGauge.tsx
Normal file
136
src/components/RetaliationGauge.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
interface RetaliationGaugeProps {
|
||||
value: number
|
||||
history: { time: string; value: number }[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘与折线图均不注入中文,避免 Canvas/SVG 字体乱码。
|
||||
* 中文标签使用 HTML 层叠渲染,确保正确显示。
|
||||
*/
|
||||
export function RetaliationGauge({ value, history, className = '' }: RetaliationGaugeProps) {
|
||||
const times = history.map((h) => {
|
||||
const d = new Date(h.time)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}`
|
||||
})
|
||||
const values = history.map((h) => h.value)
|
||||
|
||||
const gaugeOption = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { show: false },
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
center: ['50%', '50%'],
|
||||
radius: '80%',
|
||||
startAngle: 200,
|
||||
endAngle: -20,
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitNumber: 5,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 6,
|
||||
color: [
|
||||
[0.3, 'rgba(75, 85, 99, 0.5)'],
|
||||
[0.6, 'rgba(239, 68, 68, 0.5)'],
|
||||
[1, '#EF4444'],
|
||||
],
|
||||
},
|
||||
},
|
||||
pointer: {
|
||||
itemStyle: { color: '#EF4444' },
|
||||
width: 3,
|
||||
length: '65%',
|
||||
},
|
||||
axisTick: { show: true, length: 4, lineStyle: { color: 'rgba(75, 85, 99, 0.6)' } },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
title: { show: false },
|
||||
detail: {
|
||||
show: true,
|
||||
valueAnimation: true,
|
||||
offsetCenter: [0, '25%'],
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#EF4444',
|
||||
formatter: '{value}',
|
||||
},
|
||||
data: [{ value }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const lineOption = {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 24, right: 8, top: 4, bottom: 16 },
|
||||
tooltip: { trigger: 'axis', confine: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: 'rgba(75, 85, 99, 0.6)' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitNumber: 2,
|
||||
splitLine: { lineStyle: { color: 'rgba(75, 85, 99, 0.3)' } },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: { color: '#EF4444', width: 2 },
|
||||
itemStyle: { color: '#EF4444' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(239, 68, 68, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(239, 68, 68, 0)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full flex-col ${className}`}>
|
||||
<div className="relative h-[55%] min-h-0 shrink-0">
|
||||
<ReactECharts
|
||||
option={gaugeOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
opts={{ renderer: 'svg' }}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 text-center text-[10px] text-military-text-secondary">
|
||||
反击情绪
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[45%] min-h-0 shrink-0">
|
||||
<div className="mb-0.5 px-1 text-[9px] text-military-text-secondary">
|
||||
历史趋势
|
||||
</div>
|
||||
<ReactECharts
|
||||
option={lineOption}
|
||||
style={{ height: 'calc(100% - 14px)', width: '100%' }}
|
||||
opts={{ renderer: 'svg' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/components/StatCard.tsx
Normal file
64
src/components/StatCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
variant?: 'default' | 'us' | 'iran' | 'accent'
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
/** 资产/人员用 base 适配百万位,海军/航空用 lg 适配万位 */
|
||||
valueSize?: 'base' | 'lg'
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: 'border-military-border text-military-text-primary',
|
||||
us: 'border-military-us/50 text-military-us',
|
||||
iran: 'border-military-iran/50 text-military-iran',
|
||||
accent: 'border-military-accent/50 text-military-accent',
|
||||
}
|
||||
|
||||
/** 根据数字位数返回字体大小类,百万位及以上缩小字体避免溢出 */
|
||||
function getValueSizeClass(value: string | number, baseSize: 'base' | 'lg'): string {
|
||||
const s = String(value).replace(/[^\d]/g, '')
|
||||
const len = s.length
|
||||
const base = baseSize === 'base' ? 'text-sm' : 'text-base'
|
||||
if (len >= 9) return 'text-[10px]'
|
||||
if (len >= 7) return 'text-xs'
|
||||
if (len >= 6) return baseSize === 'base' ? 'text-xs' : 'text-sm'
|
||||
return base
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
unit = '',
|
||||
variant = 'default',
|
||||
icon,
|
||||
className = '',
|
||||
valueSize = 'lg',
|
||||
}: StatCardProps) {
|
||||
const valueClass = getValueSizeClass(value, valueSize)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex min-w-0 flex-col rounded border px-2 py-1.5 font-orbitron
|
||||
bg-military-panel/80 backdrop-blur-sm
|
||||
${variantStyles[variant]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1 text-[9px] uppercase tracking-wider opacity-80">
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<div className={`mt-1 min-w-0 overflow-hidden font-bold tabular-nums leading-tight ${valueClass}`} title={String(value)}>
|
||||
<span className="block min-w-0">
|
||||
{value}
|
||||
{unit && <span className="ml-0.5 shrink-0 text-xs font-normal opacity-80">{unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
src/components/WarMap.tsx
Normal file
99
src/components/WarMap.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import Map, { Marker } from 'react-map-gl'
|
||||
import type { MapRef } from 'react-map-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
|
||||
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN || ''
|
||||
|
||||
// Persian Gulf center
|
||||
const DEFAULT_VIEW = {
|
||||
longitude: 54,
|
||||
latitude: 27,
|
||||
zoom: 5.5,
|
||||
}
|
||||
|
||||
export function WarMap() {
|
||||
const mapRef = useRef<MapRef>(null)
|
||||
const { situation } = useSituationStore()
|
||||
const { usForces, iranForces } = situation
|
||||
|
||||
const usMarkers = usForces.keyLocations
|
||||
const iranMarkers = iranForces.keyLocations
|
||||
|
||||
// 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>
|
||||
<p className="mt-2 text-xs text-military-text-secondary">
|
||||
请在 .env 中设置 VITE_MAPBOX_ACCESS_TOKEN
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={DEFAULT_VIEW}
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
mapboxAccessToken={MAPBOX_TOKEN}
|
||||
attributionControl={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>
|
||||
))}
|
||||
</Map>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
src/data/mockData.ts
Normal file
205
src/data/mockData.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// TypeScript interfaces for military situation data
|
||||
|
||||
export interface ForceAsset {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
count: number
|
||||
status: 'active' | 'standby' | 'alert'
|
||||
location?: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
export interface ForceSummary {
|
||||
totalAssets: number
|
||||
personnel: number
|
||||
navalShips: number
|
||||
aircraft: number
|
||||
groundUnits: number
|
||||
uav: number
|
||||
missileConsumed: number
|
||||
missileStock: number
|
||||
}
|
||||
|
||||
export interface PowerIndex {
|
||||
overall: number
|
||||
militaryStrength: number
|
||||
economicPower: number
|
||||
geopoliticalInfluence: number
|
||||
}
|
||||
|
||||
export interface CombatLosses {
|
||||
bases: { destroyed: number; damaged: number }
|
||||
personnelCasualties: { killed: number; wounded: number }
|
||||
aircraft: number
|
||||
warships: number
|
||||
armor: number
|
||||
vehicles: number
|
||||
}
|
||||
|
||||
export interface SituationUpdate {
|
||||
id: string
|
||||
timestamp: string
|
||||
category: 'deployment' | 'alert' | 'intel' | 'diplomatic' | 'other'
|
||||
summary: string
|
||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||
}
|
||||
|
||||
export interface MilitarySituation {
|
||||
lastUpdated: string
|
||||
usForces: {
|
||||
summary: ForceSummary
|
||||
powerIndex: PowerIndex
|
||||
assets: ForceAsset[]
|
||||
keyLocations: { name: string; lat: number; lng: number }[]
|
||||
combatLosses: CombatLosses
|
||||
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
|
||||
wallStreetInvestmentTrend: { time: string; value: number }[]
|
||||
}
|
||||
iranForces: {
|
||||
summary: ForceSummary
|
||||
powerIndex: PowerIndex
|
||||
assets: ForceAsset[]
|
||||
keyLocations: { name: string; lat: number; lng: number }[]
|
||||
combatLosses: CombatLosses
|
||||
/** 反击情绪指标 0-100 */
|
||||
retaliationSentiment: number
|
||||
/** 历史情绪曲线 { time: ISO string, value: 0-100 } */
|
||||
retaliationSentimentHistory: { time: string; value: number }[]
|
||||
}
|
||||
recentUpdates: SituationUpdate[]
|
||||
}
|
||||
|
||||
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
usForces: {
|
||||
summary: {
|
||||
totalAssets: 1247,
|
||||
personnel: 185000,
|
||||
navalShips: 285,
|
||||
aircraft: 1850,
|
||||
groundUnits: 18,
|
||||
uav: 420,
|
||||
missileConsumed: 156,
|
||||
missileStock: 2840,
|
||||
},
|
||||
powerIndex: {
|
||||
overall: 94,
|
||||
militaryStrength: 96,
|
||||
economicPower: 98,
|
||||
geopoliticalInfluence: 97,
|
||||
},
|
||||
assets: [
|
||||
{ id: 'us-1', name: '艾森豪威尔号航母', type: '航母', count: 1, 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 },
|
||||
],
|
||||
combatLosses: {
|
||||
bases: { destroyed: 0, damaged: 2 },
|
||||
personnelCasualties: { killed: 127, wounded: 384 },
|
||||
},
|
||||
wallStreetInvestmentTrend: [
|
||||
{ time: '2025-03-01T00:00:00', value: 82 },
|
||||
{ time: '2025-03-01T03:00:00', value: 85 },
|
||||
{ time: '2025-03-01T06:00:00', value: 88 },
|
||||
{ time: '2025-03-01T09:00:00', value: 90 },
|
||||
{ time: '2025-03-01T12:00:00', value: 92 },
|
||||
{ time: '2025-03-01T15:00:00', value: 94 },
|
||||
{ time: '2025-03-01T18:00:00', value: 95 },
|
||||
{ time: '2025-03-01T21:00:00', value: 96 },
|
||||
{ time: '2025-03-01T23:00:00', value: 98 },
|
||||
],
|
||||
},
|
||||
iranForces: {
|
||||
summary: {
|
||||
totalAssets: 8523,
|
||||
personnel: 2350000,
|
||||
navalShips: 4250,
|
||||
aircraft: 8200,
|
||||
groundUnits: 350,
|
||||
uav: 1850,
|
||||
missileConsumed: 3420,
|
||||
missileStock: 15600,
|
||||
},
|
||||
powerIndex: {
|
||||
overall: 42,
|
||||
militaryStrength: 58,
|
||||
economicPower: 28,
|
||||
geopoliticalInfluence: 35,
|
||||
},
|
||||
assets: [
|
||||
{ id: 'ir-1', name: '护卫舰', type: '水面舰艇', count: 6, status: 'active' },
|
||||
{ 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-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 },
|
||||
],
|
||||
combatLosses: {
|
||||
bases: { destroyed: 3, damaged: 8 },
|
||||
personnelCasualties: { killed: 2847, wounded: 5620 },
|
||||
aircraft: 24,
|
||||
warships: 12,
|
||||
armor: 18,
|
||||
vehicles: 42,
|
||||
},
|
||||
retaliationSentiment: 78,
|
||||
retaliationSentimentHistory: [
|
||||
{ time: '2025-03-01T00:00:00', value: 42 },
|
||||
{ time: '2025-03-01T03:00:00', value: 48 },
|
||||
{ time: '2025-03-01T06:00:00', value: 55 },
|
||||
{ time: '2025-03-01T09:00:00', value: 61 },
|
||||
{ time: '2025-03-01T12:00:00', value: 58 },
|
||||
{ time: '2025-03-01T15:00:00', value: 65 },
|
||||
{ time: '2025-03-01T18:00:00', value: 72 },
|
||||
{ time: '2025-03-01T21:00:00', value: 76 },
|
||||
{ time: '2025-03-01T23:00:00', value: 78 },
|
||||
],
|
||||
},
|
||||
recentUpdates: [
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
category: 'deployment',
|
||||
summary: '美军航母打击群在阿拉伯海重新部署',
|
||||
severity: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
category: 'alert',
|
||||
summary: '霍尔木兹海峡海军巡逻活动加强',
|
||||
severity: 'high',
|
||||
},
|
||||
{
|
||||
id: 'u3',
|
||||
timestamp: new Date(Date.now() - 10800000).toISOString(),
|
||||
category: 'intel',
|
||||
summary: '卫星图像显示阿巴斯港活动增加',
|
||||
severity: 'low',
|
||||
},
|
||||
{
|
||||
id: 'u4',
|
||||
timestamp: new Date(Date.now() - 14400000).toISOString(),
|
||||
category: 'diplomatic',
|
||||
summary: '阿曼间接谈判进行中',
|
||||
severity: 'low',
|
||||
},
|
||||
],
|
||||
}
|
||||
50
src/index.css
Normal file
50
src/index.css
Normal file
@@ -0,0 +1,50 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-dark: #0A0F1C;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
min-width: 100vw;
|
||||
height: 100%;
|
||||
background: var(--bg-dark);
|
||||
color: #F3F4F6;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Military-grade high-contrast typography */
|
||||
.font-orbitron {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
}
|
||||
|
||||
/* Tabular numbers for aligned stat display */
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Thin scrollbar for value overflow */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
77
src/pages/Dashboard.tsx
Normal file
77
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from 'react'
|
||||
import { HeaderPanel } from '@/components/HeaderPanel'
|
||||
import { ForcePanel } from '@/components/ForcePanel'
|
||||
import { WarMap } from '@/components/WarMap'
|
||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||
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'
|
||||
|
||||
export function Dashboard() {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
|
||||
useEffect(() => {
|
||||
startWebSocketMock()
|
||||
return () => stopWebSocketMock()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full min-h-0 flex-col overflow-hidden bg-military-dark font-orbitron">
|
||||
<HeaderPanel />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
||||
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-b border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-b-0 lg:border-r lg:p-4">
|
||||
<div className="flex h-44 shrink-0 flex-col gap-0 rounded-lg border border-military-us/30 bg-military-panel/80 p-1 lg:h-48">
|
||||
<div className="h-[55%] min-h-0 shrink-0">
|
||||
<PowerChart
|
||||
usPower={situation.usForces.powerIndex}
|
||||
iranPower={situation.iranForces.powerIndex}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[45%] min-h-0 shrink-0">
|
||||
<InvestmentTrendChart
|
||||
history={situation.usForces.wallStreetInvestmentTrend}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ForcePanel
|
||||
side="us"
|
||||
summary={situation.usForces.summary}
|
||||
powerIndex={situation.usForces.powerIndex}
|
||||
assets={situation.usForces.assets}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-[200px] min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1">
|
||||
<WarMap />
|
||||
</div>
|
||||
<CombatLossesPanel
|
||||
usLosses={situation.usForces.combatLosses}
|
||||
iranLosses={situation.iranForces.combatLosses}
|
||||
/>
|
||||
</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">
|
||||
<div className="h-44 shrink-0 rounded-lg border border-military-iran/30 bg-military-panel/80 p-1 lg:h-48">
|
||||
<RetaliationGauge
|
||||
value={situation.iranForces.retaliationSentiment}
|
||||
history={situation.iranForces.retaliationSentimentHistory}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<ForcePanel
|
||||
side="iran"
|
||||
summary={situation.iranForces.summary}
|
||||
powerIndex={situation.iranForces.powerIndex}
|
||||
assets={situation.iranForces.assets}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/store/situationStore.ts
Normal file
82
src/store/situationStore.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { create } from 'zustand'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
import { INITIAL_MOCK_DATA } from '@/data/mockData'
|
||||
|
||||
interface SituationState {
|
||||
situation: MilitarySituation
|
||||
isConnected: boolean
|
||||
lastError: string | null
|
||||
setSituation: (situation: MilitarySituation) => void
|
||||
updateFromWebSocket: (partial: Partial<MilitarySituation>) => void
|
||||
setConnected: (connected: boolean) => void
|
||||
setLastError: (error: string | null) => void
|
||||
}
|
||||
|
||||
export const useSituationStore = create<SituationState>((set) => ({
|
||||
situation: INITIAL_MOCK_DATA,
|
||||
isConnected: false,
|
||||
lastError: null,
|
||||
|
||||
setSituation: (situation) => set({ situation }),
|
||||
|
||||
updateFromWebSocket: (partial) =>
|
||||
set((state) => ({
|
||||
situation: {
|
||||
...state.situation,
|
||||
...partial,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
})),
|
||||
|
||||
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 stopWebSocketMock(): void {
|
||||
if (mockWsInterval) {
|
||||
clearInterval(mockWsInterval)
|
||||
mockWsInterval = null
|
||||
}
|
||||
useSituationStore.getState().setConnected(false)
|
||||
}
|
||||
11
src/utils/formatNumber.ts
Normal file
11
src/utils/formatNumber.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 资产、人员:支持到百万位 (最高 9,999,999)
|
||||
* 海军、航空:支持到万位 (最高 99,999)
|
||||
*/
|
||||
export function formatMillions(n: number): string {
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
export function formatTenThousands(n: number): string {
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_MAPBOX_ACCESS_TOKEN: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
38
tailwind.config.js
Normal file
38
tailwind.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
'vert-marquee': {
|
||||
'0%': { transform: 'translateY(0)' },
|
||||
'100%': { transform: 'translateY(-50%)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'vert-marquee': 'vert-marquee 25s linear infinite',
|
||||
},
|
||||
fontFamily: {
|
||||
orbitron: ['Orbitron', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
military: {
|
||||
dark: '#0A0F1C',
|
||||
panel: '#111827',
|
||||
border: '#1F2937',
|
||||
accent: '#00D4AA',
|
||||
us: '#3B82F6',
|
||||
iran: '#EF4444',
|
||||
neutral: '#9CA3AF',
|
||||
'neutral-dim': '#6B7280',
|
||||
'text-primary': '#F3F4F6',
|
||||
'text-secondary': '#D1D5DB',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/forcepanel.tsx","./src/components/headerpanel.tsx","./src/components/statcard.tsx","./src/components/warmap.tsx","./src/data/mockdata.ts","./src/pages/dashboard.tsx","./src/store/situationstore.ts"],"errors":true,"version":"5.6.3"}
|
||||
12
vite.config.ts
Normal file
12
vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user