333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
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="请输入留言内容(1–2000 字)..."
|
||
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>
|
||
)
|
||
}
|