fix:
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Stats {
|
||||
cumulative?: number
|
||||
feedbackCount?: number
|
||||
shareCount?: number
|
||||
likeCount?: number
|
||||
}
|
||||
|
||||
interface StatsState {
|
||||
|
||||
Reference in New Issue
Block a user