feat: new file

This commit is contained in:
Daniel
2026-03-01 17:21:15 +08:00
commit d705fd6c83
28 changed files with 5877 additions and 0 deletions

2
.env.example Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

14
src/App.tsx Normal file
View 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

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

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

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

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

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

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

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

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