fix: 优化数据

This commit is contained in:
Daniel
2026-03-02 11:28:13 +08:00
parent 4a8fff5a00
commit 004d10b283
39 changed files with 1106 additions and 56 deletions

View File

@@ -1,4 +1,6 @@
import { Routes, Route } from 'react-router-dom'
import { Dashboard } from '@/pages/Dashboard'
import { DbDashboard } from '@/pages/DbDashboard'
function App() {
return (
@@ -6,7 +8,10 @@ function App() {
className="min-h-screen w-full bg-military-dark overflow-hidden"
style={{ background: '#0A0F1C' }}
>
<Dashboard />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/db" element={<DbDashboard />} />
</Routes>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import type { MilitarySituation } from '@/data/mockData'
export async function fetchSituation(): Promise<MilitarySituation> {
const res = await fetch('/api/situation')
const res = await fetch(`/api/situation?t=${Date.now()}`, { cache: 'no-store' })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}

View File

@@ -17,16 +17,15 @@ interface CombatLossesPanelProps {
usLosses: CombatLosses
iranLosses: CombatLosses
conflictStats?: ConflictStats | null
/** 平民伤亡合计(不区分阵营) */
civilianTotal?: { killed: number; wounded: number }
className?: string
}
export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, className = '' }: CombatLossesPanelProps) {
const civUs = usLosses.civilianCasualties ?? { killed: 0, wounded: 0 }
const civIr = iranLosses.civilianCasualties ?? { killed: 0, wounded: 0 }
const civTotal = { killed: (civUs.killed ?? 0) + (civIr.killed ?? 0), wounded: (civUs.wounded ?? 0) + (civIr.wounded ?? 0) }
export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilianTotal, className = '' }: CombatLossesPanelProps) {
const civ = civilianTotal ?? { killed: 0, wounded: 0 }
const otherRows = [
{ label: '平民', icon: UserCircle, iconColor: 'text-amber-400', value: `${formatMillions(civTotal.killed)} / ${formatMillions(civTotal.wounded)}`, noSide: true },
{ label: '基地', icon: Building2, iconColor: 'text-amber-500', us: `${usLosses.bases.destroyed}/${usLosses.bases.damaged}`, ir: `${iranLosses.bases.destroyed}/${iranLosses.bases.damaged}` },
{ label: '战机', icon: Plane, iconColor: 'text-sky-400', us: usLosses.aircraft, ir: iranLosses.aircraft },
{ label: '战舰', icon: Ship, iconColor: 'text-blue-500', us: usLosses.warships, ir: iranLosses.warships },
@@ -70,6 +69,25 @@ export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, classNa
</div>
</div>
{/* 平民伤亡:合计显示,不区分阵营 */}
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2">
<div className="mb-1 flex shrink-0 items-center justify-center gap-2 text-[9px] text-military-text-secondary">
<UserCircle className="h-3 w-3 text-amber-400" />
</div>
<div className="flex items-center justify-center gap-3 text-center tabular-nums">
<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>
<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>
</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="mb-1 text-[8px] text-military-text-secondary">:</div>

View File

@@ -1,5 +1,7 @@
import * as React from 'react'
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
import { History } from 'lucide-react'
import { History, RefreshCw } from 'lucide-react'
import { fetchAndSetSituation } from '@/store/situationStore'
interface EventTimelinePanelProps {
updates: SituationUpdate[]
@@ -29,6 +31,11 @@ type TimelineItem = {
}
export function EventTimelinePanel({ updates = [], conflictEvents = [], className = '' }: EventTimelinePanelProps) {
const [refreshing, setRefreshing] = React.useState(false)
const handleRefresh = React.useCallback(async () => {
setRefreshing(true)
await fetchAndSetSituation().finally(() => setRefreshing(false))
}, [])
// 合并 GDELT + RSS按时间倒序最新在前
const merged: TimelineItem[] = [
...(conflictEvents || []).map((e) => ({
@@ -49,7 +56,7 @@ export function EventTimelinePanel({ updates = [], conflictEvents = [], classNam
})),
]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 6)
.slice(0, 8)
return (
<div className={`flex min-w-0 max-w-[280px] shrink flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
@@ -58,7 +65,16 @@ export function EventTimelinePanel({ updates = [], conflictEvents = [], classNam
<History className="h-3.5 w-3.5 shrink-0 text-military-accent" />
</span>
<span className="text-[8px] text-military-text-secondary/80">GDELT · Reuters · BBC · Al Jazeera · NYT</span>
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-1 rounded p-0.5 text-[8px] text-military-text-secondary/80 hover:bg-military-border/30 hover:text-cyan-400 disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="min-h-0 max-h-[140px] flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
{merged.length === 0 ? (

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore'
import { Wifi, WifiOff, Clock } from 'lucide-react'
import { Wifi, WifiOff, Clock, Database } from 'lucide-react'
export function HeaderPanel() {
const situation = useReplaySituation()
@@ -58,7 +59,14 @@ export function HeaderPanel() {
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-3">
<Link
to="/db"
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"
>
<Database className="h-3 w-3" />
</Link>
{isConnected ? (
<>
<Wifi className="h-3.5 w-3.5 text-green-500" />

View File

@@ -109,6 +109,8 @@ export interface MilitarySituation {
conflictEvents?: ConflictEvent[]
/** 战损统计(展示用) */
conflictStats?: ConflictStats
/** 平民伤亡合计(不区分阵营) */
civilianCasualtiesTotal?: { killed: number; wounded: number }
}
export const INITIAL_MOCK_DATA: MilitarySituation = {
@@ -246,4 +248,5 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
],
conflictEvents: [],
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
civilianCasualtiesTotal: { killed: 430, wounded: 1255 },
}

View File

@@ -61,8 +61,7 @@ export function useReplaySituation(): MilitarySituation {
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
const usLoss = situation.usForces.combatLosses
const irLoss = situation.iranForces.combatLosses
const civUs = usLoss.civilianCasualties ?? { killed: 0, wounded: 0 }
const civIr = irLoss.civilianCasualties ?? { killed: 0, wounded: 0 }
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
const usLossesAt = {
bases: {
destroyed: lerp(0, usLoss.bases.destroyed),
@@ -72,7 +71,7 @@ export function useReplaySituation(): MilitarySituation {
killed: lerp(0, usLoss.personnelCasualties.killed),
wounded: lerp(0, usLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: lerp(0, civUs.killed), wounded: lerp(0, civUs.wounded) },
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, usLoss.aircraft),
warships: lerp(0, usLoss.warships),
armor: lerp(0, usLoss.armor),
@@ -87,7 +86,7 @@ export function useReplaySituation(): MilitarySituation {
killed: lerp(0, irLoss.personnelCasualties.killed),
wounded: lerp(0, irLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: lerp(0, civIr.killed), wounded: lerp(0, civIr.wounded) },
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, irLoss.aircraft),
warships: lerp(0, irLoss.warships),
armor: lerp(0, irLoss.armor),
@@ -115,6 +114,10 @@ export function useReplaySituation(): MilitarySituation {
return {
...situation,
lastUpdated: playbackTime,
civilianCasualtiesTotal: {
killed: lerp(0, civTotal.killed),
wounded: lerp(0, civTotal.wounded),
},
usForces: {
...situation.usForces,
keyLocations: usLocsAt,

View File

@@ -31,6 +31,11 @@ body,
font-family: 'Orbitron', sans-serif;
}
/* 数据库面板:易读字体 */
.font-db {
font-family: 'Noto Sans SC', system-ui, -apple-system, sans-serif;
}
/* Tabular numbers for aligned stat display */
.tabular-nums {
font-variant-numeric: tabular-nums;

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View File

@@ -68,6 +68,7 @@ export function Dashboard() {
usLosses={situation.usForces.combatLosses}
iranLosses={situation.iranForces.combatLosses}
conflictStats={situation.conflictStats}
civilianTotal={situation.civilianCasualtiesTotal}
className="min-w-0 flex-1 py-1"
/>
<EventTimelinePanel updates={situation.recentUpdates} conflictEvents={situation.conflictEvents} className="min-w-0 shrink-0 min-h-[80px] overflow-hidden lg:min-w-[240px]" />

161
src/pages/DbDashboard.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Database, Table, ArrowLeft, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
interface TableData {
[table: string]: Record<string, unknown>[] | { error: string }
}
export function DbDashboard() {
const [data, setData] = useState<TableData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set(['situation_update', 'combat_losses', 'conflict_stats']))
useEffect(() => {
fetchData()
const t = setInterval(fetchData, 30000)
return () => clearInterval(t)
}, [])
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/db/dashboard')
if (!res.ok) throw new Error(res.statusText)
const json = await res.json()
setData(json)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}
const toggle = (name: string) => {
setExpanded((s) => {
const next = new Set(s)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
if (loading && !data) {
return (
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
<RefreshCw className="h-6 w-6 animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-military-dark font-db text-military-text-primary">
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-military-border bg-military-panel/95 px-4 py-3">
<div className="flex items-center gap-4">
<Link
to="/"
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 hover:text-military-text-primary"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="flex items-center gap-2 text-lg">
<Database className="h-5 w-5 text-cyan-400" />
</span>
</div>
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
{error && (
<div className="mx-4 mt-4 rounded border border-amber-600/50 bg-amber-950/30 px-4 py-2 text-amber-400">
{error} API npm run api
</div>
)}
<main className="max-w-6xl space-y-4 p-4">
{data &&
Object.entries(data).map(([name, rows]) => {
const isExpanded = expanded.has(name)
const isError = rows && typeof rows === 'object' && 'error' in rows
const arr = Array.isArray(rows) ? rows : []
return (
<section
key={name}
className="rounded border border-military-border bg-military-panel/80 overflow-hidden"
>
<button
onClick={() => toggle(name)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="flex items-center gap-2 font-medium">
<Table className="h-4 w-4 text-cyan-400" />
{name}
<span className="text-military-text-secondary font-normal">
{isError ? '(错误)' : `(${arr.length} 条)`}
</span>
</span>
<span className="text-military-text-secondary">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="border-t border-military-border overflow-x-auto">
{isError ? (
<pre className="p-4 text-sm text-amber-400">{(rows as { error: string }).error}</pre>
) : arr.length === 0 ? (
<p className="p-4 text-sm text-military-text-secondary"></p>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-military-border bg-military-dark/50">
{Object.keys(arr[0] as object).map((col) => (
<th
key={col}
className="whitespace-nowrap px-3 py-2 font-medium text-cyan-400"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{arr.map((row, i) => (
<tr
key={i}
className="border-b border-military-border/50 hover:bg-military-border/10"
>
{Object.values(row as object).map((v, j) => (
<td
key={j}
className="max-w-xs truncate px-3 py-2 text-military-text-secondary"
title={String(v ?? '')}
>
{v === null || v === undefined
? '—'
: typeof v === 'object'
? JSON.stringify(v)
: String(v)}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</section>
)
})}
</main>
</div>
)
}

View File

@@ -47,20 +47,36 @@ export function fetchAndSetSituation(): Promise<void> {
}
let disconnectWs: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL_MS = 5000
function pollSituation() {
fetchSituation()
.then((situation) => useSituationStore.getState().setSituation(situation))
.catch(() => {})
}
export function startSituationWebSocket(): () => void {
useSituationStore.getState().setConnected(true)
useSituationStore.getState().setLastError(null)
disconnectWs = connectSituationWebSocket((data) => {
useSituationStore.getState().setConnected(true)
useSituationStore.getState().setSituation(data as MilitarySituation)
})
pollSituation()
pollInterval = setInterval(pollSituation, POLL_INTERVAL_MS)
return stopSituationWebSocket
}
export function stopSituationWebSocket(): void {
disconnectWs?.()
disconnectWs = null
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
useSituationStore.getState().setConnected(false)
}