This commit is contained in:
Daniel
2026-03-04 00:07:14 +08:00
parent ac24c528f3
commit 95e2fe1c41
7 changed files with 271 additions and 45 deletions

View File

@@ -54,11 +54,20 @@ export interface ForceSummaryRow {
missile_stock: number
}
export interface DisplayStatsRow {
viewers: number
cumulative: number
shareCount: number
likeCount: number
feedbackCount: number
}
export interface EditRawData {
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
situationUpdates: SituationUpdateRow[]
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
displayStats?: DisplayStatsRow
}
export async function fetchEditRaw(): Promise<EditRawData> {
@@ -129,3 +138,16 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSu
throw new Error((e as { error?: string }).error || res.statusText)
}
}
/** 传 null 的字段会清除覆盖,改回实时统计 */
export async function putDisplayStats(body: Partial<{ [K in keyof DisplayStatsRow]: number | null }>): Promise<void> {
const res = await fetch('/api/edit/display-stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}

View File

@@ -1,28 +1,26 @@
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'
import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare } from 'lucide-react'
const STORAGE_LIKES = 'us-iran-dashboard-likes'
// 开发环境下每标签一个 viewer-id便于本地验证「在看」开/关标签变化
const STORAGE_VIEWER_ID = 'us-iran-viewer-id'
// 用 sessionStorage每个标签/窗口一个 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
if (typeof crypto?.randomUUID !== 'function') return undefined
try {
let id = sessionStorage.getItem(STORAGE_VIEWER_ID)
if (!id) {
id = crypto.randomUUID()
sessionStorage.setItem(STORAGE_VIEWER_ID, id)
}
return id
} catch {
return undefined
}
return undefined
}
function getStoredLikes(): number {
@@ -47,11 +45,11 @@ export function HeaderPanel() {
const cumulative = stats.cumulative ?? 0
const feedbackCount = stats.feedbackCount ?? 0
const shareCount = stats.shareCount ?? 0
const serverLikeCount = stats.likeCount
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)
@@ -70,6 +68,7 @@ export function HeaderPanel() {
cumulative: data.cumulative,
feedbackCount: data.feedbackCount,
shareCount: data.shareCount,
likeCount: data.likeCount,
})
} catch {
setStats({ viewers: 0, cumulative: 0 })
@@ -226,20 +225,7 @@ export function HeaderPanel() {
}`}
>
<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' : ''}`} />
{(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? likes}</span>}
</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" />}

View File

@@ -8,13 +8,15 @@ import {
postSituationUpdate,
deleteSituationUpdate,
putForceSummary,
putDisplayStats,
type EditRawData,
type CombatLossesRow,
type KeyLocationRow,
type SituationUpdateRow,
type ForceSummaryRow,
type DisplayStatsRow,
} from '@/api/edit'
import { fetchAndSetSituation } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore'
const LOSS_FIELDS: { key: keyof CombatLossesRow; label: string }[] = [
{ key: 'bases_destroyed', label: '基地摧毁' },
@@ -62,8 +64,9 @@ export function EditDashboard() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['losses', 'updates']))
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['displayStats', 'losses', 'updates']))
const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
const setStats = useStatsStore((s) => s.setStats)
const load = useCallback(async () => {
setLoading(true)
@@ -166,6 +169,47 @@ export function EditDashboard() {
}
}
const handleSaveDisplayStats = async (row: DisplayStatsRow) => {
setSaving('displayStats')
try {
await putDisplayStats(row)
await load()
const res = await fetch('/api/stats', { cache: 'no-store' })
if (res.ok) {
const stats = await res.json()
setStats(stats)
}
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleClearDisplayStatsOverrides = async () => {
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数看过=累计访问等)。')) return
setSaving('displayStats')
try {
await putDisplayStats({
viewers: null,
cumulative: null,
shareCount: null,
likeCount: null,
feedbackCount: null,
})
await load()
const res = await fetch('/api/stats', { cache: 'no-store' })
if (res.ok) {
const stats = await res.json()
setStats(stats)
}
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
if (loading && !data) {
return (
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
@@ -207,6 +251,30 @@ export function EditDashboard() {
)}
<main className="max-w-4xl space-y-2 p-4">
{/* 看过、在看、分享、点赞、留言 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('displayStats')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / / / / </span>
{openSections.has('displayStats') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('displayStats') && data && (
<div className="border-t border-military-border p-4 space-y-3">
<p className="text-military-text-secondary text-xs">
= 2 ID = 访
</p>
<DisplayStatsForm
row={data.displayStats ?? { viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
onSave={handleSaveDisplayStats}
onClearOverrides={handleClearDisplayStatsOverrides}
saving={saving === 'displayStats'}
/>
</div>
)}
</section>
{/* 战损 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
@@ -476,9 +544,9 @@ function KeyLocationRowEdit({
onChange={(e) => setStatus(e.target.value)}
className="rounded border border-military-border bg-military-panel px-2 py-1 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
{STATUS_OPTIONS.map((s) => {
return <option key={s} value={s}>{s}</option>
})}
</select>
<label className="flex items-center gap-1 text-sm">
<span className="text-military-text-secondary"></span>
@@ -550,3 +618,65 @@ function ForceSummaryForm({
</div>
)
}
const DISPLAY_STATS_FIELDS: { key: keyof DisplayStatsRow; label: string }[] = [
{ key: 'cumulative', label: '看过' },
{ key: 'viewers', label: '在看' },
{ key: 'shareCount', label: '分享' },
{ key: 'likeCount', label: '点赞' },
{ key: 'feedbackCount', label: '留言' },
]
function DisplayStatsForm({
row,
onSave,
onClearOverrides,
saving,
}: {
row: DisplayStatsRow
onSave: (row: DisplayStatsRow) => void
onClearOverrides: () => void
saving: boolean
}) {
const [edit, setEdit] = useState<DisplayStatsRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
return (
<div className="rounded border border-military-border/50 bg-military-dark/50 p-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3 md:grid-cols-5">
{DISPLAY_STATS_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-14 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key]}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClearOverrides}
disabled={saving}
className="rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
</button>
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ export interface Stats {
cumulative?: number
feedbackCount?: number
shareCount?: number
likeCount?: number
}
interface StatsState {