Files
usa/src/components/HeaderPanel.tsx
2026-03-03 20:17:38 +08:00

333 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore'
import { fetchAndSetSituation } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore'
import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare, RefreshCw } from 'lucide-react'
const STORAGE_LIKES = 'us-iran-dashboard-likes'
// 开发环境下每标签一个 viewer-id便于本地验证「在看」开/关标签变化
const getViewerId = (): string | undefined => {
if (typeof import.meta !== 'undefined' && import.meta.env?.DEV && typeof crypto?.randomUUID === 'function') {
try {
let id = sessionStorage.getItem('us-iran-viewer-id')
if (!id) {
id = crypto.randomUUID()
sessionStorage.setItem('us-iran-viewer-id', id)
}
return id
} catch {
return undefined
}
}
return undefined
}
function getStoredLikes(): number {
try {
return parseInt(localStorage.getItem(STORAGE_LIKES) ?? '0', 10)
} catch {
return 0
}
}
export function HeaderPanel() {
const situation = useReplaySituation()
const isConnected = useSituationStore((s) => s.isConnected)
const isReplayMode = usePlaybackStore((s) => s.isReplayMode)
const { usForces, iranForces } = situation
const [now, setNow] = useState(() => new Date())
const [likes, setLikes] = useState(getStoredLikes)
const [liked, setLiked] = useState(false)
const stats = useStatsStore((s) => s.stats)
const setStats = useStatsStore((s) => s.setStats)
const viewers = stats.viewers ?? 0
const cumulative = stats.cumulative ?? 0
const feedbackCount = stats.feedbackCount ?? 0
const shareCount = stats.shareCount ?? 0
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [feedbackText, setFeedbackText] = useState('')
const [feedbackSending, setFeedbackSending] = useState(false)
const [feedbackDone, setFeedbackDone] = useState(false)
const [refreshing, setRefreshing] = useState(false)
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const fetchStats = async () => {
try {
const headers: HeadersInit = {}
const vid = getViewerId()
if (vid) headers['X-Viewer-Id'] = vid
const res = await fetch('/api/visit', { method: 'POST', headers })
const data = await res.json()
setStats({
viewers: data.viewers,
cumulative: data.cumulative,
feedbackCount: data.feedbackCount,
shareCount: data.shareCount,
})
} catch {
setStats({ viewers: 0, cumulative: 0 })
}
}
useEffect(() => {
fetchStats()
const t = setInterval(fetchStats, 15000)
return () => clearInterval(t)
}, [])
const handleShare = async () => {
const url = window.location.href
const title = '美伊军事态势显示'
let shared = false
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title, url })
shared = true
} catch (e) {
if ((e as Error).name !== 'AbortError') {
await copyToClipboard(url)
shared = true
}
}
} else {
await copyToClipboard(url)
shared = true
}
if (shared) {
try {
const res = await fetch('/api/share', { method: 'POST' })
const data = await res.json()
if (data.shareCount != null) setStats({ shareCount: data.shareCount })
} catch {}
}
}
const copyToClipboard = (text: string) => {
return navigator.clipboard?.writeText(text) ?? Promise.resolve()
}
const handleFeedback = async () => {
const text = feedbackText.trim()
if (!text || feedbackSending) return
setFeedbackSending(true)
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
})
const data = await res.json()
if (data.ok) {
setFeedbackText('')
setFeedbackDone(true)
setStats({ feedbackCount: (feedbackCount ?? 0) + 1 })
setTimeout(() => {
setFeedbackOpen(false)
setFeedbackDone(false)
}, 800)
}
} catch {
setFeedbackDone(false)
} finally {
setFeedbackSending(false)
}
}
const handleLike = () => {
if (liked) return
setLiked(true)
const next = likes + 1
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
}
const formatDateTime = (d: Date) =>
d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const formatDataTime = (iso: string) => {
const d = new Date(iso)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour12: false,
hour: '2-digit',
minute: '2-digit',
})
}
return (
<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="min-w-0 shrink truncate text-sm font-bold uppercase tracking-wider text-military-accent sm:text-base sm:tracking-widest lg:text-2xl">
</h1>
<div className="flex w-[12rem] shrink-0 flex-col gap-0.5 font-mono sm:w-[13rem]">
<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="min-w-[10rem] tabular-nums sm:min-w-[11rem]">
{formatDateTime(now)}
</span>
</div>
{(isConnected || isReplayMode) && (
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
</span>
)}
</div>
</div>
<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>
<span className="text-[10px]"> <b className="tabular-nums">{cumulative}</b></span>
</div>
<button
type="button"
onClick={() => setFeedbackOpen(true)}
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-2.5 w-2.5 sm:h-3 sm:w-3" />
{feedbackCount > 0 && <span className="tabular-nums">{feedbackCount}</span>}
</button>
<button
type="button"
onClick={handleShare}
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-2.5 w-2.5 sm:h-3 sm:w-3" />
{shareCount > 0 && <span className="tabular-nums">{shareCount}</span>}
</button>
<button
type="button"
onClick={handleLike}
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-2.5 w-2.5 sm:h-3 sm:w-3 ${liked ? 'fill-current' : ''}`} />
{likes > 0 && <span className="tabular-nums">{likes}</span>}
</button>
<button
type="button"
onClick={async () => {
setRefreshing(true)
await fetchAndSetSituation().finally(() => setRefreshing(false))
}}
disabled={refreshing}
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 disabled:opacity-50 sm:px-2 sm:py-1 sm:text-[10px]"
title="从服务器拉取最新态势数据"
>
<RefreshCw className={`h-3 w-3 sm:h-3.5 sm:w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<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 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}%` }}
/>
<div
className="bg-military-iran transition-all"
style={{ width: `${(iranForces.powerIndex.overall / (usForces.powerIndex.overall + iranForces.powerIndex.overall)) * 100}%` }}
/>
</div>
<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="hidden h-8 w-px shrink-0 bg-military-border sm:block" />
<StatCard
label="美国/盟国"
value={usForces.powerIndex.overall}
variant="us"
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="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="shrink-0 border-military-accent/50 px-1.5 py-1 sm:px-2 sm:py-1.5"
/>
</div>
{/* 留言弹窗 */}
{feedbackOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={() => !feedbackSending && setFeedbackOpen(false)}
>
<div
className="w-[90%] max-w-md rounded-lg border border-military-border bg-military-panel p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-2 text-sm font-medium text-military-accent"></h3>
<p className="mb-2 text-[10px] text-military-text-secondary">
</p>
<textarea
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="请输入留言内容12000 字)..."
rows={4}
maxLength={2000}
className="mb-3 w-full resize-none rounded border border-military-border bg-military-dark px-2 py-2 text-xs text-military-text-primary placeholder:text-military-text-secondary focus:border-cyan-500 focus:outline-none"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => !feedbackSending && setFeedbackOpen(false)}
className="rounded border border-military-border px-3 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30"
>
</button>
<button
type="button"
onClick={handleFeedback}
disabled={!feedbackText.trim() || feedbackSending}
className="rounded bg-cyan-600 px-3 py-1 text-[10px] text-white hover:bg-cyan-500 disabled:opacity-50"
>
{feedbackSending ? '提交中...' : feedbackDone ? '已提交' : '提交'}
</button>
</div>
</div>
</div>
)}
</header>
)
}