From 97b04b6ccc7f792a380b9e8b8b6b21c865154006 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 11:41:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=97=AE=E9=A2=98,=E6=96=B0=E5=A2=9E=E7=95=99=E8=A8=80?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../realtime_conflict_service.cpython-39.pyc | Bin 15315 -> 15874 bytes crawler/realtime_conflict_service.py | 21 ++- server/db.js | 10 ++ server/index.js | 14 +- server/routes.js | 34 ++++ src/api/edit.ts | 30 ++++ src/components/BaseStatusPanel.tsx | 1 + src/components/IranBaseStatusPanel.tsx | 1 + src/components/TimelinePanel.tsx | 3 + src/pages/EditDashboard.tsx | 150 +++++++++++++++++- 10 files changed, 252 insertions(+), 12 deletions(-) diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc index 26f43edde54e4fdd7ff0acb0ef4518cb321e7b87..f084ae4fd2f7161bdd5dd7d077b7e2fd21e8ddc5 100644 GIT binary patch delta 4526 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 AFaQ7m diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py index aed9cb2..7736491 100644 --- a/crawler/realtime_conflict_service.py +++ b/crawler/realtime_conflict_service.py @@ -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")) + + # ========================== # 冲突强度评分 (1–10) # ========================== @@ -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) # ========================== diff --git a/server/db.js b/server/db.js index 180b6a4..cfb6d24 100644 --- a/server/db.js +++ b/server/db.js @@ -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 (_) {} diff --git a/server/index.js b/server/index.js index e38786e..a2f0d48 100644 --- a/server/index.js +++ b/server/index.js @@ -55,12 +55,14 @@ wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'situation', data: getSituation(), stats: getStats() })) }) -// 仅用 situation.updated_at + situation_update 条数做“版本”,避免无变更时重复查库和推送 +// 版本含 situation、situation_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) } diff --git a/server/routes.js b/server/routes.js index 1f06973..a64be04 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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 30–86400' }) + 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 { diff --git a/src/api/edit.ts b/src/api/edit.ts index d3feeb8..55a0b44 100644 --- a/src/api/edit.ts +++ b/src/api/edit.ts @@ -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 { @@ -172,3 +184,21 @@ export async function putAnimationConfig(body: Partial): Pro throw new Error((e as { error?: string }).error || res.statusText) } } + +export async function putCrawlerConfig(body: { rssIntervalSec: number }): Promise { + 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() +} diff --git a/src/components/BaseStatusPanel.tsx b/src/components/BaseStatusPanel.tsx index 22247a6..ef4bb87 100644 --- a/src/components/BaseStatusPanel.tsx +++ b/src/components/BaseStatusPanel.tsx @@ -33,6 +33,7 @@ export function BaseStatusPanel({ keyLocations, className = '' }: BaseStatusPane
美军基地态势 + (随爬虫 AI 实时更新)
diff --git a/src/components/IranBaseStatusPanel.tsx b/src/components/IranBaseStatusPanel.tsx index 464f9b8..6661429 100644 --- a/src/components/IranBaseStatusPanel.tsx +++ b/src/components/IranBaseStatusPanel.tsx @@ -32,6 +32,7 @@ export function IranBaseStatusPanel({ keyLocations = [], className = '' }: IranB
伊朗基地态势 + (随爬虫 AI 实时更新)
diff --git a/src/components/TimelinePanel.tsx b/src/components/TimelinePanel.tsx index 884ed62..643ffb0 100644 --- a/src/components/TimelinePanel.tsx +++ b/src/components/TimelinePanel.tsx @@ -211,6 +211,9 @@ export function TimelinePanel() { )} + + 数据来源于互联网资讯 +
) diff --git a/src/pages/EditDashboard.tsx b/src/pages/EditDashboard.tsx index 7eebdfa..4bb0463 100644 --- a/src/pages/EditDashboard.tsx +++ b/src/pages/EditDashboard.tsx @@ -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(null) const [openSections, setOpenSections] = useState>(new Set(['displayStats', 'losses', 'updates'])) const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' }) + const [feedbackOpen, setFeedbackOpen] = useState(false) + const [feedbackList, setFeedbackList] = useState([]) + 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() { )} - {/* 看过、在看、分享、点赞、留言 */} + {/* 爬虫配置:RSS 抓取间隔,爬虫从 DB 读取便于修订面板调整 */}
+ {openSections.has('crawlerConfig') && data && ( +
+

+ RSS 抓取间隔(秒):爬虫每轮抓取后等待该秒数再执行下一轮。范围 30–86400(1 分钟–24 小时)。修改后爬虫下次循环生效。 +

+
{ + 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 }) + }} + > + + +
+
+ )} +
+ + {/* 看过、在看、分享、点赞、留言 */} +
+
+ + +
{openSections.has('displayStats') && data && (

@@ -508,6 +598,56 @@ export function EditDashboard() { )}

+ + {/* 留言数据弹窗 */} + {feedbackOpen && ( +
setFeedbackOpen(false)} + aria-modal="true" + role="dialog" + > +
e.stopPropagation()} + > +
+

留言数据

+ +
+
+ {feedbackLoading ? ( +

加载中…

+ ) : feedbackList.length === 0 ? ( +

暂无留言(若刚点击后即显示,可能是接口暂时不可用,请稍后重试)

+ ) : ( +
    + {feedbackList.map((f) => ( +
  • +
    + #{f.id} + {f.created_at?.slice(0, 19).replace('T', ' ') ?? ''} + {f.ip && {f.ip}} +
    +

    {f.content}

    +
  • + ))} +
+ )} +
+
+
+ )}
) }