diff --git a/server/db.js b/server/db.js index 0991791..7905d98 100644 --- a/server/db.js +++ b/server/db.js @@ -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() { diff --git a/server/routes.js b/server/routes.js index 25dc5f0..c393165 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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 diff --git a/server/stats.js b/server/stats.js index d943e6f..ad84aa1 100644 --- a/server/stats.js +++ b/server/stats.js @@ -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 } diff --git a/src/api/edit.ts b/src/api/edit.ts index e4e2500..abad7f4 100644 --- a/src/api/edit.ts +++ b/src/api/edit.ts @@ -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 { @@ -129,3 +138,16 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial): Promise { + 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) + } +} diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index b25b138..47c1605 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -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() { }`} > - 点赞 {likes > 0 && {likes}} - - {isConnected ? : } diff --git a/src/pages/EditDashboard.tsx b/src/pages/EditDashboard.tsx index 75b44f7..98a6aab 100644 --- a/src/pages/EditDashboard.tsx +++ b/src/pages/EditDashboard.tsx @@ -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(null) const [saving, setSaving] = useState(null) - const [openSections, setOpenSections] = useState>(new Set(['losses', 'updates'])) + const [openSections, setOpenSections] = useState>(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 (
@@ -207,6 +251,30 @@ export function EditDashboard() { )}
+ {/* 看过、在看、分享、点赞、留言 */} +
+ + {openSections.has('displayStats') && data && ( +
+

+ 统计方式:在看 = 近 2 分钟内不同设备数(按设备 ID 去重);看过 = 累计访问次数。手动填写会覆盖实时值;点「恢复实时统计」可清除覆盖。 +

+ +
+ )} +
+ {/* 战损 */}
) } + +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({ ...row }) + useEffect(() => { + setEdit({ ...row }) + }, [row]) + return ( +
+
+ {DISPLAY_STATS_FIELDS.map(({ key, label }) => ( + + ))} +
+
+ + +
+
+ ) +} diff --git a/src/store/statsStore.ts b/src/store/statsStore.ts index 4290524..e56ad8d 100644 --- a/src/store/statsStore.ts +++ b/src/store/statsStore.ts @@ -5,6 +5,7 @@ export interface Stats { cumulative?: number feedbackCount?: number shareCount?: number + likeCount?: number } interface StatsState {