fix:修复相关问题,新增留言查看

This commit is contained in:
Daniel
2026-03-06 11:41:09 +08:00
parent cbac58af62
commit 97b04b6ccc
10 changed files with 252 additions and 12 deletions

View File

@@ -31,7 +31,7 @@ API_BASE = os.environ.get("API_BASE", "http://localhost:3001")
QUERY = os.environ.get("GDELT_QUERY", "United States Iran military")
MAX_RECORDS = int(os.environ.get("GDELT_MAX_RECORDS", "30"))
FETCH_INTERVAL_SEC = int(os.environ.get("FETCH_INTERVAL_SEC", "60"))
RSS_INTERVAL_SEC = int(os.environ.get("RSS_INTERVAL_SEC", "60")) # 每分钟抓取世界主流媒体
# RSS 抓取间隔:优先从 DB crawler_config 读取(修订面板可调),否则用环境变量或默认 60
# 时间范围1h=1小时 1d=1天 1week=1周不设则默认 3 个月(易返回旧文)
GDELT_TIMESPAN = os.environ.get("GDELT_TIMESPAN", "1d")
# 设为 1 则跳过 GDELT仅用 RSS 新闻作为事件脉络GDELT 国外可能无法访问)
@@ -48,6 +48,22 @@ if os.environ.get("CRAWLER_USE_PROXY") != "1":
EVENT_CACHE: List[dict] = []
def _get_rss_interval_sec() -> int:
"""从 DB crawler_config 读取 RSS 抓取间隔(秒),修订面板可调。无配置或异常时返回 60。"""
try:
if os.path.exists(DB_PATH):
conn = sqlite3.connect(DB_PATH, timeout=3)
row = conn.execute("SELECT rss_interval_sec FROM crawler_config WHERE id = 1").fetchone()
conn.close()
if row and row[0] is not None:
val = int(row[0])
if 30 <= val <= 86400:
return val
except Exception:
pass
return int(os.environ.get("RSS_INTERVAL_SEC", "60"))
# ==========================
# 冲突强度评分 (110)
# ==========================
@@ -386,7 +402,8 @@ async def _periodic_fetch() -> None:
break
except Exception as e:
print(f" [warn] 定时抓取: {e}")
await asyncio.sleep(min(RSS_INTERVAL_SEC, FETCH_INTERVAL_SEC))
interval = min(_get_rss_interval_sec(), FETCH_INTERVAL_SEC)
await asyncio.sleep(interval)
# ==========================

View File

@@ -227,6 +227,11 @@ function runMigrations(db) {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
const fbCols = prepare('PRAGMA table_info(feedback)').all()
const fbNames = (fbCols || []).map((c) => c.name)
if (!fbNames.includes('created_at')) {
exec('ALTER TABLE feedback ADD COLUMN created_at TEXT NOT NULL DEFAULT (datetime(\'now\'))')
}
} catch (_) {}
try {
exec(`
@@ -308,6 +313,11 @@ function runMigrations(db) {
config TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS crawler_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
rss_interval_sec INTEGER NOT NULL DEFAULT 60
);
INSERT OR IGNORE INTO crawler_config (id, rss_interval_sec) VALUES (1, 60);
`)
} catch (_) {}

View File

