fix: 优化后端数据
This commit is contained in:
72
src/components/IranBaseStatusPanel.tsx
Normal file
72
src/components/IranBaseStatusPanel.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from 'react'
|
||||
import { MapPin, AlertTriangle, AlertCircle } from 'lucide-react'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
|
||||
interface IranBaseStatusPanelProps {
|
||||
keyLocations: MilitarySituation['iranForces']['keyLocations']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function IranBaseStatusPanel({ keyLocations = [], className = '' }: IranBaseStatusPanelProps) {
|
||||
const stats = useMemo(() => {
|
||||
const bases = (keyLocations || []).filter((loc) => loc.type === 'Base' || loc.type === 'Port' || loc.type === 'Nuclear' || loc.type === 'Missile')
|
||||
let attacked = 0
|
||||
let severe = 0
|
||||
let moderate = 0
|
||||
let light = 0
|
||||
for (const b of bases) {
|
||||
const s = b.status ?? 'operational'
|
||||
if (s === 'attacked') attacked++
|
||||
const lvl = b.damage_level
|
||||
if (lvl === 3) severe++
|
||||
else if (lvl === 2) moderate++
|
||||
else if (lvl === 1) light++
|
||||
}
|
||||
return { total: bases.length, attacked, severe, moderate, light }
|
||||
}, [keyLocations])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-military-border bg-military-panel/80 p-3 font-orbitron ${className}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
||||
<MapPin className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
伊朗基地态势
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-xs tabular-nums">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-military-text-secondary">总基地数</span>
|
||||
<strong>{stats.total}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||
<AlertCircle className="h-3 w-3 text-red-400" />
|
||||
被袭击
|
||||
</span>
|
||||
<strong className="text-red-400">{stats.attacked}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
严重损毁
|
||||
</span>
|
||||
<strong className="text-amber-500">{stats.severe}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-400" />
|
||||
中度损毁
|
||||
</span>
|
||||
<strong className="text-amber-400">{stats.moderate}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-military-text-secondary">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-300" />
|
||||
轻度损毁
|
||||
</span>
|
||||
<strong className="text-amber-300">{stats.light}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/NewsTicker.tsx
Normal file
56
src/components/NewsTicker.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||
import { processTickerText } from '@/utils/tickerText'
|
||||
|
||||
interface NewsTickerProps {
|
||||
updates?: SituationUpdate[]
|
||||
conflictEvents?: ConflictEvent[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NewsTicker({ updates = [], conflictEvents = [], className = '' }: NewsTickerProps) {
|
||||
const items = useMemo(() => {
|
||||
const list: { id: string; text: string }[] = []
|
||||
for (const e of conflictEvents || []) {
|
||||
const text = processTickerText(e.title || '')
|
||||
if (text) list.push({ id: `ev-${e.event_id}`, text })
|
||||
}
|
||||
for (const u of updates || []) {
|
||||
const text = processTickerText(u.summary || '')
|
||||
if (text) list.push({ id: `up-${u.id}`, text })
|
||||
}
|
||||
return list.slice(0, 30)
|
||||
}, [updates, conflictEvents])
|
||||
|
||||
const baseCls = 'flex items-center overflow-hidden'
|
||||
const defaultCls = 'border-b border-military-border/50 bg-military-panel/60 py-1.5'
|
||||
const wrapperCls = className ? `${baseCls} ${className}` : `${baseCls} ${defaultCls}`
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={wrapperCls}>
|
||||
<span className="shrink-0 pr-2 text-[10px] uppercase text-military-text-secondary">滚动情报</span>
|
||||
<span className="text-[11px] text-military-text-secondary">暂无资讯</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const content = items.map((i) => i.text).join(' ◆ ')
|
||||
const duration = Math.max(180, Math.min(480, content.length * 0.8))
|
||||
|
||||
return (
|
||||
<div className={wrapperCls}>
|
||||
<span className="shrink-0 pr-2 text-[10px] font-medium uppercase tracking-wider text-cyan-400">滚动情报</span>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
className="inline-flex whitespace-nowrap text-[11px] text-military-text-secondary"
|
||||
style={{ animation: `ticker ${duration}s linear infinite` }}
|
||||
>
|
||||
<span className="px-2">{content}</span>
|
||||
<span className="px-2 text-cyan-400/60">◆</span>
|
||||
<span className="px-2">{content}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { NewsTicker } from './NewsTicker'
|
||||
|
||||
function formatTick(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
@@ -14,6 +16,7 @@ function formatTick(iso: string): string {
|
||||
}
|
||||
|
||||
export function TimelinePanel() {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
const {
|
||||
isReplayMode,
|
||||
playbackTime,
|
||||
@@ -75,6 +78,16 @@ export function TimelinePanel() {
|
||||
数据回放
|
||||
</button>
|
||||
|
||||
{!isReplayMode && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<NewsTicker
|
||||
updates={situation.recentUpdates}
|
||||
conflictEvents={situation.conflictEvents}
|
||||
className="!border-0 !bg-transparent !py-0 !px-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReplayMode && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -53,3 +53,9 @@ body,
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes ticker {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TimelinePanel } from '@/components/TimelinePanel'
|
||||
import { ForcePanel } from '@/components/ForcePanel'
|
||||
import { WarMap } from '@/components/WarMap'
|
||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||
import { EventTimelinePanel } from '@/components/EventTimelinePanel'
|
||||
import { IranBaseStatusPanel } from '@/components/IranBaseStatusPanel'
|
||||
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
||||
import { PowerChart } from '@/components/PowerChart'
|
||||
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
||||
@@ -71,7 +71,10 @@ export function Dashboard() {
|
||||
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]" />
|
||||
<IranBaseStatusPanel
|
||||
keyLocations={situation.iranForces.keyLocations}
|
||||
className="min-w-0 shrink-0 lg:min-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function fetchAndSetSituation(): Promise<void> {
|
||||
let disconnectWs: (() => void) | null = null
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
function pollSituation() {
|
||||
fetchSituation()
|
||||
|
||||
35
src/utils/tickerText.ts
Normal file
35
src/utils/tickerText.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 滚动情报文本处理:转为简体中文,过滤非中文内容
|
||||
*/
|
||||
import { Converter } from 'opencc-js/t2cn'
|
||||
|
||||
const t2s = Converter({ from: 'twp', to: 'cn' })
|
||||
|
||||
/** 简体中文字符范围 */
|
||||
const ZH_REGEX = /[\u4e00-\u9fff]/g
|
||||
|
||||
/** 文本中中文占比是否达标(至少30%) */
|
||||
export function isMostlyChinese(text: string): boolean {
|
||||
if (!text?.trim()) return false
|
||||
const zh = text.match(ZH_REGEX)
|
||||
const zhCount = zh ? zh.length : 0
|
||||
return zhCount / text.length >= 0.3
|
||||
}
|
||||
|
||||
/** 繁体转简体 */
|
||||
export function toSimplifiedChinese(text: string): string {
|
||||
if (!text?.trim()) return text
|
||||
try {
|
||||
return t2s(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理滚动情报项:转为简体,非中文为主则过滤 */
|
||||
export function processTickerText(text: string): string | null {
|
||||
const t = (text || '').trim()
|
||||
if (!t) return null
|
||||
if (!isMostlyChinese(t)) return null
|
||||
return toSimplifiedChinese(t)
|
||||
}
|
||||
Reference in New Issue
Block a user