fix:
This commit is contained in:
13
server/db.js
13
server/db.js
@@ -236,6 +236,19 @@ function runMigrations(db) {
|
|||||||
INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0);
|
INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0);
|
||||||
`)
|
`)
|
||||||
} catch (_) {}
|
} 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() {
|
async function initDb() {
|
||||||
|
|||||||
@@ -105,12 +105,12 @@ function getClientIp(req) {
|
|||||||
return req.ip || req.socket?.remoteAddress || 'unknown'
|
return req.ip || req.socket?.remoteAddress || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先用前端传来的 viewer-id 去重(每设备一个),否则用 IP,这样多设备同 WiFi 也能正确统计「在看」
|
||||||
function getVisitKey(req) {
|
function getVisitKey(req) {
|
||||||
const vid = req.headers['x-viewer-id']
|
const vid = req.headers['x-viewer-id']
|
||||||
const ip = getClientIp(req)
|
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) {
|
||||||
if (typeof vid === 'string' && vid.trim().length > 0 && (process.env.NODE_ENV === 'development' || isLocal)) {
|
return 'vid:' + vid.trim().slice(0, 64)
|
||||||
return 'vid:' + vid.trim()
|
|
||||||
}
|
}
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ router.post('/visit', (req, res) => {
|
|||||||
res.json(getStats())
|
res.json(getStats())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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())
|
res.json(getStats())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 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 summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
|
||||||
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
|
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({
|
res.json({
|
||||||
combatLosses: { us: lossesUs || null, iran: lossesIr || null },
|
combatLosses: { us: lossesUs || null, iran: lossesIr || null },
|
||||||
keyLocations: { us: locUs || [], iran: locIr || [] },
|
keyLocations: { us: locUs || [], iran: locIr || [] },
|
||||||
situationUpdates: updates || [],
|
situationUpdates: updates || [],
|
||||||
forceSummary: { us: summaryUs || null, iran: summaryIr || null },
|
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) {
|
} catch (err) {
|
||||||
console.error(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
|
module.exports = router
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
const db = require('./db')
|
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() {
|
function getStats() {
|
||||||
const viewers = db.prepare(
|
const viewersRow = db.prepare(
|
||||||
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
|
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
|
||||||
).get().n
|
).get()
|
||||||
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
|
const cumulativeRow = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()
|
||||||
const feedbackCount = db.prepare('SELECT COUNT(*) as n FROM feedback').get().n ?? 0
|
const feedbackRow = db.prepare('SELECT COUNT(*) as n FROM feedback').get()
|
||||||
const shareCount = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
|
const shareRow = db.prepare('SELECT total FROM share_count WHERE id = 1').get()
|
||||||
return { viewers, cumulative, feedbackCount, shareCount }
|
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 }
|
module.exports = { getStats }
|
||||||
|
|||||||
@@ -54,11 +54,20 @@ export interface ForceSummaryRow {
|
|||||||
missile_stock: number
|
missile_stock: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DisplayStatsRow {
|
||||||
|
viewers: number
|
||||||
|
cumulative: number
|
||||||
|
shareCount: number
|
||||||
|
likeCount: number
|
||||||
|
feedbackCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EditRawData {
|
export interface EditRawData {
|
||||||
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
|
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
|
||||||
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
|
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
|
||||||
situationUpdates: SituationUpdateRow[]
|
situationUpdates: SituationUpdateRow[]
|
||||||
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
|
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
|
||||||
|
displayStats?: DisplayStatsRow
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEditRaw(): Promise<EditRawData> {
|
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)
|
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,29 +1,27 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { StatCard } from './StatCard'
|
import { StatCard } from './StatCard'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
import { fetchAndSetSituation } from '@/store/situationStore'
|
|
||||||
import { useStatsStore } from '@/store/statsStore'
|
import { useStatsStore } from '@/store/statsStore'
|
||||||
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import { usePlaybackStore } from '@/store/playbackStore'
|
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'
|
const STORAGE_LIKES = 'us-iran-dashboard-likes'
|
||||||
// 开发环境下每标签一个 viewer-id,便于本地验证「在看」开/关标签变化
|
const STORAGE_VIEWER_ID = 'us-iran-viewer-id'
|
||||||
|
// 用 sessionStorage:每个标签/窗口一个 viewer-id,无痕多窗口也会各自独立,便于「在看」按窗口去重
|
||||||
const getViewerId = (): string | undefined => {
|
const getViewerId = (): string | undefined => {
|
||||||
if (typeof import.meta !== 'undefined' && import.meta.env?.DEV && typeof crypto?.randomUUID === 'function') {
|
if (typeof crypto?.randomUUID !== 'function') return undefined
|
||||||
try {
|
try {
|
||||||
let id = sessionStorage.getItem('us-iran-viewer-id')
|
let id = sessionStorage.getItem(STORAGE_VIEWER_ID)
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = crypto.randomUUID()
|
id = crypto.randomUUID()
|
||||||
sessionStorage.setItem('us-iran-viewer-id', id)
|
sessionStorage.setItem(STORAGE_VIEWER_ID, id)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoredLikes(): number {
|
function getStoredLikes(): number {
|
||||||
try {
|
try {
|
||||||
@@ -47,11 +45,11 @@ export function HeaderPanel() {
|
|||||||
const cumulative = stats.cumulative ?? 0
|
const cumulative = stats.cumulative ?? 0
|
||||||
const feedbackCount = stats.feedbackCount ?? 0
|
const feedbackCount = stats.feedbackCount ?? 0
|
||||||
const shareCount = stats.shareCount ?? 0
|
const shareCount = stats.shareCount ?? 0
|
||||||
|
const serverLikeCount = stats.likeCount
|
||||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||||
const [feedbackText, setFeedbackText] = useState('')
|
const [feedbackText, setFeedbackText] = useState('')
|
||||||
const [feedbackSending, setFeedbackSending] = useState(false)
|
const [feedbackSending, setFeedbackSending] = useState(false)
|
||||||
const [feedbackDone, setFeedbackDone] = useState(false)
|
const [feedbackDone, setFeedbackDone] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||||
@@ -70,6 +68,7 @@ export function HeaderPanel() {
|
|||||||
cumulative: data.cumulative,
|
cumulative: data.cumulative,
|
||||||
feedbackCount: data.feedbackCount,
|
feedbackCount: data.feedbackCount,
|
||||||
shareCount: data.shareCount,
|
shareCount: data.shareCount,
|
||||||
|
likeCount: data.likeCount,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setStats({ viewers: 0, cumulative: 0 })
|
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' : ''}`} />
|
<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>}
|
点赞 {(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? 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>
|
</button>
|
||||||
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>
|
<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" />}
|
{isConnected ? <Wifi className="h-3.5 w-3.5" /> : <WifiOff className="h-3.5 w-3.5" />}
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import {
|
|||||||
postSituationUpdate,
|
postSituationUpdate,
|
||||||
deleteSituationUpdate,
|
deleteSituationUpdate,
|
||||||
putForceSummary,
|
putForceSummary,
|
||||||
|
putDisplayStats,
|
||||||
type EditRawData,
|
type EditRawData,
|
||||||
type CombatLossesRow,
|
type CombatLossesRow,
|
||||||
type KeyLocationRow,
|
type KeyLocationRow,
|
||||||
type SituationUpdateRow,
|
|
||||||
type ForceSummaryRow,
|
type ForceSummaryRow,
|
||||||
|
type DisplayStatsRow,
|
||||||
} from '@/api/edit'
|
} from '@/api/edit'
|
||||||
import { fetchAndSetSituation } from '@/store/situationStore'
|
import { fetchAndSetSituation } from '@/store/situationStore'
|
||||||
|
import { useStatsStore } from '@/store/statsStore'
|
||||||
|
|
||||||
const LOSS_FIELDS: { key: keyof CombatLossesRow; label: string }[] = [
|
const LOSS_FIELDS: { key: keyof CombatLossesRow; label: string }[] = [
|
||||||
{ key: 'bases_destroyed', label: '基地摧毁' },
|
{ key: 'bases_destroyed', label: '基地摧毁' },
|
||||||
@@ -62,8 +64,9 @@ export function EditDashboard() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = 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 [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
|
||||||
|
const setStats = useStatsStore((s) => s.setStats)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
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) {
|
if (loading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
|
<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">
|
<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">
|
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
@@ -476,9 +544,9 @@ function KeyLocationRowEdit({
|
|||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
className="rounded border border-military-border bg-military-panel px-2 py-1 text-sm"
|
className="rounded border border-military-border bg-military-panel px-2 py-1 text-sm"
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{STATUS_OPTIONS.map((s) => {
|
||||||
<option key={s} value={s}>{s}</option>
|
return <option key={s} value={s}>{s}</option>
|
||||||
))}
|
})}
|
||||||
</select>
|
</select>
|
||||||
<label className="flex items-center gap-1 text-sm">
|
<label className="flex items-center gap-1 text-sm">
|
||||||
<span className="text-military-text-secondary">损伤</span>
|
<span className="text-military-text-secondary">损伤</span>
|
||||||
@@ -550,3 +618,65 @@ function ForceSummaryForm({
|
|||||||
</div>
|
</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
|
cumulative?: number
|
||||||
feedbackCount?: number
|
feedbackCount?: number
|
||||||
shareCount?: number
|
shareCount?: number
|
||||||
|
likeCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsState {
|
interface StatsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user