fix:优化数据来源

This commit is contained in:
Daniel
2026-03-02 01:00:04 +08:00
parent 91d9e48e1e
commit 4a8fff5a00
26 changed files with 1361 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
import { History } from 'lucide-react'
interface EventTimelinePanelProps {
updates: SituationUpdate[]
conflictEvents?: ConflictEvent[]
className?: string
}
const CAT_LABELS: Record<string, string> = {
deployment: '部署',
alert: '警报',
intel: '情报',
diplomatic: '外交',
other: '其他',
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
}
type TimelineItem = {
id: string
summary: string
timestamp: string
source: 'gdelt' | 'rss'
category?: string
severity?: string
}
export function EventTimelinePanel({ updates = [], conflictEvents = [], className = '' }: EventTimelinePanelProps) {
// 合并 GDELT + RSS按时间倒序最新在前
const merged: TimelineItem[] = [
...(conflictEvents || []).map((e) => ({
id: e.event_id,
summary: e.title,
timestamp: e.event_time,
source: 'gdelt' as const,
category: 'alert',
severity: e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low',
})),
...(updates || []).map((u) => ({
id: u.id,
summary: u.summary,
timestamp: u.timestamp,
source: 'rss' as const,
category: u.category,
severity: u.severity,
})),
]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 6)
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}`}>
<div className="flex shrink-0 items-center justify-between border-b border-military-border px-3 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
<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>
</div>
<div className="min-h-0 max-h-[140px] flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
{merged.length === 0 ? (
<p className="py-4 text-center text-[11px] text-military-text-secondary"></p>
) : (
<ul className="space-y-2">
{merged.map((item) => (
<li key={item.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
<span className="shrink-0 pt-0.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${item.source === 'gdelt' ? 'bg-cyan-500' : 'bg-amber-500'}`} />
</span>
<div className="min-w-0 flex-1 text-right">
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{item.summary}</p>
<span className="mt-0.5 flex items-center justify-end gap-1 text-[9px] text-military-text-secondary">
{formatTime(item.timestamp)}
<span className="text-military-text-secondary/60">
{item.source === 'gdelt' ? 'GDELT' : CAT_LABELS[item.category ?? ''] ?? '新闻'}
</span>
</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
import { Newspaper, AlertTriangle } from 'lucide-react'
interface RecentUpdatesPanelProps {
updates: SituationUpdate[]
conflictEvents?: ConflictEvent[]
className?: string
}
const CAT_LABELS: Record<string, string> = {
deployment: '部署',
alert: '警报',
intel: '情报',
diplomatic: '外交',
other: '其他',
}
const SEV_COLORS: Record<string, string> = {
low: 'text-military-text-secondary',
medium: 'text-amber-400',
high: 'text-orange-500',
critical: 'text-red-500',
}
function formatTime(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export function RecentUpdatesPanel({ updates, conflictEvents = [], className = '' }: RecentUpdatesPanelProps) {
// 优先展示 GDELT 冲突事件(最新 10 条),无则用 updates
const fromConflict = (conflictEvents || [])
.slice(0, 10)
.map((e) => ({ id: e.event_id, summary: e.title, timestamp: e.event_time, category: 'alert' as const, severity: (e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low') as const }))
const list = fromConflict.length > 0 ? fromConflict : (updates || []).slice(0, 8)
return (
<div className={`flex min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
<div className="flex shrink-0 items-center gap-1.5 border-b border-military-border px-3 py-1.5">
<Newspaper className="h-3.5 w-3.5 shrink-0 text-military-accent" />
<span className="truncate text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
{list.length === 0 ? (
<p className="py-4 text-center text-[11px] text-military-text-secondary"></p>
) : (
<ul className="space-y-2">
{list.map((u) => (
<li key={u.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
<span className={`shrink-0 text-[9px] ${SEV_COLORS[u.severity] ?? 'text-military-text-secondary'}`}>
{u.severity === 'critical' && <AlertTriangle className="inline h-2.5 w-2.5" />}
{CAT_LABELS[u.category] ?? u.category}
</span>
<div className="min-w-0 flex-1 text-right">
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{u.summary}</p>
<span className="mt-0.5 block text-[9px] text-military-text-secondary">{formatTime(u.timestamp)}</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef } from 'react'
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
function formatTick(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
export function TimelinePanel() {
const {
isReplayMode,
playbackTime,
isPlaying,
speedSecPerTick,
setReplayMode,
setPlaybackTime,
setIsPlaying,
stepForward,
stepBack,
setSpeed,
} = usePlaybackStore()
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!isPlaying || !isReplayMode) {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
return
}
timerRef.current = setInterval(() => {
const current = usePlaybackStore.getState().playbackTime
const i = REPLAY_TICKS.indexOf(current)
if (i >= REPLAY_TICKS.length - 1) {
setIsPlaying(false)
return
}
setPlaybackTime(REPLAY_TICKS[i + 1])
}, speedSecPerTick * 1000)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
const index = REPLAY_TICKS.indexOf(playbackTime)
const value = index >= 0 ? index : REPLAY_TICKS.length - 1
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const i = parseInt(e.target.value, 10)
setPlaybackTime(REPLAY_TICKS[i])
}
return (
<div className="shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setReplayMode(!isReplayMode)}
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors ${
isReplayMode
? 'bg-military-accent/30 text-military-accent'
: 'bg-military-border/50 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary'
}`}
>
<History className="h-3.5 w-3.5" />
</button>
{isReplayMode && (
<>
<div className="flex items-center gap-1">
<button
type="button"
onClick={stepBack}
disabled={index <= 0}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
title="上一步"
>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setIsPlaying(!isPlaying)}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary"
title={isPlaying ? '暂停' : '播放'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={stepForward}
disabled={index >= REPLAY_TICKS.length - 1}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
title="下一步"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
<input
type="range"
min={0}
max={REPLAY_TICKS.length - 1}
value={value}
onChange={handleSliderChange}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
/>
</div>
<div className="flex items-center gap-2 text-[11px] tabular-nums text-military-text-secondary">
<span>{formatTick(REPLAY_START)}</span>
<span className="font-medium text-military-accent">{formatTick(playbackTime)}</span>
<span>{formatTick(REPLAY_END)}</span>
</div>
<select
value={speedSecPerTick}
onChange={(e) => setSpeed(Number(e.target.value))}
className="rounded border border-military-border bg-military-dark/80 px-2 py-1 text-[11px] text-military-text-secondary focus:border-military-accent focus:outline-none"
>
<option value={0.5}>0.5 /</option>
<option value={1}>1 /</option>
<option value={2}>2 /</option>
<option value={3}>3 /</option>
<option value={5}>5 /</option>
</select>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useMemo } from 'react'
import type { MilitarySituation } from '@/data/mockData'
import { useSituationStore } from '@/store/situationStore'
import { usePlaybackStore } from '@/store/playbackStore'
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
function toReplayDay(iso: string, baseDay: string): string {
const d = new Date(iso)
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number)
return new Date(y, (m || 1) - 1, day || 1, d.getUTCHours(), d.getUTCMinutes(), 0, 0).toISOString()
}
function interpolateAt(
series: { time: string; value: number }[],
at: string,
baseDay = '2026-03-01'
): number {
if (series.length === 0) return 0
const t = new Date(at).getTime()
const mapped = series.map((p) => ({
time: toReplayDay(p.time, baseDay),
value: p.value,
}))
const sorted = [...mapped].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
const before = sorted.filter((p) => new Date(p.time).getTime() <= t)
const after = sorted.filter((p) => new Date(p.time).getTime() > t)
if (before.length === 0) return sorted[0].value
if (after.length === 0) return sorted[sorted.length - 1].value
const a = before[before.length - 1]
const b = after[0]
const ta = new Date(a.time).getTime()
const tb = new Date(b.time).getTime()
const f = tb === ta ? 1 : (t - ta) / (tb - ta)
return a.value + f * (b.value - a.value)
}
function linearProgress(start: string, end: string, at: string): number {
const ts = new Date(start).getTime()
const te = new Date(end).getTime()
const ta = new Date(at).getTime()
if (ta <= ts) return 0
if (ta >= te) return 1
return (ta - ts) / (te - ts)
}
/** 根据回放时刻派生态势数据 */
export function useReplaySituation(): MilitarySituation {
const situation = useSituationStore((s) => s.situation)
const { isReplayMode, playbackTime } = usePlaybackStore()
return useMemo(() => {
if (!isReplayMode) return situation
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
// 华尔街趋势、反击情绪:按时间插值
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
// 战斗损失:从 0 线性增长到当前值
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 usLossesAt = {
bases: {
destroyed: lerp(0, usLoss.bases.destroyed),
damaged: lerp(0, usLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, usLoss.personnelCasualties.killed),
wounded: lerp(0, usLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: lerp(0, civUs.killed), wounded: lerp(0, civUs.wounded) },
aircraft: lerp(0, usLoss.aircraft),
warships: lerp(0, usLoss.warships),
armor: lerp(0, usLoss.armor),
vehicles: lerp(0, usLoss.vehicles),
}
const irLossesAt = {
bases: {
destroyed: lerp(0, irLoss.bases.destroyed),
damaged: lerp(0, irLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, irLoss.personnelCasualties.killed),
wounded: lerp(0, irLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: lerp(0, civIr.killed), wounded: lerp(0, civIr.wounded) },
aircraft: lerp(0, irLoss.aircraft),
warships: lerp(0, irLoss.warships),
armor: lerp(0, irLoss.armor),
vehicles: lerp(0, irLoss.vehicles),
}
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
const usLocs = situation.usForces.keyLocations || []
const attackedBases = usLocs
.filter((loc) => loc.status === 'attacked')
.sort((a, b) => (b.damage_level ?? 0) - (a.damage_level ?? 0))
const totalAttacked = attackedBases.length
const shownAttackedCount = Math.round(progress * totalAttacked)
const attackedNames = new Set(
attackedBases.slice(0, shownAttackedCount).map((l) => l.name)
)
const usLocsAt = usLocs.map((loc) => {
if (loc.status === 'attacked' && !attackedNames.has(loc.name)) {
return { ...loc, status: 'operational' as const }
}
return { ...loc }
})
return {
...situation,
lastUpdated: playbackTime,
usForces: {
...situation.usForces,
keyLocations: usLocsAt,
combatLosses: usLossesAt,
wallStreetInvestmentTrend: [
...situation.usForces.wallStreetInvestmentTrend.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
{ time: playbackTime, value: wsValue },
].slice(-20),
},
iranForces: {
...situation.iranForces,
combatLosses: irLossesAt,
retaliationSentiment: retValue,
retaliationSentimentHistory: [
...situation.iranForces.retaliationSentimentHistory.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
{ time: playbackTime, value: retValue },
].slice(-20),
},
recentUpdates: (situation.recentUpdates || []).filter(
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
),
conflictEvents: situation.conflictEvents || [],
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
}
}, [situation, isReplayMode, playbackTime])
}

View File

@@ -0,0 +1,80 @@
import { create } from 'zustand'
const REPLAY_DAY = '2026-03-01'
const TICK_MS = 30 * 60 * 1000 // 30 minutes
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
function parseTime(iso: string): number {
return new Date(iso).getTime()
}
export function getTicks(): string[] {
const ticks: string[] = []
let t = parseTime(REPLAY_START)
const end = parseTime(REPLAY_END)
while (t <= end) {
ticks.push(new Date(t).toISOString())
t += TICK_MS
}
return ticks
}
export const REPLAY_TICKS = getTicks()
export interface PlaybackState {
/** 是否开启回放模式 */
isReplayMode: boolean
/** 当前回放时刻 (ISO) */
playbackTime: string
/** 是否正在自动播放 */
isPlaying: boolean
/** 播放速度 (秒/刻度) */
speedSecPerTick: number
setReplayMode: (v: boolean) => void
setPlaybackTime: (iso: string) => void
setIsPlaying: (v: boolean) => void
stepForward: () => void
stepBack: () => void
setSpeed: (sec: number) => void
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
isReplayMode: false,
playbackTime: REPLAY_END,
isPlaying: false,
speedSecPerTick: 2,
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
setPlaybackTime: (iso) => {
const ticks = REPLAY_TICKS
if (ticks.includes(iso)) {
set({ playbackTime: iso })
return
}
const idx = ticks.findIndex((t) => t >= iso)
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
set({ playbackTime: ticks[clamp] })
},
setIsPlaying: (v) => set({ isPlaying: v }),
stepForward: () => {
const { playbackTime } = get()
const ticks = REPLAY_TICKS
const i = ticks.indexOf(playbackTime)
if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] })
else set({ isPlaying: false })
},
stepBack: () => {
const { playbackTime } = get()
const ticks = REPLAY_TICKS
const i = ticks.indexOf(playbackTime)
if (i > 0) set({ playbackTime: ticks[i - 1] })
},
setSpeed: (sec) => set({ speedSecPerTick: sec }),
}))