fix: 优化自适应界面

This commit is contained in:
Daniel
2026-03-02 18:39:29 +08:00
parent 4e91018752
commit 13ca470cad
16 changed files with 145 additions and 72 deletions

View File

@@ -41,6 +41,11 @@ def _ensure_tables(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE combat_losses ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))")
except sqlite3.OperationalError:
pass
for col in ("drones", "missiles", "helicopters", "submarines"):
try:
conn.execute(f"ALTER TABLE combat_losses ADD COLUMN {col} INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
conn.execute("CREATE TABLE IF NOT EXISTS wall_street_trend (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_current (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_history (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
@@ -74,16 +79,19 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
continue
try:
row = conn.execute(
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?",
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles,drones,missiles,helicopters,submarines FROM combat_losses WHERE side = ?",
(side,),
).fetchone()
cur = {"personnel_killed": 0, "personnel_wounded": 0, "civilian_killed": 0, "civilian_wounded": 0,
"bases_destroyed": 0, "bases_damaged": 0, "aircraft": 0, "warships": 0, "armor": 0, "vehicles": 0}
"bases_destroyed": 0, "bases_damaged": 0, "aircraft": 0, "warships": 0, "armor": 0, "vehicles": 0,
"drones": 0, "missiles": 0, "helicopters": 0, "submarines": 0}
if row:
cur = {
"personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0,
"civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5],
"aircraft": row[6], "warships": row[7], "armor": row[8], "vehicles": row[9],
"drones": row[10] if len(row) > 10 else 0, "missiles": row[11] if len(row) > 11 else 0,
"helicopters": row[12] if len(row) > 12 else 0, "submarines": row[13] if len(row) > 13 else 0,
}
pk = max(0, (cur["personnel_killed"] or 0) + delta.get("personnel_killed", 0))
pw = max(0, (cur["personnel_wounded"] or 0) + delta.get("personnel_wounded", 0))
@@ -95,18 +103,23 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
ws = max(0, (cur["warships"] or 0) + delta.get("warships", 0))
ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0))
vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0))
dr = max(0, (cur["drones"] or 0) + delta.get("drones", 0))
ms = max(0, (cur["missiles"] or 0) + delta.get("missiles", 0))
hp = max(0, (cur["helicopters"] or 0) + delta.get("helicopters", 0))
sb = max(0, (cur["submarines"] or 0) + delta.get("submarines", 0))
ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
if row:
conn.execute(
"""UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?,
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""",
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side),
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?,
drones=?, missiles=?, helicopters=?, submarines=?, updated_at=? WHERE side=?""",
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, dr, ms, hp, sb, ts, side),
)
else:
conn.execute(
"""INSERT OR REPLACE INTO combat_losses (side, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded,
bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(side, pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts),
bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, drones, missiles, helicopters, submarines, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(side, pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, dr, ms, hp, sb, ts),
)
if conn.total_changes > 0:
updated = True

View File

