fix:修复相关问题,新增留言查看
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
# ==========================
|
||||
|
||||
10
server/db.js
10
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 (_) {}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -211,6 +211,9 @@ export function TimelinePanel() {
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-military-text-secondary shrink-0">
|
||||
数据来源于互联网资讯
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 抓取间隔(秒):爬虫每轮抓取后等待该秒数再执行下一轮。范围 30–86400(1 分钟–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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user