diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc index 26f43ed..f084ae4 100644 Binary files a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc and b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc differ 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}

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