@@ -55,12 +55,14 @@ wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'situation', data: getSituation(), stats: getStats() }))
})
// 仅用 situation.updated_at + situation_update 条数做“版本”,避免无变更时重复查库和推送
// 版本含 situationsituation_update、key_location 基地态势,任一变化都会触发广播,保证爬虫 AI 更新基地后前端实时刷新
function getBroadcastVersion() {
try {
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
const row = db.prepare('SELECT COUNT(*) as c FROM situation_update').get()
return `${meta?.updated_at || ''}_${row?.c ?? 0}`
const usAtt = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='us' AND status='attacked'").get()
const irAtt = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='iran' AND status='attacked'").get()
return `${meta?.updated_at || ''}_${row?.c ?? 0}_b${usAtt?.c ?? 0}_${irAtt?.c ?? 0}`
} catch (_) {
return ''
}
@@ -92,15 +94,17 @@ if (BROADCAST_INTERVAL_MS > 0) {
setInterval(() => broadcastSituation(false), BROADCAST_INTERVAL_MS)
}
// 供爬虫调用:先从磁盘重载 DB纳入爬虫写入再更新 situation.updated_at 并立即广播;前端据此显示「实时更新」时间
// 供爬虫调用:先从磁盘重载 DB纳入爬虫写入再更新 situation.updated_at 并立即广播;前端据此实时更新基地态势等
function notifyCrawlerUpdate() {
try {
const db = require('./db')
db.reloadFromFile()
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
broadcastSituation()
broadcastSituation(true) // 强制推送,确保基地数/被袭数等随 AI 清洗实时更新
const n = db.prepare('SELECT COUNT(*) as c FROM situation_update').get().c
console.log('[crawler/notify] DB 已重载并广播situation_update 条数:', n)
const usB = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='us'").get().c
const irB = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='iran'").get().c
console.log('[crawler/notify] DB 已重载并广播situation_update:', n, '基地 us/iran:', usB, irB)
} catch (e) {
console.error('[crawler/notify]', e?.message || e)
}

View File

@@ -267,6 +267,10 @@ router.get('/edit/raw', (req, res) => {
try {
animationConfig = db.prepare('SELECT strike_cutoff_days FROM animation_config WHERE id = 1').get()
} catch (_) {}
let crawlerConfig = null
try {
crawlerConfig = db.prepare('SELECT rss_interval_sec FROM crawler_config 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(
@@ -293,6 +297,9 @@ router.get('/edit/raw', (req, res) => {
animationConfig: {
strikeCutoffDays: animationConfig?.strike_cutoff_days ?? 5,
},
crawlerConfig: {
rssIntervalSec: crawlerConfig?.rss_interval_sec ?? 60,
},
})
} catch (err) {
console.error(err)
@@ -300,6 +307,33 @@ router.get('/edit/raw', (req, res) => {
}
})
/** GET 留言列表(修订页查看) */
router.get('/edit/feedback', (req, res) => {
try {
const list = db.prepare('SELECT id, content, ip, created_at FROM feedback ORDER BY id DESC LIMIT 500').all()
res.json({ list })
} catch (err) {
console.error('[edit/feedback]', err?.message || err)
// 表不存在或列缺失时返回空列表,避免修订页报错
res.json({ list: [] })
}
})
/** PUT 爬虫配置RSS 抓取间隔等,爬虫从同一 DB 读取) */
router.put('/edit/crawler-config', (req, res) => {
try {
const n = req.body?.rssIntervalSec
if (n === undefined) return res.status(400).json({ error: 'rssIntervalSec required' })
const val = parseInt(String(n), 10)
if (!Number.isFinite(val) || val < 30 || val > 86400) return res.status(400).json({ error: 'rssIntervalSec must be 3086400' })
db.prepare('INSERT OR REPLACE INTO crawler_config (id, rss_interval_sec) VALUES (1, ?)').run(val)
res.json({ ok: true, rssIntervalSec: val })
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
/** PUT 更新战损(美/伊) */
router.put('/edit/combat-losses', (req, res) => {
try {

View File

@@ -67,6 +67,17 @@ export interface AnimationConfigRow {
strikeCutoffDays: number
}
export interface CrawlerConfigRow {
rssIntervalSec: number
}
export interface FeedbackRow {
id: number
content: string
ip: string | null
created_at: string
}
export interface EditRawData {
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
@@ -74,6 +85,7 @@ export interface EditRawData {
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
displayStats?: DisplayStatsRow
animationConfig?: AnimationConfigRow
crawlerConfig?: CrawlerConfigRow
}
export async function fetchEditRaw(): Promise<EditRawData> {
@@ -172,3 +184,21 @@ export async function putAnimationConfig(body: Partial<AnimationConfigRow>): Pro
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function putCrawlerConfig(body: { rssIntervalSec: number }): Promise<void> {
const res = await fetch('/api/edit/crawler-config', {
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)
}
}
export async function fetchFeedbackList(): Promise<{ list: FeedbackRow[] }> {
const res = await fetch('/api/edit/feedback', { cache: 'no-store' })
if (!res.ok) throw new Error('获取留言失败')
return res.json()
}

View File

@@ -33,6 +33,7 @@ export function BaseStatusPanel({ keyLocations, className = '' }: BaseStatusPane
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
<MapPin className="h-3 w-3 shrink-0 text-blue-400" />
<span className="normal-case opacity-75"> AI </span>
</div>
<div className="flex flex-col gap-1.5 text-xs tabular-nums">
<div className="flex items-center justify-between gap-2">

View File

@@ -32,6 +32,7 @@ export function IranBaseStatusPanel({ keyLocations = [], className = '' }: IranB
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
<MapPin className="h-3 w-3 shrink-0 text-amber-500" />
<span className="normal-case opacity-75"> AI </span>
</div>
<div className="flex flex-col gap-1.5 text-xs tabular-nums">
<div className="flex items-center justify-between gap-2">

View File

@@ -211,6 +211,9 @@ export function TimelinePanel() {
</select>
</>
)}
<span className="ml-auto text-[10px] text-military-text-secondary shrink-0">
</span>
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight } from 'lucide-react'
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight, MessageSquare, X } from 'lucide-react'
import {
fetchEditRaw,
putCombatLosses,
@@ -10,12 +10,16 @@ import {
putForceSummary,
putDisplayStats,
putAnimationConfig,
putCrawlerConfig,
fetchFeedbackList,
type EditRawData,
type CombatLossesRow,
type KeyLocationRow,
type ForceSummaryRow,
type DisplayStatsRow,
type AnimationConfigRow,
type CrawlerConfigRow,
type FeedbackRow,
} from '@/api/edit'
import { fetchAndSetSituation } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore'
@@ -68,6 +72,9 @@ export function EditDashboard() {
const [saving, setSaving] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['displayStats', 'losses', 'updates']))
const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [feedbackList, setFeedbackList] = useState<FeedbackRow[]>([])
const [feedbackLoading, setFeedbackLoading] = useState(false)
const setStats = useStatsStore((s) => s.setStats)
const load = useCallback(async () => {
@@ -200,6 +207,32 @@ export function EditDashboard() {
}
}
const handleSaveCrawlerConfig = async (row: CrawlerConfigRow) => {
setSaving('crawlerConfig')
try {
await putCrawlerConfig({ rssIntervalSec: row.rssIntervalSec })
await load()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleOpenFeedback = async () => {
setFeedbackOpen(true)
setFeedbackLoading(true)
setFeedbackList([])
try {
const { list } = await fetchFeedbackList()
setFeedbackList(list ?? [])
} catch {
setFeedbackList([])
} finally {
setFeedbackLoading(false)
}
}
const handleClearDisplayStatsOverrides = async () => {
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数看过=累计访问等)。')) return
setSaving('displayStats')
@@ -310,15 +343,72 @@ export function EditDashboard() {
)}
</section>
{/* 看过、在看、分享、点赞、留言 */}
{/* 爬虫配置RSS 抓取间隔,爬虫从 DB 读取便于修订面板调整 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('displayStats')}
onClick={() => toggleSection('crawlerConfig')}
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" />}
<span className="font-medium text-cyan-400"></span>
{openSections.has('crawlerConfig') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('crawlerConfig') && data && (
<div className="border-t border-military-border p-4 space-y-3 text-sm">
<p className="text-military-text-secondary text-xs">
RSS 30864001 24
</p>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault()
const v = Number((e.currentTarget.querySelector('input[name="rssIntervalSec"]') as HTMLInputElement)?.value ?? 60)
const sec = Number.isFinite(v) && v >= 30 && v <= 86400 ? v : data.crawlerConfig?.rssIntervalSec ?? 60
handleSaveCrawlerConfig({ rssIntervalSec: sec })
}}
>
<label className="flex items-center gap-2 text-xs text-military-text-secondary">
<span></span>
<input
type="number"
name="rssIntervalSec"
min={30}
max={86400}
defaultValue={data.crawlerConfig?.rssIntervalSec ?? 60}
className="w-24 rounded border border-military-border bg-black/40 px-2 py-1 text-right text-xs text-military-text-primary"
/>
</label>
<button
type="submit"
disabled={saving === 'crawlerConfig'}
className="inline-flex items-center gap-1 rounded border border-military-border px-3 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<Save className="h-3 w-3" />
</button>
</form>
</div>
)}
</section>
{/* 看过、在看、分享、点赞、留言 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<div className="flex w-full items-center gap-2">
<button
onClick={() => toggleSection('displayStats')}
className="flex flex-1 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>
<button
type="button"
onClick={handleOpenFeedback}
className="flex items-center gap-1.5 rounded border border-military-border px-2 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 shrink-0"
>
<MessageSquare className="h-3.5 w-3.5" />
</button>
</div>
{openSections.has('displayStats') && data && (
<div className="border-t border-military-border p-4 space-y-3">
<p className="text-military-text-secondary text-xs">
@@ -508,6 +598,56 @@ export function EditDashboard() {
)}
</section>
</main>
{/* 留言数据弹窗 */}
{feedbackOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={() => setFeedbackOpen(false)}
aria-modal="true"
role="dialog"
>
<div
className="max-h-[85vh] w-full max-w-2xl rounded border border-military-border bg-military-panel shadow-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-military-border px-4 py-3">
<h3 className="font-medium text-cyan-400"></h3>
<button
type="button"
onClick={() => setFeedbackOpen(false)}
className="rounded p-1 text-military-text-secondary hover:bg-military-border/50 hover:text-military-text-primary"
aria-label="关闭"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{feedbackLoading ? (
<p className="text-military-text-secondary text-sm"></p>
) : feedbackList.length === 0 ? (
<p className="text-military-text-secondary text-sm"></p>
) : (
<ul className="space-y-3">
{feedbackList.map((f) => (
<li
key={f.id}
className="rounded border border-military-border/50 bg-military-dark/30 px-3 py-2 text-sm"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-military-text-secondary mb-1">
<span>#{f.id}</span>
<span>{f.created_at?.slice(0, 19).replace('T', ' ') ?? ''}</span>
{f.ip && <span>{f.ip}</span>}
</div>
<p className="text-military-text-primary whitespace-pre-wrap break-words">{f.content}</p>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}