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

@@ -236,6 +236,19 @@ function runMigrations(db) {
INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0);
`)
} catch (_) {}
try {
exec(`
CREATE TABLE IF NOT EXISTS display_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
viewers INTEGER NULL,
cumulative INTEGER NULL,
share_count INTEGER NULL,
like_count INTEGER NULL,
feedback_count INTEGER NULL
);
INSERT OR IGNORE INTO display_stats (id) VALUES (1);
`)
} catch (_) {}
}
async function initDb() {

View File

@@ -105,12 +105,12 @@ function getClientIp(req) {
return req.ip || req.socket?.remoteAddress || 'unknown'
}
// 优先用前端传来的 viewer-id 去重(每设备一个),否则用 IP这样多设备同 WiFi 也能正确统计「在看」
function getVisitKey(req) {
const vid = req.headers['x-viewer-id']
const ip = getClientIp(req)
const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
if (typeof vid === 'string' && vid.trim().length > 0 && (process.env.NODE_ENV === 'development' || isLocal)) {
return 'vid:' + vid.trim()
if (typeof vid === 'string' && vid.trim().length > 0) {
return 'vid:' + vid.trim().slice(0, 64)
}
return ip
}
@@ -129,7 +129,7 @@ router.post('/visit', (req, res) => {
res.json(getStats())
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0 })
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 0 })
}
})
@@ -168,7 +168,7 @@ router.get('/stats', (req, res) => {
res.json(getStats())
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0 })
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 0 })
}
})
@@ -205,11 +205,28 @@ router.get('/edit/raw', (req, res) => {
const updates = db.prepare('SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 80').all()
const summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
let displayStats = null
try {
displayStats = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
} catch (_) {}
const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
const liveViewers = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get()?.n ?? 0
const realFeedback = db.prepare('SELECT COUNT(*) as n FROM feedback').get()?.n ?? 0
res.json({
combatLosses: { us: lossesUs || null, iran: lossesIr || null },
keyLocations: { us: locUs || [], iran: locIr || [] },
situationUpdates: updates || [],
forceSummary: { us: summaryUs || null, iran: summaryIr || null },
displayStats: {
viewers: displayStats?.viewers ?? liveViewers,
cumulative: displayStats?.cumulative ?? realCumulative,
shareCount: displayStats?.share_count ?? realShare,
likeCount: displayStats?.like_count ?? 0,
feedbackCount: displayStats?.feedback_count ?? realFeedback,
},
})
} catch (err) {
console.error(err)
@@ -343,4 +360,39 @@ router.put('/edit/force-summary', (req, res) => {
}
})
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除覆盖、改用实时统计 */
router.put('/edit/display-stats', (req, res) => {
try {
db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run()
const updates = []
const values = []
const setField = (key, bodyKey) => {
const v = req.body?.[bodyKey ?? key]
if (v === undefined) return
if (v === null) {
updates.push(`${key} = ?`)
values.push(null)
return
}
const n = Math.max(0, parseInt(v, 10))
if (!Number.isFinite(n)) throw new Error(`${bodyKey ?? key} must be number`)
updates.push(`${key} = ?`)
values.push(n)
}
setField('viewers')
setField('cumulative')
setField('share_count', 'shareCount')
setField('like_count', 'likeCount')
setField('feedback_count', 'feedbackCount')
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
values.push(1)
db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values)
broadcastAfterEdit(req)
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(400).json({ error: err.message })
}
})
module.exports = router

View File

@@ -1,13 +1,35 @@
const db = require('./db')
function toNum(v) {
if (v == null || v === '') return 0
const n = Number(v)
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0
}
function getStats() {
const viewers = db.prepare(
const viewersRow = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get().n
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
const feedbackCount = db.prepare('SELECT COUNT(*) as n FROM feedback').get().n ?? 0
const shareCount = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
return { viewers, cumulative, feedbackCount, shareCount }
).get()
const cumulativeRow = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()
const feedbackRow = db.prepare('SELECT COUNT(*) as n FROM feedback').get()
const shareRow = db.prepare('SELECT total FROM share_count WHERE id = 1').get()
let viewers = toNum(viewersRow?.n)
let cumulative = toNum(cumulativeRow?.total)
let feedbackCount = toNum(feedbackRow?.n)
let shareCount = toNum(shareRow?.total)
let likeCount = 0
let display = null
try {
display = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
} catch (_) {}
if (display) {
if (display.viewers != null) viewers = toNum(display.viewers)
if (display.cumulative != null) cumulative = toNum(display.cumulative)
if (display.share_count != null) shareCount = toNum(display.share_count)
if (display.like_count != null) likeCount = toNum(display.like_count)
if (display.feedback_count != null) feedbackCount = toNum(display.feedback_count)
}
return { viewers, cumulative, feedbackCount, shareCount, likeCount }
}
module.exports = { getStats }

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 {