fix: 优化数据
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
161
src/pages/DbDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user