@@ -19,7 +19,7 @@ TimeSeriesPoint = Tuple[str, int] # (ISO time, value)
# AI 可从新闻中提取的字段
EXTRACTABLE_FIELDS = {
"situation_update": ["summary", "category", "severity", "timestamp"],
"combat_losses": ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"],
"combat_losses": ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles", "drones", "missiles", "helicopters", "submarines"],
"retaliation": ["value"], # 0-100
"wall_street_trend": ["time", "value"], # 0-100
"conflict_stats": ["estimated_casualties", "estimated_strike_count"],

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -145,6 +145,10 @@ try {
if (!lossNames.includes('civilian_killed')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('civilian_wounded')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('updated_at')) db.exec('ALTER TABLE combat_losses ADD COLUMN updated_at TEXT DEFAULT (datetime("now"))')
if (!lossNames.includes('drones')) db.exec('ALTER TABLE combat_losses ADD COLUMN drones INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('missiles')) db.exec('ALTER TABLE combat_losses ADD COLUMN missiles INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('helicopters')) db.exec('ALTER TABLE combat_losses ADD COLUMN helicopters INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('submarines')) db.exec('ALTER TABLE combat_losses ADD COLUMN submarines INTEGER NOT NULL DEFAULT 0')
} catch (_) {}
// 迁移:所有表添加 updated_at 用于数据回放

View File

@@ -149,9 +149,9 @@ function seed() {
try {
db.exec(`
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles) VALUES
('us', 0, 27, 127, 384, 18, 52, 2, 0, 0, 8),
('iran', 3, 8, 2847, 5620, 412, 1203, 24, 12, 18, 42);
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles, drones, missiles, helicopters, submarines) VALUES
('us', 0, 27, 127, 384, 18, 52, 2, 0, 0, 8, 4, 12, 1, 0),
('iran', 3, 8, 2847, 5620, 412, 1203, 24, 12, 18, 42, 28, 156, 8, 2);
`)
} catch (_) {
db.exec(`

View File

@@ -20,6 +20,10 @@ function toLosses(row) {
warships: row.warships,
armor: row.armor,
vehicles: row.vehicles,
drones: row.drones ?? 0,
missiles: row.missiles ?? 0,
helicopters: row.helicopters ?? 0,
submarines: row.submarines ?? 0,
}
}
@@ -31,6 +35,10 @@ const defaultLosses = {
warships: 0,
armor: 0,
vehicles: 0,
drones: 0,
missiles: 0,
helicopters: 0,
submarines: 0,
}
function getSituation() {

View File

@@ -6,6 +6,10 @@ import {
Ship,
Shield,
Car,
Scan,
Rocket,
Wind,
Anchor,
TrendingDown,
UserCircle,
Activity,
@@ -31,10 +35,14 @@ export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilia
{ label: '战舰', icon: Ship, iconColor: 'text-blue-500', us: usLosses.warships, ir: iranLosses.warships },
{ label: '装甲', icon: Shield, iconColor: 'text-emerald-500', us: usLosses.armor, ir: iranLosses.armor },
{ label: '车辆', icon: Car, iconColor: 'text-slate-400', us: usLosses.vehicles, ir: iranLosses.vehicles },
{ label: '无人机', icon: Scan, iconColor: 'text-violet-400', us: usLosses.drones ?? 0, ir: iranLosses.drones ?? 0 },
{ label: '导弹', icon: Rocket, iconColor: 'text-orange-500', us: usLosses.missiles ?? 0, ir: iranLosses.missiles ?? 0 },
{ label: '直升机', icon: Wind, iconColor: 'text-teal-400', us: usLosses.helicopters ?? 0, ir: iranLosses.helicopters ?? 0 },
{ label: '潜艇', icon: Anchor, iconColor: 'text-indigo-400', us: usLosses.submarines ?? 0, ir: iranLosses.submarines ?? 0 },
]
return (
<div className={`flex min-h-[220px] max-h-[240px] min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/95 font-orbitron ${className}`}>
<div className={`flex min-h-[200px] min-w-0 flex-1 flex-col overflow-auto rounded border border-military-border bg-military-panel/95 font-orbitron ${className}`}>
<div className="mb-1.5 flex shrink-0 items-center justify-center gap-2 text-[10px] uppercase tracking-wider text-military-text-secondary">
<TrendingDown className="h-2.5 w-2.5 shrink-0 text-amber-400" />
@@ -46,7 +54,7 @@ export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilia
)}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-3 pb-2">
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-visible px-3 pb-2">
{/* 人员伤亡 - 单独容器 */}
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2">
<div className="mb-1 flex shrink-0 items-center justify-center gap-3 text-[9px] text-military-text-secondary">
@@ -57,14 +65,14 @@ export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilia
</div>
<div className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-1 overflow-hidden text-center tabular-nums sm:gap-x-4">
<div className="min-w-0 truncate text-military-us" title={`美: ${formatMillions(usLosses.personnelCasualties.killed)} / ${formatMillions(usLosses.personnelCasualties.wounded)}`}>
<span className="text-base font-bold text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</span>
<span className="text-sm font-bold text-red-500 sm:text-base">{formatMillions(usLosses.personnelCasualties.killed)}</span>
<span className="mx-0.5 text-military-text-secondary">/</span>
<span className="text-base font-semibold text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</span>
<span className="text-sm font-semibold text-amber-500 sm:text-base">{formatMillions(usLosses.personnelCasualties.wounded)}</span>
</div>
<div className="min-w-0 truncate text-military-iran" title={`伊: ${formatMillions(iranLosses.personnelCasualties.killed)} / ${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
<span className="text-base font-bold text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</span>
<span className="text-sm font-bold text-red-500 sm:text-base">{formatMillions(iranLosses.personnelCasualties.killed)}</span>
<span className="mx-0.5 text-military-text-secondary">/</span>
<span className="text-base font-semibold text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</span>
<span className="text-sm font-semibold text-amber-500 sm:text-base">{formatMillions(iranLosses.personnelCasualties.wounded)}</span>
</div>
</div>
</div>
@@ -75,36 +83,36 @@ export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilia
<UserCircle className="h-3 w-3 text-amber-400" />
</div>
<div className="flex items-center justify-center gap-3 text-center tabular-nums">
<div className="flex flex-wrap items-center justify-center gap-2 text-center tabular-nums sm:gap-3">
<span className="flex items-center gap-0.5">
<Skull className="h-3 w-3 text-red-500" />
<span className="text-base font-bold text-red-500">{formatMillions(civ.killed)}</span>
<span className="text-sm font-bold text-red-500 sm:text-base">{formatMillions(civ.killed)}</span>
</span>
<span className="text-military-text-secondary/60">/</span>
<span className="flex items-center gap-0.5">
<Bandage className="h-3 w-3 text-amber-500" />
<span className="text-base font-semibold text-amber-500">{formatMillions(civ.wounded)}</span>
<span className="text-sm font-semibold text-amber-500 sm:text-base">{formatMillions(civ.wounded)}</span>
</span>
</div>
</div>
{/* 其它 - 标签+图标+数字,单独容器 */}
<div className="min-h-0 min-w-0 flex-1 overflow-hidden rounded border border-military-border/50 bg-military-dark/30 px-2 py-1.5">
{/* 其它 - 标签+图标+数字,竖屏横屏均完整显示,自适应排版 */}
<div className="min-w-0 shrink-0 rounded border border-military-border/50 bg-military-dark/30 px-2 py-1.5">
<div className="mb-1 text-[8px] text-military-text-secondary">:</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 overflow-hidden text-[11px] tabular-nums lg:grid-cols-3">
<div className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-1 tabular-nums sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{otherRows.map(({ label, icon: Icon, iconColor, ...rest }, i) => (
<div key={i} className="flex min-w-0 items-center justify-between gap-1 overflow-hidden">
<span className="flex shrink-0 items-center gap-0.5 text-military-text-primary">
<Icon className={`h-3 w-3 ${iconColor}`} />
<div key={i} className="flex min-w-0 items-center justify-between gap-1">
<span className="flex shrink-0 items-center gap-0.5 text-military-text-primary text-[9px] sm:text-[10px]">
<Icon className={`h-2.5 w-2.5 shrink-0 sm:h-3 sm:w-3 ${iconColor}`} />
{label}
</span>
{'value' in rest ? (
<span className="min-w-0 truncate text-right text-amber-400">{rest.value}</span>
<span className="min-w-0 text-right text-[9px] text-amber-400 sm:text-[10px] tabular-nums">{String(rest.value)}</span>
) : (
<span className="min-w-0 truncate text-right">
<span className="text-military-us">{rest.us}</span>
<span className="min-w-0 shrink-0 text-right text-[9px] sm:text-[10px]">
<span className="text-military-us tabular-nums">{rest.us}</span>
<span className="text-military-text-secondary/60">:</span>
<span className="text-military-iran">{rest.ir}</span>
<span className="text-military-iran tabular-nums">{rest.ir}</span>
</span>
)}
</div>

View File

@@ -133,15 +133,15 @@ export function HeaderPanel() {
}
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">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-military-border bg-military-panel/95 px-2 py-2 font-orbitron sm:gap-3 sm:px-4 sm:py-3 lg:flex-nowrap lg:gap-4 lg:px-6">
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:gap-3 lg:gap-6">
<h1 className="truncate text-sm font-bold uppercase tracking-wider text-military-accent sm:text-base sm:tracking-widest lg:text-2xl">
</h1>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-sm text-military-text-secondary">
<Clock className="h-4 w-4 shrink-0" />
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
<div className="flex min-w-0 shrink-0 flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-xs text-military-text-secondary sm:gap-2 sm:text-sm">
<Clock className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="tabular-nums sm:min-w-[10rem]">{formatDateTime(now)}</span>
</div>
{(isConnected || isReplayMode) && (
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
@@ -150,8 +150,8 @@ export function HeaderPanel() {
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-4">
<div className="flex items-center gap-2 text-military-text-secondary">
<div className="flex min-w-0 shrink flex-wrap items-center justify-end gap-2 sm:gap-3 lg:shrink-0 lg:gap-4">
<div className="flex shrink-0 items-center gap-1.5 text-military-text-secondary sm:gap-2">
<Eye className="h-3.5 w-3.5" />
<span className="text-[10px]"> <b className="text-military-accent tabular-nums">{viewers}</b></span>
<span className="text-[10px] opacity-70">|</span>
@@ -160,48 +160,41 @@ export function HeaderPanel() {
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
className="flex shrink-0 items-center gap-1 rounded border border-military-border px-1.5 py-0.5 text-[9px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400 sm:px-2 sm:py-1 sm:text-[10px]"
>
<MessageSquare className="h-3 w-3" />
<MessageSquare className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</button>
<button
type="button"
onClick={handleShare}
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
className="flex shrink-0 items-center gap-1 rounded border border-military-border px-1.5 py-0.5 text-[9px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400 sm:px-2 sm:py-1 sm:text-[10px]"
>
<Share2 className="h-3 w-3" />
<Share2 className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</button>
<button
type="button"
onClick={handleLike}
className={`flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors ${
className={`flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors sm:px-2 sm:py-1 sm:text-[10px] ${
liked
? 'border-red-500/50 bg-red-500/20 text-red-400'
: 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400'
}`}
>
<Heart className={`h-3 w-3 ${liked ? 'fill-current' : ''}`} />
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 ${liked ? 'fill-current' : ''}`} />
{likes > 0 && <span className="tabular-nums">{likes}</span>}
</button>
{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>
</>
)}
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>
{isConnected ? <Wifi className="h-3.5 w-3.5" /> : <WifiOff className="h-3.5 w-3.5" />}
<span className="text-xs">{isConnected ? '实时' : '已断开'}</span>
</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="flex min-w-0 shrink flex-wrap items-center gap-2 sm:gap-3 lg:gap-4">
<div className="flex shrink-0 flex-col">
<span className="text-[9px] uppercase tracking-wider text-military-text-secondary sm:text-[10px]"></span>
<div className="mt-0.5 flex h-1.5 w-20 overflow-hidden rounded-full bg-military-border sm:h-2 sm:w-28">
<div
className="bg-military-us transition-all"
style={{ width: `${(usForces.powerIndex.overall / (usForces.powerIndex.overall + iranForces.powerIndex.overall)) * 100}%` }}
@@ -211,29 +204,29 @@ export function HeaderPanel() {
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">
<div className="mt-0.5 flex justify-between text-[8px] tabular-nums text-military-text-secondary sm:text-[9px]">
<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" />
<div className="hidden h-8 w-px shrink-0 bg-military-border sm:block" />
<StatCard
label="美国/盟国"
value={usForces.powerIndex.overall}
variant="us"
className="border-military-us/50"
className="shrink-0 border-military-us/50 px-1.5 py-1 sm:px-2 sm:py-1.5"
/>
<StatCard
label="伊朗"
value={iranForces.powerIndex.overall}
variant="iran"
className="border-military-iran/50"
className="shrink-0 border-military-iran/50 px-1.5 py-1 sm:px-2 sm:py-1.5"
/>
<StatCard
label="差距"
value={`+${usForces.powerIndex.overall - iranForces.powerIndex.overall}`}
variant="accent"
className="border-military-accent/50"
className="shrink-0 border-military-accent/50 px-1.5 py-1 sm:px-2 sm:py-1.5"
/>
</div>
{/* 留言弹窗 */}

View File

@@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef } from 'react'
import { useMemo, useEffect, useRef, useCallback } from 'react'
import Map, { Source, Layer } from 'react-map-gl'
import type { MapRef } from 'react-map-gl'
import type { Map as MapboxMap } from 'mapbox-gl'
@@ -20,6 +20,11 @@ const MAPBOX_TOKEN = config.mapboxAccessToken || ''
// 相关区域 bbox伊朗、以色列、胡塞区 (minLng, minLat, maxLng, maxLat),覆盖红蓝区域
const THEATER_BBOX = [22, 11, 64, 41] as const
/** 移动端/小屏时 fitBounds 使区域完整显示 */
const THEATER_BOUNDS: [[number, number], [number, number]] = [
[THEATER_BBOX[0], THEATER_BBOX[1]],
[THEATER_BBOX[2], THEATER_BBOX[3]],
]
const THEATER_CENTER = {
longitude: (THEATER_BBOX[0] + THEATER_BBOX[2]) / 2,
latitude: (THEATER_BBOX[1] + THEATER_BBOX[3]) / 2,
@@ -124,6 +129,7 @@ const FLIGHT_DURATION_MS = 2500 // 光点飞行单程时间
export function WarMap() {
const mapRef = useRef<MapRef>(null)
const containerRef = useRef<HTMLDivElement>(null)
const animRef = useRef<number>(0)
const startRef = useRef<number>(0)
const attackPathsRef = useRef<[number, number][][]>([])
@@ -433,6 +439,21 @@ export function WarMap() {
return () => cancelAnimationFrame(animRef.current)
}, [])
// 容器尺寸变化时 fitBounds保证区域完整显示移动端自适应
const fitToTheater = useCallback(() => {
const map = mapRef.current?.getMap()
if (!map) return
map.fitBounds(THEATER_BOUNDS, { padding: 32, maxZoom: 6, duration: 0 })
}, [])
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => fitToTheater())
ro.observe(el)
return () => ro.disconnect()
}, [fitToTheater])
if (!MAPBOX_TOKEN) {
return (
<div className="flex h-full w-full items-center justify-center bg-military-dark">
@@ -455,7 +476,7 @@ export function WarMap() {
}
return (
<div className="relative h-full w-full">
<div ref={containerRef} className="relative h-full w-full min-w-0">
{/* 图例 - 随容器自适应,避免遮挡 */}
<div className="absolute bottom-2 left-2 z-10 flex flex-wrap gap-x-3 gap-y-1 rounded bg-black/70 px-2 py-1.5 text-[9px] sm:text-[10px]">
<span className="flex items-center gap-1">
@@ -502,7 +523,7 @@ export function WarMap() {
touchRotate={false}
style={{ width: '100%', height: '100%' }}
onLoad={(e) => {
// 地图加载完成后启动动画;延迟确保 Source/Layer 已挂载
fitToTheater()
setTimeout(() => initAnimation.current(e.target), 150)
}}
>

View File

@@ -37,6 +37,11 @@ export interface CombatLosses {
warships: number
armor: number
vehicles: number
/** 其它 */
drones?: number
missiles?: number
helicopters?: number
submarines?: number
}
export interface SituationUpdate {
@@ -152,6 +157,10 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
warships: 0,
armor: 0,
vehicles: 8,
drones: 4,
missiles: 12,
helicopters: 1,
submarines: 0,
},
wallStreetInvestmentTrend: [
{ time: '2025-03-01T00:00:00', value: 82 },
@@ -202,6 +211,10 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
warships: 12,
armor: 18,
vehicles: 42,
drones: 28,
missiles: 156,
helicopters: 8,
submarines: 2,
},
retaliationSentiment: 78,
retaliationSentimentHistory: [

View File

@@ -76,6 +76,10 @@ export function useReplaySituation(): MilitarySituation {
warships: lerp(0, usLoss.warships),
armor: lerp(0, usLoss.armor),
vehicles: lerp(0, usLoss.vehicles),
drones: lerp(0, usLoss.drones ?? 0),
missiles: lerp(0, usLoss.missiles ?? 0),
helicopters: lerp(0, usLoss.helicopters ?? 0),
submarines: lerp(0, usLoss.submarines ?? 0),
}
const irLossesAt = {
bases: {
@@ -91,6 +95,10 @@ export function useReplaySituation(): MilitarySituation {
warships: lerp(0, irLoss.warships),
armor: lerp(0, irLoss.armor),
vehicles: lerp(0, irLoss.vehicles),
drones: lerp(0, irLoss.drones ?? 0),
missiles: lerp(0, irLoss.missiles ?? 0),
helicopters: lerp(0, irLoss.helicopters ?? 0),
submarines: lerp(0, irLoss.submarines ?? 0),
}
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked

View File

@@ -61,3 +61,5 @@ body,
to { transform: translateX(-50%); }
}
/* 移动端横屏使用单列+滚动,不再做 zoom 缩放,保持比例正常 */

View File

@@ -24,7 +24,7 @@ export function Dashboard() {
}, [])
return (
<div className="flex h-screen w-full max-w-full min-h-0 flex-col overflow-hidden bg-military-dark font-orbitron">
<div className="landscape-scaler flex h-screen w-full max-w-full min-h-0 flex-col overflow-hidden bg-military-dark font-orbitron">
{lastError && (
<div className="shrink-0 bg-amber-500/20 px-4 py-2 text-center text-sm text-amber-400">
{lastError}使 API + WebSocket npm run api
@@ -34,20 +34,20 @@ export function Dashboard() {
<TimelinePanel />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto overflow-x-hidden xl:flex-row xl:overflow-hidden">
{/* 竖屏/小屏:地图(100%宽) → 战损 → 左 → 右。横屏≥1280px左|中|右 */}
{/* xl左|中|右。竖屏:地图→战损→美国基地→伊朗基地→左→右 */}
<main className="flex w-full min-h-[280px] shrink-0 flex-col overflow-hidden xl:order-2 xl:min-h-0 xl:min-w-0 xl:flex-1 xl:shrink">
<div className="h-[45vmin] min-h-[220px] w-full shrink-0 xl:min-h-0 xl:flex-1">
<div className="h-[45vmin] min-h-[180px] w-full shrink-0 xl:min-h-0 xl:flex-1">
<WarMap />
</div>
<div className="flex shrink-0 flex-col gap-2 overflow-x-auto border-t border-military-border bg-military-panel/95 px-3 py-2 xl:flex-row xl:items-stretch xl:overflow-visible xl:px-4">
<BaseStatusPanel keyLocations={situation.usForces.keyLocations} className="shrink-0 xl:min-w-[200px] xl:border-r xl:border-military-border xl:pr-4" />
<CombatLossesPanel
usLosses={situation.usForces.combatLosses}
iranLosses={situation.iranForces.combatLosses}
conflictStats={situation.conflictStats}
civilianTotal={situation.civilianCasualtiesTotal}
className="min-w-0 flex-1 py-1"
className="min-w-0 flex-1 shrink-0 py-1"
/>
<BaseStatusPanel keyLocations={situation.usForces.keyLocations} className="shrink-0 xl:min-w-[200px] xl:border-r xl:border-military-border xl:pr-4" />
<IranBaseStatusPanel
keyLocations={situation.iranForces.keyLocations}
className="min-w-0 shrink-0 xl:min-w-[200px]"

View File

@@ -6,6 +6,9 @@ export default {
],
theme: {
extend: {
screens: {
'landscape-sm': { raw: '(orientation: landscape) and (max-height: 768px)' },
},
keyframes: {
'vert-marquee': {
'0%': { transform: 'translateY(0)' },