Compare commits

..

3 Commits

Author SHA1 Message Date
Daniel
004d10b283 fix: 优化数据 2026-03-02 11:28:13 +08:00
Daniel
4a8fff5a00 fix:优化数据来源 2026-03-02 01:00:04 +08:00
Daniel
91d9e48e1e fix:优化整个大屏界面 2026-03-02 00:59:40 +08:00
56 changed files with 2732 additions and 108 deletions

0
=1.11.0 Normal file
View File

View File

@@ -41,7 +41,13 @@ npm run api:seed
npm run api
```
开发时需同时运行前端与 API
开发时可用一键启动(推荐)
```bash
npm start
```
或分终端分别运行:
```bash
# 终端 1
@@ -53,6 +59,13 @@ npm run dev
API 会由 Vite 代理到 `/api`,前端通过 `/api/situation` 获取完整态势数据。数据库文件位于 `server/data.db`,可通过修改表数据实现动态调整。
### 爬虫不生效时
1. 测试 RSS 抓取:`npm run crawler:test`(需网络,返回抓取条数)
2. 单独启动爬虫查看日志:`npm run gdelt`(另开终端)
3. 查看爬虫状态:`curl http://localhost:8000/crawler/status`(需爬虫服务已启动)
4. 数据库面板 `/db` 每 30 秒自动刷新,可观察 situation_update 条数是否增加
## Development
```bash

105
crawler/README.md Normal file
View File

@@ -0,0 +1,105 @@
# GDELT 实时冲突服务 + 新闻爬虫
## 数据来源梳理
### 1. GDELT Project (gdelt_events)
| 项目 | 说明 |
|------|------|
| API | `https://api.gdeltproject.org/api/v2/doc/doc` |
| 查询 | `query=United States Iran military`(可配 `GDELT_QUERY` |
| 模式 | `mode=ArtList``format=json``maxrecords=30` |
| 时间范围 | **未指定时默认最近 3 个月**,按相关性排序,易返回较旧文章 |
| 更新频率 | GDELT 约 15 分钟级,爬虫 60 秒拉一次 |
**数据偏老原因**:未传 `timespan``sort=datedesc`API 返回 3 个月内“最相关”文章,不保证最新。
### 2. RSS 新闻 (situation_update) — 主事件脉络来源
| 项目 | 说明 |
|------|------|
| 源 | 多国主流媒体:美(Reuters/NYT)、英(BBC)、法(France 24)、俄(TASS/RT)、中(Xinhua/CGTN)、伊(Press TV)、卡塔尔(Al Jazeera) |
| 过滤 | 标题/摘要需含 `KEYWORDS` 之一iran、usa、strike、military 等) |
| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 |
| 优先级 | 启动时先拉 RSS再拉 GDELT |
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS 新闻即可维持事件脉络。部分境外源可能受网络限制。
### 3. AI 新闻清洗与分类(可选)
- **清洗**`cleaner_ai.py` 用 Ollama 提炼新闻为简洁摘要,供面板展示
- **分类**`parser_ai.py` 用 Ollama 替代规则做 category/severity 判定
- 需先安装并运行 Ollama`ollama run llama3.1`
- 环境变量:`OLLAMA_MODEL=llama3.1``PARSER_AI_DISABLED=1``CLEANER_AI_DISABLED=1`(禁用对应 AI
---
**事件脉络可实时更新**:爬虫抓取后 → 写入 SQLite → 调用 Node 通知 → WebSocket 广播 → 前端自动刷新。
## 依赖
```bash
pip install -r requirements.txt
```
新增 `deep-translator`GDELT 与 RSS 新闻入库前自动翻译为中文。
## 运行(需同时启动 3 个服务)
| 终端 | 命令 | 说明 |
|------|------|------|
| 1 | `npm run api` | Node API + WebSocket必须 |
| 2 | `npm run gdelt` | GDELT + RSS 爬虫(**事件脉络数据来源** |
| 3 | `npm run dev` | 前端开发 |
**事件脉络不更新时**:多半是未启动 `npm run gdelt`。只跑 `npm run api` 时,事件脉络会显示空或仅有缓存。
## 数据流
```
GDELT API → 抓取(60s) → SQLite (gdelt_events, conflict_stats) → POST /api/crawler/notify
Node 更新 situation.updated_at + WebSocket 广播
前端实时展示
```
## 配置
环境变量:
- `DB_PATH`: SQLite 路径,默认 `../server/data.db`
- `API_BASE`: Node API 地址,默认 `http://localhost:3001`
- `GDELT_QUERY`: 搜索关键词,默认 `United States Iran military`
- `GDELT_MAX_RECORDS`: 最大条数,默认 30
- `GDELT_TIMESPAN`: 时间范围,`1h` / `1d` / `1week`,默认 `1d`(近日资讯)
- `GDELT_DISABLED`: 设为 `1` 则跳过 GDELT仅用 RSS 新闻GDELT 无法访问时用)
- `FETCH_INTERVAL_SEC`: GDELT 抓取间隔(秒),默认 60
- `RSS_INTERVAL_SEC`: RSS 抓取间隔(秒),默认 45优先保证事件脉络
- `OLLAMA_MODEL`: AI 分类模型,默认 `llama3.1`
- `PARSER_AI_DISABLED`: 设为 `1` 则禁用 AI 分类,仅用规则
- `CLEANER_AI_DISABLED`: 设为 `1` 则禁用 AI 清洗,仅用规则截断
## 冲突强度 (impact_score)
| 分数 | 地图效果 |
|------|------------|
| 13 | 绿色点 |
| 46 | 橙色闪烁 |
| 710 | 红色脉冲扩散 |
## API
- `GET http://localhost:8000/events`返回事件列表与冲突统计Python 服务直连)
- `GET http://localhost:3001/api/events`:从 Node 读取(推荐,含 WebSocket 同步)
## 故障排查
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| 事件脉络始终为空 | 未启动 GDELT 爬虫 | 另开终端运行 `npm run gdelt`,观察是否有 `GDELT 更新 X 条事件` 输出 |
| 事件脉络不刷新 | WebSocket 未连上 | 确认 `npm run api` 已启动,前端需通过 `npm run dev` 访问Vite 会代理 /ws |
| GDELT 抓取失败 | 系统代理超时 / ProxyError | 爬虫默认直连,不走代理;若需代理请设 `CRAWLER_USE_PROXY=1` |
| GDELT 抓取失败 | 网络 / GDELT API 限流 | 检查 Python 终端报错GDELT 在国外,国内网络可能较慢或超时 |
| 新闻条数为 0 | RSS 源被墙或关键词不匹配 | 检查 crawler/config.py 中 RSS_FEEDS、KEYWORDS国内需代理 |
| **返回数据偏老** | GDELT 默认 3 个月内按相关性 | 设置 `GDELT_TIMESPAN=1d` 限制为近日;加 `sort=datedesc` 最新优先 |

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

87
crawler/cleaner_ai.py Normal file
View File

@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""
AI 清洗新闻数据,严格按面板字段约束输出
面板 EventTimelinePanel 所需summary(≤120字)、category(枚举)、severity(枚举)
"""
import os
import re
from typing import Optional
CLEANER_AI_DISABLED = os.environ.get("CLEANER_AI_DISABLED", "0") == "1"
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1")
# 面板 schema必须与 EventTimelinePanel / SituationUpdate 一致
SUMMARY_MAX_LEN = 120 # 面板 line-clamp-2 展示
CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
SEVERITIES = ("low", "medium", "high", "critical")
def _sanitize_summary(text: str, max_len: int = SUMMARY_MAX_LEN) -> str:
"""确保 summary 符合面板:纯文本、无换行、限制长度"""
if not text or not isinstance(text, str):
return ""
s = re.sub(r"\s+", " ", str(text).strip())
s = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", s) # 去除控制字符
return s[:max_len].rstrip()
def _rule_clean(text: str, max_len: int = SUMMARY_MAX_LEN) -> str:
"""规则清洗:去空白、去控制符、截断"""
return _sanitize_summary(text, max_len)
def _call_ollama_summary(text: str, max_len: int, timeout: int = 6) -> Optional[str]:
"""调用 Ollama 提炼摘要输出须为纯文本、≤max_len 字"""
if CLEANER_AI_DISABLED or not text or len(str(text).strip()) < 5:
return None
try:
import requests
prompt = f"""将新闻提炼为1-2句简洁中文事实直接输出纯文本不要标号、引号、解释。限{max_len}字内。
原文:{str(text)[:350]}
输出:"""
r = requests.post(
"http://localhost:11434/api/chat",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_predict": 150},
},
timeout=timeout,
)
if r.status_code != 200:
return None
out = (r.json().get("message", {}).get("content", "") or "").strip()
out = re.sub(r"^[\d\.\-\*\s]+", "", out) # 去编号
out = re.sub(r"^['\"\s]+|['\"\s]+$", "", out)
out = _sanitize_summary(out, max_len)
if out and len(out) > 3:
return out
return None
except Exception:
return None
def clean_news_for_panel(text: str, max_len: int = SUMMARY_MAX_LEN) -> str:
"""清洗 summary 字段,供 EventTimelinePanel 展示。输出必为≤max_len 的纯文本"""
if not text or not isinstance(text, str):
return ""
t = str(text).strip()
if not t:
return ""
res = _call_ollama_summary(t, max_len, timeout=6)
if res:
return res
return _rule_clean(t, max_len)
def ensure_category(cat: str) -> str:
"""确保 category 在面板枚举内"""
return cat if cat in CATEGORIES else "other"
def ensure_severity(sev: str) -> str:
"""确保 severity 在面板枚举内"""
return sev if sev in SEVERITIES else "medium"

49
crawler/config.py Normal file
View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""爬虫配置"""
import os
from pathlib import Path
# 数据库路径(与 server 共用 SQLite
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
# Node API 地址(用于通知推送)
API_BASE = os.environ.get("API_BASE", "http://localhost:3001")
# 抓取间隔(秒)
CRAWL_INTERVAL = int(os.environ.get("CRAWL_INTERVAL", "300"))
# RSS 源:世界主流媒体,覆盖美伊/中东多视角
RSS_FEEDS = [
# 美国
"https://feeds.reuters.com/reuters/topNews",
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
# 英国
"https://feeds.bbci.co.uk/news/world/rss.xml",
"https://feeds.bbci.co.uk/news/world/middle_east/rss.xml",
"https://www.theguardian.com/world/rss",
# 法国
"https://www.france24.com/en/rss",
# 德国
"https://rss.dw.com/xml/rss-en-world",
# 俄罗斯
"https://tass.com/rss/v2.xml",
"https://www.rt.com/rss/",
# 中国
"https://english.news.cn/rss/world.xml",
"https://www.cgtn.com/rss/world",
# 伊朗
"https://www.presstv.ir/rss",
# 卡塔尔(中东)
"https://www.aljazeera.com/xml/rss/all.xml",
"https://www.aljazeera.com/xml/rss/middleeast.xml",
]
# 关键词过滤:至少匹配一个才会入库
KEYWORDS = [
"iran", "iranian", "tehran", "以色列", "israel",
"usa", "us ", "american", "美军", "美国",
"middle east", "中东", "persian gulf", "波斯湾",
"strike", "attack", "military", "missile", "", "nuclear",
"carrier", "航母", "houthi", "胡塞", "hamas",
]

126
crawler/db_merge.py Normal file
View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""
将 AI 提取的结构化数据合并到 SQLite
与 panel schema 及 situationData.getSituation 对齐,支持回放
"""
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
def _ensure_tables(conn: sqlite3.Connection) -> None:
"""确保所需表存在(与 db.js 一致)"""
conn.execute("""
CREATE TABLE IF NOT EXISTS situation_update (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, category TEXT NOT NULL,
summary TEXT NOT NULL, severity TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS combat_losses (
side TEXT PRIMARY KEY CHECK (side IN ('us', 'iran')),
bases_destroyed INTEGER NOT NULL, bases_damaged INTEGER NOT NULL,
personnel_killed INTEGER NOT NULL, personnel_wounded INTEGER NOT NULL,
aircraft INTEGER NOT NULL, warships INTEGER NOT NULL, armor INTEGER NOT NULL, vehicles INTEGER NOT NULL
)
""")
try:
conn.execute("ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE combat_losses ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))")
except sqlite3.OperationalError:
pass
conn.execute("CREATE TABLE IF NOT EXISTS wall_street_trend (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_current (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_history (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS situation (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, updated_at TEXT NOT NULL)")
conn.commit()
def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
"""将提取数据合并到 DB返回是否有更新"""
path = db_path or DB_PATH
if not os.path.exists(path):
return False
conn = sqlite3.connect(path, timeout=10)
try:
_ensure_tables(conn)
updated = False
# situation_update
if "situation_update" in extracted:
u = extracted["situation_update"]
uid = f"ai_{hash(u.get('summary','')+u.get('timestamp','')) % 10**10}"
conn.execute(
"INSERT OR IGNORE INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)",
(uid, u.get("timestamp", ""), u.get("category", "other"), u.get("summary", "")[:500], u.get("severity", "medium")),
)
if conn.total_changes > 0:
updated = True
# combat_losses增量叠加到当前值
if "combat_losses_delta" in extracted:
for side, delta in extracted["combat_losses_delta"].items():
if side not in ("us", "iran"):
continue
try:
row = conn.execute(
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?",
(side,),
).fetchone()
if not row:
continue
cur = {
"personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0,
"civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5],
"aircraft": row[6], "warships": row[7], "armor": row[8], "vehicles": row[9],
}
pk = max(0, (cur["personnel_killed"] or 0) + delta.get("personnel_killed", 0))
pw = max(0, (cur["personnel_wounded"] or 0) + delta.get("personnel_wounded", 0))
ck = max(0, (cur["civilian_killed"] or 0) + delta.get("civilian_killed", 0))
cw = max(0, (cur["civilian_wounded"] or 0) + delta.get("civilian_wounded", 0))
bd = max(0, (cur["bases_destroyed"] or 0) + delta.get("bases_destroyed", 0))
bm = max(0, (cur["bases_damaged"] or 0) + delta.get("bases_damaged", 0))
ac = max(0, (cur["aircraft"] or 0) + delta.get("aircraft", 0))
ws = max(0, (cur["warships"] or 0) + delta.get("warships", 0))
ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0))
vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0))
ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
conn.execute(
"""UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?,
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""",
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side),
)
if conn.total_changes > 0:
updated = True
except Exception:
pass
# retaliation
if "retaliation" in extracted:
r = extracted["retaliation"]
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (r["value"],))
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (r["time"], r["value"]))
updated = True
# wall_street_trend
if "wall_street" in extracted:
w = extracted["wall_street"]
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"]))
updated = True
if updated:
conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
conn.commit()
return updated
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()

110
crawler/db_writer.py Normal file
View File

@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""写入 SQLite 并确保 situation_update 表存在"""
import sqlite3
import hashlib
import os
from datetime import datetime, timezone
from config import DB_PATH
CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
SEVERITIES = ("low", "medium", "high", "critical")
def _ensure_table(conn: sqlite3.Connection) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS situation_update (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
category TEXT NOT NULL,
summary TEXT NOT NULL,
severity TEXT NOT NULL
)
""")
conn.commit()
def _make_id(title: str, url: str, published: str) -> str:
raw = f"{title}|{url}|{published}"
return "nw_" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _to_utc_iso(dt: datetime) -> str:
if dt.tzinfo:
dt = dt.astimezone(timezone.utc)
return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
def insert_update(
conn: sqlite3.Connection,
title: str,
summary: str,
url: str,
published: datetime,
category: str = "other",
severity: str = "medium",
) -> bool:
"""插入一条更新,若 id 已存在则跳过。返回是否插入了新记录。"""
_ensure_table(conn)
ts = _to_utc_iso(published)
uid = _make_id(title, url, ts)
if category not in CATEGORIES:
category = "other"
if severity not in SEVERITIES:
severity = "medium"
try:
conn.execute(
"INSERT OR IGNORE INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)",
(uid, ts, category, summary[:500], severity),
)
conn.commit()
return conn.total_changes > 0
except Exception:
conn.rollback()
return False
def touch_situation_updated_at(conn: sqlite3.Connection) -> None:
"""更新 situation 表的 updated_at"""
conn.execute(
"INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)",
(datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),),
)
conn.commit()
def write_updates(updates: list[dict]) -> int:
"""
updates: [{"title","summary","url","published","category","severity"}, ...]
返回新增条数。
"""
if not os.path.exists(DB_PATH):
return 0
conn = sqlite3.connect(DB_PATH, timeout=10)
try:
count = 0
for u in updates:
pub = u.get("published")
if isinstance(pub, str):
try:
pub = datetime.fromisoformat(pub.replace("Z", "+00:00"))
except ValueError:
pub = datetime.utcnow()
elif pub is None:
pub = datetime.utcnow()
ok = insert_update(
conn,
title=u.get("title", "")[:200],
summary=u.get("summary", "") or u.get("title", ""),
url=u.get("url", ""),
published=pub,
category=u.get("category", "other"),
severity=u.get("severity", "medium"),
)
if ok:
count += 1
if count > 0:
touch_situation_updated_at(conn)
return count
finally:
conn.close()

100
crawler/extractor_ai.py Normal file
View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""
从新闻文本中 AI 提取结构化数据,映射到面板 schema
输出符合 panel_schema 的字段,供 db_merge 写入
"""
import json
import os
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from panel_schema import validate_category, validate_severity, validate_summary
CLEANER_AI_DISABLED = os.environ.get("CLEANER_AI_DISABLED", "0") == "1"
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1")
def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
"""调用 Ollama 提取结构化数据。输出 JSON仅包含新闻中可明确推断的字段"""
if CLEANER_AI_DISABLED or not text or len(str(text).strip()) < 10:
return None
try:
import requests
prompt = f"""从以下美伊/中东新闻中提取可推断的数值,输出 JSON仅包含有明确依据的字段。无依据则省略该字段。
要求:
- summary: 1-2句中文事实≤80字
- category: deployment|alert|intel|diplomatic|other
- severity: low|medium|high|critical
- us_personnel_killed, iran_personnel_killed 等:仅当新闻明确提及具体数字时填写
- retaliation_sentiment: 0-100仅当新闻涉及伊朗报复情绪时
- wall_street_value: 0-100仅当新闻涉及美股/市场反应时
原文:{str(text)[:500]}
直接输出 JSON不要解释"""
r = requests.post(
"http://localhost:11434/api/chat",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_predict": 256},
},
timeout=timeout,
)
if r.status_code != 200:
return None
raw = (r.json().get("message", {}).get("content", "") or "").strip()
raw = re.sub(r"^```\w*\s*|\s*```$", "", raw)
return json.loads(raw)
except Exception:
return None
def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, Any]:
"""
从新闻文本提取结构化数据,严格符合面板 schema
返回: { situation_update?, combat_losses_delta?, retaliation?, wall_street?, ... }
"""
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
out: Dict[str, Any] = {}
parsed = _call_ollama_extract(text)
if not parsed:
return out
# situation_update
if parsed.get("summary"):
out["situation_update"] = {
"summary": validate_summary(str(parsed["summary"])[:120], 120),
"category": validate_category(str(parsed.get("category", "other")).lower()),
"severity": validate_severity(str(parsed.get("severity", "medium")).lower()),
"timestamp": ts,
}
# combat_losses 增量(仅数字字段)
loss_us = {}
loss_ir = {}
for k in ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"]:
uk = f"us_{k}"
ik = f"iran_{k}"
if uk in parsed and isinstance(parsed[uk], (int, float)):
loss_us[k] = max(0, int(parsed[uk]))
if ik in parsed and isinstance(parsed[ik], (int, float)):
loss_ir[k] = max(0, int(parsed[ik]))
if loss_us or loss_ir:
out["combat_losses_delta"] = {}
if loss_us:
out["combat_losses_delta"]["us"] = loss_us
if loss_ir:
out["combat_losses_delta"]["iran"] = loss_ir
# retaliation
if "retaliation_sentiment" in parsed:
v = parsed["retaliation_sentiment"]
if isinstance(v, (int, float)) and 0 <= v <= 100:
out["retaliation"] = {"value": int(v), "time": ts}
# wall_street
if "wall_street_value" in parsed:
v = parsed["wall_street_value"]
if isinstance(v, (int, float)) and 0 <= v <= 100:
out["wall_street"] = {"time": ts, "value": int(v)}
return out

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
基于规则的新闻数据提取(无需 Ollama
从新闻文本中提取战损、报复情绪等数值,供 db_merge 写入
"""
import re
from datetime import datetime, timezone
from typing import Any, Dict, Optional
def _first_int(text: str, pattern: str) -> Optional[int]:
m = re.search(pattern, text, re.I)
if m and m.group(1) and m.group(1).replace(",", "").isdigit():
return max(0, int(m.group(1).replace(",", "")))
return None
def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, Any]:
"""
规则提取:匹配数字+关键词,输出符合 panel schema 的字段(无需 Ollama
"""
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
out: Dict[str, Any] = {}
t = (text or "").lower()
loss_us, loss_ir = {}, {}
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
if v is not None:
loss_us["personnel_killed"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|killed|dead)")
if v is not None:
loss_us["personnel_killed"] = v
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
if v is not None:
loss_ir["personnel_killed"] = v
v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|killed|dead)")
if v is not None:
loss_ir["personnel_killed"] = v
if loss_us:
out.setdefault("combat_losses_delta", {})["us"] = loss_us
if loss_ir:
out.setdefault("combat_losses_delta", {})["iran"] = loss_ir
if "retaliat" in t or "revenge" in t or "报复" in t:
out["retaliation"] = {"value": 75, "time": ts}
if "wall street" in t or " dow " in t or "s&p" in t or "market slump" in t or "stock fall" in t or "美股" in t:
out["wall_street"] = {"time": ts, "value": 55}
return out

57
crawler/main.py Normal file
View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""爬虫入口:定时抓取 → 解析 → 入库 → 通知 API"""
import time
import sys
from pathlib import Path
# 确保能导入 config
sys.path.insert(0, str(Path(__file__).resolve().parent))
from config import DB_PATH, API_BASE, CRAWL_INTERVAL
from scrapers.rss_scraper import fetch_all
from db_writer import write_updates
def notify_api() -> bool:
"""调用 Node API 触发立即广播"""
try:
import urllib.request
req = urllib.request.Request(
f"{API_BASE}/api/crawler/notify",
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except Exception as e:
print(f" [warn] notify API failed: {e}")
return False
def run_once() -> int:
items = fetch_all()
if not items:
return 0
n = write_updates(items)
if n > 0:
notify_api()
return n
def main() -> None:
print("Crawler started. DB:", DB_PATH)
print("API:", API_BASE, "| Interval:", CRAWL_INTERVAL, "s")
while True:
try:
n = run_once()
if n > 0:
print(f"[{time.strftime('%H:%M:%S')}] Inserted {n} new update(s)")
except KeyboardInterrupt:
break
except Exception as e:
print(f"[{time.strftime('%H:%M:%S')}] Error: {e}")
time.sleep(CRAWL_INTERVAL)
if __name__ == "__main__":
main()

42
crawler/panel_schema.py Normal file
View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""
前端面板完整数据 schema与 DB / situationData / useReplaySituation 对齐
爬虫 + AI 清洗后的数据必须符合此 schema 才能正确更新前端
"""
from typing import Any, Dict, List, Literal, Optional, Tuple
# 事件脉络
SITUATION_UPDATE_CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
SITUATION_UPDATE_SEVERITIES = ("low", "medium", "high", "critical")
SUMMARY_MAX_LEN = 120
# 战损
CombatLossesRow = Dict[str, Any] # bases_destroyed, bases_damaged, personnel_killed, ...
# 时间序列(回放用)
TimeSeriesPoint = Tuple[str, int] # (ISO time, value)
# AI 可从新闻中提取的字段
EXTRACTABLE_FIELDS = {
"situation_update": ["summary", "category", "severity", "timestamp"],
"combat_losses": ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"],
"retaliation": ["value"], # 0-100
"wall_street_trend": ["time", "value"], # 0-100
"conflict_stats": ["estimated_casualties", "estimated_strike_count"],
}
def validate_category(cat: str) -> str:
return cat if cat in SITUATION_UPDATE_CATEGORIES else "other"
def validate_severity(sev: str) -> str:
return sev if sev in SITUATION_UPDATE_SEVERITIES else "medium"
def validate_summary(s: str, max_len: int = SUMMARY_MAX_LEN) -> str:
import re
if not s or not isinstance(s, str):
return ""
t = re.sub(r"\s+", " ", str(s).strip())[:max_len]
return re.sub(r"[\x00-\x1f]", "", t).rstrip()

52
crawler/parser.py Normal file
View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""新闻分类与严重度判定"""
import re
from typing import Literal
Category = Literal["deployment", "alert", "intel", "diplomatic", "other"]
Severity = Literal["low", "medium", "high", "critical"]
# 分类关键词
CAT_DEPLOYMENT = ["deploy", "carrier", "航母", "military build", "troop", "forces"]
CAT_ALERT = ["strike", "attack", "fire", "blast", "hit", "爆炸", "袭击", "打击"]
CAT_INTEL = ["satellite", "intel", "image", "surveillance", "卫星", "情报"]
CAT_DIPLOMATIC = ["talk", "negotiation", "diplomat", "sanction", "谈判", "制裁"]
def _match(text: str, words: list[str]) -> bool:
t = (text or "").lower()
for w in words:
if w.lower() in t:
return True
return False
def classify(text: str) -> Category:
if _match(text, CAT_ALERT):
return "alert"
if _match(text, CAT_DEPLOYMENT):
return "deployment"
if _match(text, CAT_INTEL):
return "intel"
if _match(text, CAT_DIPLOMATIC):
return "diplomatic"
return "other"
def severity(text: str, category: Category) -> Severity:
t = (text or "").lower()
critical = [
"nuclear", "", "strike", "attack", "killed", "dead", "casualty",
"war", "invasion", "袭击", "打击", "死亡",
]
high = [
"missile", "drone", "bomb", "explosion", "blasted", "fire",
"导弹", "无人机", "爆炸", "轰炸",
]
if _match(t, critical):
return "critical"
if _match(t, high) or category == "alert":
return "high"
if category == "deployment":
return "medium"
return "low"

101
crawler/parser_ai.py Normal file
View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
AI 新闻分类与严重度判定
优先使用 Ollama 本地模型(免费),失败则回退到规则
设置 PARSER_AI_DISABLED=1 可只用规则(更快)
"""
import os
from typing import Literal, Optional, Tuple
Category = Literal["deployment", "alert", "intel", "diplomatic", "other"]
Severity = Literal["low", "medium", "high", "critical"]
PARSER_AI_DISABLED = os.environ.get("PARSER_AI_DISABLED", "0") == "1"
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1") # 或 qwen2.5:7b
_CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
_SEVERITIES = ("low", "medium", "high", "critical")
def _parse_ai_response(text: str) -> Tuple[Category, Severity]:
"""从 AI 回复解析 category:severity"""
t = (text or "").strip().lower()
cat, sev = "other", "low"
for c in _CATEGORIES:
if c in t:
cat = c
break
for s in _SEVERITIES:
if s in t:
sev = s
break
return cat, sev # type: ignore
def _call_ollama(text: str, timeout: int = 5) -> Optional[Tuple[Category, Severity]]:
"""调用 Ollama 本地模型。需先运行 ollama run llama3.1 或 qwen2.5:7b"""
if PARSER_AI_DISABLED:
return None
try:
import requests
prompt = f"""Classify this news about US-Iran/middle east (one line only):
- category: deployment|alert|intel|diplomatic|other
- severity: low|medium|high|critical
News: {text[:300]}
Reply format: category:severity (e.g. alert:high)"""
r = requests.post(
"http://localhost:11434/api/chat",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_predict": 32},
},
timeout=timeout,
)
if r.status_code != 200:
return None
out = r.json().get("message", {}).get("content", "")
return _parse_ai_response(out)
except Exception:
return None
def _rule_classify(text: str) -> Category:
from parser import classify
return classify(text)
def _rule_severity(text: str, category: Category) -> Severity:
from parser import severity
return severity(text, category)
def classify(text: str) -> Category:
"""分类。AI 失败时回退规则"""
res = _call_ollama(text)
if res:
return res[0]
return _rule_classify(text)
def severity(text: str, category: Category) -> Severity:
"""严重度。AI 失败时回退规则"""
res = _call_ollama(text)
if res:
return res[1]
return _rule_severity(text, category)
def classify_and_severity(text: str) -> Tuple[Category, Severity]:
"""一次调用返回分类和严重度(减少 AI 调用)"""
if PARSER_AI_DISABLED:
from parser import classify, severity
c = classify(text)
return c, severity(text, c)
res = _call_ollama(text)
if res:
return res
return _rule_classify(text), _rule_severity(text, _rule_classify(text))

View File

@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
"""
GDELT 实时冲突抓取 + API 服务
核心数据源GDELT Project约 15 分钟级更新,含经纬度、事件编码、参与方、事件强度
"""
import os
# 直连外网,避免系统代理导致 ProxyError / 超时(需代理时设置 CRAWLER_USE_PROXY=1
if os.environ.get("CRAWLER_USE_PROXY") != "1":
os.environ.setdefault("NO_PROXY", "*")
import hashlib
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import logging
import requests
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.background import BackgroundScheduler
logging.getLogger("apscheduler.scheduler").setLevel(logging.ERROR)
app = FastAPI(title="GDELT Conflict Service")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
# 配置
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
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")) # 每分钟抓取世界主流媒体
# 时间范围1h=1小时 1d=1天 1week=1周不设则默认 3 个月(易返回旧文)
GDELT_TIMESPAN = os.environ.get("GDELT_TIMESPAN", "1d")
# 设为 1 则跳过 GDELT仅用 RSS 新闻作为事件脉络GDELT 国外可能无法访问)
GDELT_DISABLED = os.environ.get("GDELT_DISABLED", "0") == "1"
# 伊朗攻击源(无经纬度时默认)
IRAN_COORD = [51.3890, 35.6892] # Tehran [lng, lat]
# 请求直连,不经过系统代理(避免 ProxyError / 代理超时)
_REQ_KW = {"timeout": 15, "headers": {"User-Agent": "US-Iran-Dashboard/1.0"}}
if os.environ.get("CRAWLER_USE_PROXY") != "1":
_REQ_KW["proxies"] = {"http": None, "https": None}
EVENT_CACHE: List[dict] = []
# ==========================
# 冲突强度评分 (110)
# ==========================
def calculate_impact_score(title: str) -> int:
score = 1
t = (title or "").lower()
if "missile" in t:
score += 3
if "strike" in t:
score += 2
if "killed" in t or "death" in t or "casualt" in t:
score += 4
if "troops" in t or "soldier" in t:
score += 2
if "attack" in t or "attacked" in t:
score += 3
if "nuclear" in t or "" in t:
score += 4
if "explosion" in t or "blast" in t or "bomb" in t:
score += 2
return min(score, 10)
# ==========================
# 获取 GDELT 实时事件
# ==========================
def _parse_article(article: dict) -> Optional[dict]:
title_raw = article.get("title") or article.get("seendate") or ""
if not title_raw:
return None
from translate_utils import translate_to_chinese
from cleaner_ai import clean_news_for_panel
title = translate_to_chinese(str(title_raw)[:500])
title = clean_news_for_panel(title, max_len=150)
url = article.get("url") or article.get("socialimage") or ""
seendate = article.get("seendate") or datetime.utcnow().isoformat()
lat = article.get("lat")
lng = article.get("lng")
# 无经纬度时使用伊朗坐标(攻击源)
if lat is None or lng is None:
lat, lng = IRAN_COORD[1], IRAN_COORD[0]
try:
lat, lng = float(lat), float(lng)
except (TypeError, ValueError):
lat, lng = IRAN_COORD[1], IRAN_COORD[0]
impact = calculate_impact_score(title_raw)
event_id = hashlib.sha256(f"{url}{seendate}".encode()).hexdigest()[:24]
return {
"event_id": event_id,
"event_time": seendate,
"title": title[:500],
"lat": lat,
"lng": lng,
"impact_score": impact,
"url": url,
}
def fetch_gdelt_events() -> None:
if GDELT_DISABLED:
return
url = (
"https://api.gdeltproject.org/api/v2/doc/doc"
f"?query={QUERY}"
"&mode=ArtList"
"&format=json"
f"&maxrecords={MAX_RECORDS}"
f"&timespan={GDELT_TIMESPAN}"
"&sort=datedesc"
)
try:
resp = requests.get(url, **_REQ_KW)
resp.raise_for_status()
data = resp.json()
articles = data.get("articles", data) if isinstance(data, dict) else (data if isinstance(data, list) else [])
if not isinstance(articles, list):
articles = []
new_events = []
for a in articles:
ev = _parse_article(a) if isinstance(a, dict) else None
if ev:
new_events.append(ev)
# 按 event_time 排序,最新在前
new_events.sort(key=lambda e: e.get("event_time", ""), reverse=True)
global EVENT_CACHE
EVENT_CACHE = new_events
# 写入 SQLite 并通知 Node
_write_to_db(new_events)
_notify_node()
print(f"[{datetime.now().strftime('%H:%M:%S')}] GDELT 更新 {len(new_events)} 条事件")
except Exception:
pass
def _ensure_table(conn: sqlite3.Connection) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS gdelt_events (
event_id TEXT PRIMARY KEY,
event_time TEXT NOT NULL,
title TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
impact_score INTEGER NOT NULL,
url TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS conflict_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_events INTEGER NOT NULL,
high_impact_events INTEGER NOT NULL,
estimated_casualties INTEGER NOT NULL,
estimated_strike_count INTEGER NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.commit()
def _write_to_db(events: List[dict]) -> None:
if not os.path.exists(DB_PATH):
return
conn = sqlite3.connect(DB_PATH, timeout=10)
try:
_ensure_table(conn)
for e in events:
conn.execute(
"INSERT OR REPLACE INTO gdelt_events (event_id, event_time, title, lat, lng, impact_score, url) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
e["event_id"],
e.get("event_time", ""),
e.get("title", ""),
e.get("lat", 0),
e.get("lng", 0),
e.get("impact_score", 1),
e.get("url", ""),
),
)
# 战损统计模型(展示用)
high = sum(1 for x in events if x.get("impact_score", 0) >= 7)
strikes = sum(1 for x in events if "strike" in (x.get("title") or "").lower() or "attack" in (x.get("title") or "").lower())
casualties = min(5000, high * 80 + len(events) * 10) # 估算
conn.execute(
"INSERT OR REPLACE INTO conflict_stats (id, total_events, high_impact_events, estimated_casualties, estimated_strike_count, updated_at) VALUES (1, ?, ?, ?, ?, ?)",
(len(events), high, casualties, strikes, datetime.utcnow().isoformat()),
)
conn.execute(
"INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)",
(datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),),
)
conn.commit()
except Exception as e:
print(f"写入 DB 失败: {e}")
conn.rollback()
finally:
conn.close()
def _notify_node() -> None:
try:
r = requests.post(f"{API_BASE}/api/crawler/notify", timeout=5, proxies={"http": None, "https": None})
if r.status_code != 200:
print(" [warn] notify API 失败")
except Exception as e:
print(f" [warn] notify API: {e}")
# ==========================
# RSS 新闻抓取(补充 situation_update + AI 提取面板数据)
# ==========================
LAST_FETCH = {"items": 0, "inserted": 0, "error": None}
def fetch_news() -> None:
try:
from scrapers.rss_scraper import fetch_all
from db_writer import write_updates
from translate_utils import translate_to_chinese
from cleaner_ai import clean_news_for_panel
from cleaner_ai import ensure_category, ensure_severity
LAST_FETCH["error"] = None
items = fetch_all()
for it in items:
raw_title = translate_to_chinese(it.get("title", "") or "")
raw_summary = translate_to_chinese(it.get("summary", "") or it.get("title", ""))
it["title"] = clean_news_for_panel(raw_title, max_len=80)
it["summary"] = clean_news_for_panel(raw_summary or raw_title, max_len=120)
it["category"] = ensure_category(it.get("category", "other"))
it["severity"] = ensure_severity(it.get("severity", "medium"))
n = write_updates(items) if items else 0
LAST_FETCH["items"] = len(items)
LAST_FETCH["inserted"] = n
if items:
_extract_and_merge_panel_data(items)
if n > 0:
_notify_node()
print(f"[{datetime.now().strftime('%H:%M:%S')}] RSS 抓取 {len(items)} 条,新增入库 {n}")
except Exception as e:
LAST_FETCH["error"] = str(e)
print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻抓取失败: {e}")
def _extract_and_merge_panel_data(items: list) -> None:
"""对新闻做 AI/规则 提取,合并到 combat_losses / retaliation / wall_street_trend 等表"""
if not items or not os.path.exists(DB_PATH):
return
try:
from db_merge import merge
if os.environ.get("CLEANER_AI_DISABLED", "0") == "1":
from extractor_rules import extract_from_news
else:
from extractor_ai import extract_from_news
from datetime import timezone
merged_any = False
# 只对前几条有足够文本的新闻做提取,避免 Ollama 调用过多
for it in items[:5]:
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
if len(text.strip()) < 20:
continue
pub = it.get("published")
ts = None
if pub:
try:
if isinstance(pub, str):
pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
else:
pub_dt = pub
if pub_dt.tzinfo:
pub_dt = pub_dt.astimezone(timezone.utc)
ts = pub_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
except Exception:
pass
extracted = extract_from_news(text, timestamp=ts)
if extracted:
if merge(extracted, db_path=DB_PATH):
merged_any = True
if merged_any:
_notify_node()
except Exception as e:
print(f" [warn] AI 面板数据提取/合并: {e}")
# ==========================
# 定时任务RSS 更频繁,优先保证事件脉络实时)
# ==========================
scheduler = BackgroundScheduler()
scheduler.add_job(fetch_news, "interval", seconds=RSS_INTERVAL_SEC, max_instances=2, coalesce=True)
scheduler.add_job(fetch_gdelt_events, "interval", seconds=FETCH_INTERVAL_SEC, max_instances=2, coalesce=True)
scheduler.start()
# ==========================
# API 接口
# ==========================
@app.get("/crawler/status")
def crawler_status():
"""爬虫状态:用于排查数据更新链路"""
import os
db_ok = os.path.exists(DB_PATH)
total = 0
if db_ok:
try:
conn = sqlite3.connect(DB_PATH, timeout=3)
total = conn.execute("SELECT COUNT(*) FROM situation_update").fetchone()[0]
conn.close()
except Exception:
pass
return {
"db_path": DB_PATH,
"db_exists": db_ok,
"situation_update_count": total,
"last_fetch_items": LAST_FETCH.get("items", 0),
"last_fetch_inserted": LAST_FETCH.get("inserted", 0),
"last_fetch_error": LAST_FETCH.get("error"),
}
@app.get("/events")
def get_events():
return {
"updated_at": datetime.utcnow().isoformat(),
"count": len(EVENT_CACHE),
"events": EVENT_CACHE,
"conflict_stats": _get_conflict_stats(),
}
def _get_conflict_stats() -> dict:
if not os.path.exists(DB_PATH):
return {"total_events": 0, "high_impact_events": 0, "estimated_casualties": 0, "estimated_strike_count": 0}
try:
conn = sqlite3.connect(DB_PATH, timeout=5)
row = conn.execute("SELECT total_events, high_impact_events, estimated_casualties, estimated_strike_count FROM conflict_stats WHERE id = 1").fetchone()
conn.close()
if row:
return {
"total_events": row[0],
"high_impact_events": row[1],
"estimated_casualties": row[2],
"estimated_strike_count": row[3],
}
except Exception:
pass
return {"total_events": 0, "high_impact_events": 0, "estimated_casualties": 0, "estimated_strike_count": 0}
@app.on_event("startup")
def startup():
# 新闻优先启动,确保事件脉络有数据
fetch_news()
fetch_gdelt_events()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

6
crawler/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
requests>=2.31.0
feedparser>=6.0.0
fastapi>=0.109.0
uvicorn>=0.27.0
apscheduler>=3.10.0
deep-translator>=1.11.0

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""RSS 抓取"""
import re
from datetime import datetime, timezone
import feedparser
from config import RSS_FEEDS, KEYWORDS
from parser_ai import classify_and_severity
def _parse_date(entry) -> datetime:
for attr in ("published_parsed", "updated_parsed"):
val = getattr(entry, attr, None)
if val:
try:
return datetime(*val[:6], tzinfo=timezone.utc)
except (TypeError, ValueError):
pass
return datetime.now(timezone.utc)
def _strip_html(s: str) -> str:
return re.sub(r"<[^>]+>", "", s) if s else ""
def _matches_keywords(text: str) -> bool:
t = (text or "").lower()
for k in KEYWORDS:
if k.lower() in t:
return True
return False
def fetch_all() -> list[dict]:
import socket
items: list[dict] = []
seen: set[str] = set()
# 单源超时 10 秒,避免某源卡住
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(10)
try:
for url in RSS_FEEDS:
try:
feed = feedparser.parse(
url,
request_headers={"User-Agent": "US-Iran-Dashboard/1.0"},
agent="US-Iran-Dashboard/1.0",
)
except Exception:
continue
for entry in feed.entries:
title = getattr(entry, "title", "") or ""
raw_summary = getattr(entry, "summary", "") or getattr(entry, "description", "") or ""
summary = _strip_html(raw_summary)
link = getattr(entry, "link", "") or ""
text = f"{title} {summary}"
if not _matches_keywords(text):
continue
key = (title[:80], link)
if key in seen:
continue
seen.add(key)
published = _parse_date(entry)
cat, sev = classify_and_severity(text)
items.append({
"title": title,
"summary": summary[:400] if summary else title,
"url": link,
"published": _parse_date(entry),
"category": cat,
"severity": sev,
})
finally:
socket.setdefaulttimeout(old_timeout)
return items

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""英译中,入库前统一翻译"""
import os
import re
from typing import Optional
def _is_mostly_chinese(text: str) -> bool:
if not text or len(text.strip()) < 2:
return False
chinese = len(re.findall(r"[\u4e00-\u9fff]", text))
return chinese / max(len(text), 1) > 0.3
def translate_to_chinese(text: str) -> str:
"""将文本翻译成中文失败或已是中文则返回原文。Google 失败时尝试 MyMemory。"""
if not text or not text.strip():
return text
if os.environ.get("TRANSLATE_DISABLED", "0") == "1":
return text
s = str(text).strip()
if len(s) > 2000:
s = s[:2000]
if _is_mostly_chinese(s):
return text
for translator in ["google", "mymemory"]:
try:
if translator == "google":
from deep_translator import GoogleTranslator
out = GoogleTranslator(source="auto", target="zh-CN").translate(s)
else:
from deep_translator import MyMemoryTranslator
out = MyMemoryTranslator(source="auto", target="zh-CN").translate(s)
if out and out.strip() and out != s:
return out
except Exception:
continue
return text

91
docs/BACKEND_MODULES.md Normal file
View File

@@ -0,0 +1,91 @@
# 后端模块说明
## 一、现有模块结构
```
server/
├── index.js # HTTP + WebSocket 入口
├── routes.js # REST API 路由
├── db.js # SQLite schema 与连接
├── situationData.js # 态势数据聚合 (从 DB 读取)
├── seed.js # 初始数据填充
├── data.db # SQLite 数据库
└── package.json
crawler/
├── realtime_conflict_service.py # GDELT 实时冲突服务 (核心)
├── requirements.txt
├── config.py, db_writer.py # 旧 RSS 爬虫(可保留)
├── main.py
└── README.md
```
### 1. server/index.js
- Express + CORS
- WebSocket (`/ws`),每 5 秒广播 `situation`
- `POST /api/crawler/notify`:爬虫写入后触发立即广播
### 2. server/routes.js
- `GET /api/situation`:完整态势
- `GET /api/events`GDELT 事件 + 冲突统计
- `GET /api/health`:健康检查
### 3. server/db.js
- 表:`situation``force_summary``power_index``force_asset`
`key_location``combat_losses``wall_street_trend`
`retaliation_current``retaliation_history``situation_update`
**`gdelt_events`**、**`conflict_stats`**
---
## 二、GDELT 核心数据源
**GDELT Project**:全球冲突数据库,约 15 分钟级更新,含经纬度、事件编码、参与方、事件强度。
### realtime_conflict_service.py
- 定时(默认 60 秒)从 GDELT API 抓取
- 冲突强度评分missile +3, strike +2, killed +4 等
- 无经纬度时默认攻击源:`IRAN_COORD = [51.3890, 35.6892]`
- 写入 `gdelt_events``conflict_stats`
- 调用 `POST /api/crawler/notify` 触发 Node 广播
### 冲突强度 → 地图效果
| impact_score | 效果 |
|--------------|------------|
| 13 | 绿色点 |
| 46 | 橙色闪烁 |
| 710 | 红色脉冲扩散 |
### 战损统计模型(展示用)
- `total_events`
- `high_impact_events` (impact ≥ 7)
- `estimated_casualties`
- `estimated_strike_count`
---
## 三、数据流
```
GDELT API → Python 服务(60s) → gdelt_events, conflict_stats
POST /api/crawler/notify → situation.updated_at
WebSocket 广播 getSituation() → 前端
```
---
## 四、运行方式
```bash
# 1. 启动 Node API
npm run api
# 2. 启动 GDELT 服务
npm run gdelt
# 或: cd crawler && uvicorn realtime_conflict_service:app --port 8000
```

54
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-map-gl": "^7.1.7",
"react-router-dom": "^7.13.1",
"ws": "^8.19.0",
"zustand": "^5.0.0"
},
@@ -4396,6 +4397,54 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
@@ -4645,6 +4694,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/set-value/-/set-value-2.0.1.tgz",

View File

@@ -4,9 +4,13 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "./start.sh",
"dev": "vite",
"api": "node server/index.js",
"api:seed": "node server/seed.js",
"crawler": "cd crawler && python main.py",
"gdelt": "cd crawler && uvicorn realtime_conflict_service:app --host 0.0.0.0 --port 8000",
"crawler:test": "cd crawler && python3 -c \"import sys; sys.path.insert(0,'.'); from scrapers.rss_scraper import fetch_all; n=len(fetch_all()); print('RSS 抓取:', n, '条' if n else '(0 条,检查网络或关键词过滤)')\"",
"build": "vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
@@ -23,6 +27,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-map-gl": "^7.1.7",
"react-router-dom": "^7.13.1",
"ws": "^8.19.0",
"zustand": "^5.0.0"
},

Binary file not shown.

Binary file not shown.

View File

@@ -92,6 +92,26 @@ db.exec(`
summary TEXT NOT NULL,
severity TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS gdelt_events (
event_id TEXT PRIMARY KEY,
event_time TEXT NOT NULL,
title TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
impact_score INTEGER NOT NULL,
url TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS conflict_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_events INTEGER NOT NULL DEFAULT 0,
high_impact_events INTEGER NOT NULL DEFAULT 0,
estimated_casualties INTEGER NOT NULL DEFAULT 0,
estimated_strike_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
`)
// 迁移:为已有 key_location 表添加 type、region、status、damage_level 列
@@ -103,5 +123,28 @@ try {
if (!names.includes('status')) db.exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
if (!names.includes('damage_level')) db.exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
} catch (_) {}
// 迁移combat_losses 添加平民伤亡、updated_at
try {
const lossCols = db.prepare('PRAGMA table_info(combat_losses)').all()
const lossNames = lossCols.map((c) => c.name)
if (!lossNames.includes('civilian_killed')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('civilian_wounded')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0')
if (!lossNames.includes('updated_at')) db.exec('ALTER TABLE combat_losses ADD COLUMN updated_at TEXT DEFAULT (datetime("now"))')
} catch (_) {}
// 迁移:所有表添加 updated_at 用于数据回放
const addUpdatedAt = (table) => {
try {
const cols = db.prepare(`PRAGMA table_info(${table})`).all()
if (!cols.some((c) => c.name === 'updated_at')) {
db.exec(`ALTER TABLE ${table} ADD COLUMN updated_at TEXT DEFAULT (datetime("now"))`)
}
} catch (_) {}
}
addUpdatedAt('force_summary')
addUpdatedAt('power_index')
addUpdatedAt('force_asset')
addUpdatedAt('key_location')
addUpdatedAt('retaliation_current')
module.exports = db

View File

@@ -12,6 +12,10 @@ app.use(cors())
app.use(express.json())
app.use('/api', routes)
app.get('/api/health', (_, res) => res.json({ ok: true }))
app.post('/api/crawler/notify', (_, res) => {
notifyCrawlerUpdate()
res.json({ ok: true })
})
const server = http.createServer(app)
@@ -27,7 +31,16 @@ function broadcastSituation() {
})
} catch (_) {}
}
setInterval(broadcastSituation, 5000)
setInterval(broadcastSituation, 3000)
// 供爬虫调用:更新 situation.updated_at 并立即广播
function notifyCrawlerUpdate() {
try {
const db = require('./db')
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
broadcastSituation()
} catch (_) {}
}
server.listen(PORT, () => {
console.log(`API + WebSocket running at http://localhost:${PORT}`)

View File

@@ -1,8 +1,58 @@
const express = require('express')
const { getSituation } = require('./situationData')
const db = require('./db')
const router = express.Router()
// 数据库 Dashboard返回各表原始数据
router.get('/db/dashboard', (req, res) => {
try {
const tables = [
'situation',
'force_summary',
'power_index',
'force_asset',
'key_location',
'combat_losses',
'wall_street_trend',
'retaliation_current',
'retaliation_history',
'situation_update',
'gdelt_events',
'conflict_stats',
]
const data = {}
const timeSort = {
situation: 'updated_at DESC',
situation_update: 'timestamp DESC',
gdelt_events: 'event_time DESC',
wall_street_trend: 'time DESC',
retaliation_history: 'time DESC',
conflict_stats: 'updated_at DESC',
}
for (const name of tables) {
try {
const order = timeSort[name]
let rows
try {
rows = order
? db.prepare(`SELECT * FROM ${name} ORDER BY ${order}`).all()
: db.prepare(`SELECT * FROM ${name}`).all()
} catch (qerr) {
rows = db.prepare(`SELECT * FROM ${name}`).all()
}
data[name] = rows
} catch (e) {
data[name] = { error: e.message }
}
}
res.json(data)
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
router.get('/situation', (req, res) => {
try {
res.json(getSituation())
@@ -12,4 +62,19 @@ router.get('/situation', (req, res) => {
}
})
router.get('/events', (req, res) => {
try {
const s = getSituation()
res.json({
updated_at: s.lastUpdated,
count: (s.conflictEvents || []).length,
events: s.conflictEvents || [],
conflict_stats: s.conflictStats || {},
})
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
module.exports = router

View File

@@ -140,11 +140,19 @@ function seed() {
]
iranLocs.forEach((r) => insertLoc.run(...r))
db.exec(`
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, aircraft, warships, armor, vehicles) VALUES
('us', 0, 27, 127, 384, 2, 0, 0, 8),
('iran', 3, 8, 2847, 5620, 24, 12, 18, 42);
`)
try {
db.exec(`
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles) VALUES
('us', 0, 27, 127, 384, 18, 52, 2, 0, 0, 8),
('iran', 3, 8, 2847, 5620, 412, 1203, 24, 12, 18, 42);
`)
} catch (_) {
db.exec(`
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, aircraft, warships, armor, vehicles) VALUES
('us', 0, 27, 127, 384, 2, 0, 0, 8),
('iran', 3, 8, 2847, 5620, 24, 12, 18, 42);
`)
}
db.exec('DELETE FROM wall_street_trend')
const trendRows = [['2025-03-01T00:00:00', 82], ['2025-03-01T03:00:00', 85], ['2025-03-01T06:00:00', 88], ['2025-03-01T09:00:00', 90], ['2025-03-01T12:00:00', 92], ['2025-03-01T15:00:00', 94], ['2025-03-01T18:00:00', 95], ['2025-03-01T21:00:00', 96], ['2025-03-01T23:00:00', 98]]

View File

@@ -15,6 +15,7 @@ function toLosses(row) {
return {
bases: { destroyed: row.bases_destroyed, damaged: row.bases_damaged },
personnelCasualties: { killed: row.personnel_killed, wounded: row.personnel_wounded },
civilianCasualties: { killed: row.civilian_killed ?? 0, wounded: row.civilian_wounded ?? 0 },
aircraft: row.aircraft,
warships: row.warships,
armor: row.armor,
@@ -25,6 +26,7 @@ function toLosses(row) {
const defaultLosses = {
bases: { destroyed: 0, damaged: 0 },
personnelCasualties: { killed: 0, wounded: 0 },
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: 0,
warships: 0,
armor: 0,
@@ -45,9 +47,35 @@ function getSituation() {
const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get()
const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all()
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC').all()
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
let conflictEvents = []
let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }
try {
conflictEvents = db.prepare('SELECT event_id, event_time, title, lat, lng, impact_score, url FROM gdelt_events ORDER BY event_time DESC LIMIT 30').all()
const statsRow = db.prepare('SELECT total_events, high_impact_events, estimated_casualties, estimated_strike_count FROM conflict_stats WHERE id = 1').get()
if (statsRow) conflictStats = statsRow
} catch (_) {}
// 平民伤亡:合计显示,不区分阵营
const civUsK = lossesUs?.civilian_killed ?? 0
const civUsW = lossesUs?.civilian_wounded ?? 0
const civIrK = lossesIr?.civilian_killed ?? 0
const civIrW = lossesIr?.civilian_wounded ?? 0
const dbKilled = civUsK + civIrK
const dbWounded = civUsW + civIrW
const est = conflictStats.estimated_casualties || 0
const civilianCasualtiesTotal = {
killed: est > 0 ? Math.max(dbKilled, est) : dbKilled,
wounded: dbWounded,
}
const usLossesBase = lossesUs ? toLosses(lossesUs) : defaultLosses
const irLossesBase = lossesIr ? toLosses(lossesIr) : defaultLosses
const usLosses = { ...usLossesBase, civilianCasualties: { killed: 0, wounded: 0 } }
const irLosses = { ...irLossesBase, civilianCasualties: { killed: 0, wounded: 0 } }
return {
lastUpdated: meta?.updated_at || new Date().toISOString(),
usForces: {
@@ -69,7 +97,7 @@ function getSituation() {
},
assets: (assetsUs || []).map(toAsset),
keyLocations: locUs || [],
combatLosses: lossesUs ? toLosses(lossesUs) : defaultLosses,
combatLosses: usLosses,
wallStreetInvestmentTrend: trend || [],
},
iranForces: {
@@ -91,7 +119,7 @@ function getSituation() {
},
assets: (assetsIr || []).map(toAsset),
keyLocations: locIr || [],
combatLosses: lossesIr ? toLosses(lossesIr) : defaultLosses,
combatLosses: irLosses,
retaliationSentiment: retaliationCur?.value ?? 0,
retaliationSentimentHistory: retaliationHist || [],
},
@@ -102,6 +130,17 @@ function getSituation() {
summary: u.summary,
severity: u.severity,
})),
conflictEvents: conflictEvents.map((e) => ({
event_id: e.event_id,
event_time: e.event_time,
title: e.title,
lat: e.lat,
lng: e.lng,
impact_score: e.impact_score,
url: e.url,
})),
conflictStats,
civilianCasualtiesTotal,
}
}

View File

@@ -1,4 +1,6 @@
import { Routes, Route } from 'react-router-dom'
import { Dashboard } from '@/pages/Dashboard'
import { DbDashboard } from '@/pages/DbDashboard'
function App() {
return (
@@ -6,7 +8,10 @@ function App() {
className="min-h-screen w-full bg-military-dark overflow-hidden"
style={{ background: '#0A0F1C' }}
>
<Dashboard />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/db" element={<DbDashboard />} />
</Routes>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import type { MilitarySituation } from '@/data/mockData'
export async function fetchSituation(): Promise<MilitarySituation> {
const res = await fetch('/api/situation')
const res = await fetch(`/api/situation?t=${Date.now()}`, { cache: 'no-store' })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}

View File

@@ -1,6 +1,5 @@
import {
Building2,
Users,
Skull,
Bandage,
Plane,
@@ -8,108 +7,110 @@ import {
Shield,
Car,
TrendingDown,
UserCircle,
Activity,
} from 'lucide-react'
import { formatMillions } from '@/utils/formatNumber'
import type { CombatLosses } from '@/data/mockData'
import type { CombatLosses, ConflictStats } from '@/data/mockData'
interface CombatLossesPanelProps {
usLosses: CombatLosses
iranLosses: CombatLosses
conflictStats?: ConflictStats | null
/** 平民伤亡合计(不区分阵营) */
civilianTotal?: { killed: number; wounded: number }
className?: string
}
const LOSS_ITEMS: {
key: keyof Omit<CombatLosses, 'bases' | 'personnelCasualties'>
label: string
icon: typeof Plane
iconColor: string
}[] = [
{ key: 'aircraft', label: '战', icon: Plane, iconColor: 'text-sky-400' },
{ key: 'warships', label: '战舰', icon: Ship, iconColor: 'text-blue-500' },
{ key: 'armor', label: '装甲', icon: Shield, iconColor: 'text-emerald-500' },
{ key: 'vehicles', label: '车辆', icon: Car, iconColor: 'text-slate-400' },
]
export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilianTotal, className = '' }: CombatLossesPanelProps) {
const civ = civilianTotal ?? { killed: 0, wounded: 0 }
const otherRows = [
{ label: '基地', icon: Building2, iconColor: 'text-amber-500', us: `${usLosses.bases.destroyed}/${usLosses.bases.damaged}`, ir: `${iranLosses.bases.destroyed}/${iranLosses.bases.damaged}` },
{ label: '战机', icon: Plane, iconColor: 'text-sky-400', us: usLosses.aircraft, ir: iranLosses.aircraft },
{ label: '战', icon: Ship, iconColor: 'text-blue-500', us: usLosses.warships, ir: iranLosses.warships },
{ label: '装甲', icon: Shield, iconColor: 'text-emerald-500', us: usLosses.armor, ir: iranLosses.armor },
{ label: '车辆', icon: Car, iconColor: 'text-slate-400', us: usLosses.vehicles, ir: iranLosses.vehicles },
]
export function CombatLossesPanel({ usLosses, iranLosses, className = '' }: CombatLossesPanelProps) {
return (
<div
className={`
min-w-0 shrink-0 overflow-x-auto overflow-y-hidden border-t border-military-border scrollbar-thin bg-military-panel/95 px-4 py-2 font-orbitron
${className}
`}
>
<div className="mb-2 flex items-center gap-1 text-[10px] uppercase tracking-wider text-military-text-secondary">
<div className={`flex min-h-[220px] max-h-[240px] min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/95 font-orbitron ${className}`}>
<div className="mb-1.5 flex shrink-0 items-center justify-center gap-2 text-[10px] uppercase tracking-wider text-military-text-secondary">
<TrendingDown className="h-2.5 w-2.5 shrink-0 text-amber-400" />
{conflictStats && conflictStats.total_events > 0 && (
<span className="flex items-center gap-0.5 rounded bg-cyan-950/50 px-1 py-0.5 text-[9px] text-cyan-400">
<Activity className="h-2 w-2" />
{conflictStats.total_events}
</span>
)}
</div>
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-3 overflow-x-auto text-xs">
{/* 基地 */}
<div className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
<Building2 className="h-3 w-3 shrink-0 text-amber-500" />
</span>
<div className="flex flex-col gap-0.5 tabular-nums">
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`美 毁${usLosses.bases.destroyed}${usLosses.bases.damaged}`}>
<span className="shrink-0 text-military-us"></span>
<span className="truncate">
<strong className="text-amber-400">{usLosses.bases.destroyed}</strong>
<strong className="text-amber-300">{usLosses.bases.damaged}</strong>
</span>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-3 pb-2">
{/* 人员伤亡 - 单独容器 */}
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2">
<div className="mb-1 flex shrink-0 items-center justify-center gap-3 text-[9px] text-military-text-secondary">
<span className="flex items-center gap-0.5"><Skull className="h-3 w-3 text-red-500" /> </span>
<span className="flex items-center gap-0.5"><Bandage className="h-3 w-3 text-amber-500" /> </span>
<span className="text-military-text-secondary/70">|</span>
<span> : </span>
</div>
<div className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-1 overflow-hidden text-center tabular-nums sm:gap-x-4">
<div className="min-w-0 truncate text-military-us" title={`美: ${formatMillions(usLosses.personnelCasualties.killed)} / ${formatMillions(usLosses.personnelCasualties.wounded)}`}>
<span className="text-base font-bold text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</span>
<span className="mx-0.5 text-military-text-secondary">/</span>
<span className="text-base font-semibold text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</span>
</div>
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`${iranLosses.bases.destroyed} ${iranLosses.bases.damaged}`}>
<span className="shrink-0 text-military-iran"></span>
<span className="truncate">
<strong className="text-amber-400">{iranLosses.bases.destroyed}</strong>
<strong className="text-amber-300">{iranLosses.bases.damaged}</strong>
</span>
<div className="min-w-0 truncate text-military-iran" title={`: ${formatMillions(iranLosses.personnelCasualties.killed)} / ${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
<span className="text-base font-bold text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</span>
<span className="mx-0.5 text-military-text-secondary">/</span>
<span className="text-base font-semibold text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</span>
</div>
</div>
</div>
{/* 人员伤亡 */}
<div className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
<Users className="h-3 w-3 shrink-0 text-slate-400" />
</span>
<div className="flex flex-col gap-0.5 tabular-nums">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1" title={`美 阵亡${formatMillions(usLosses.personnelCasualties.killed)} 受伤${formatMillions(usLosses.personnelCasualties.wounded)}`}>
<span className="shrink-0 text-military-us"></span>
<Skull className="h-2.5 w-2.5 shrink-0 text-red-500" />
<strong className="text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</strong>
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-500" />
<strong className="text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</strong>
</div>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1" title={`伊 阵亡${formatMillions(iranLosses.personnelCasualties.killed)} 受伤${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
<span className="shrink-0 text-military-iran"></span>
<Skull className="h-2.5 w-2.5 shrink-0 text-red-500" />
<strong className="text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</strong>
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-500" />
<strong className="text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</strong>
</div>
{/* 平民伤亡:合计显示,不区分阵营 */}
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2">
<div className="mb-1 flex shrink-0 items-center justify-center gap-2 text-[9px] text-military-text-secondary">
<UserCircle className="h-3 w-3 text-amber-400" />
</div>
</div>
{/* 战机 / 战舰 / 装甲 / 车辆 */}
{LOSS_ITEMS.map(({ key, label, icon: Icon, iconColor }) => (
<div key={key} className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
<Icon className={`h-3 w-3 shrink-0 ${iconColor}`} />
{label}
<div className="flex items-center justify-center gap-3 text-center tabular-nums">
<span className="flex items-center gap-0.5">
<Skull className="h-3 w-3 text-red-500" />
<span className="text-base font-bold text-red-500">{formatMillions(civ.killed)}</span>
</span>
<span className="text-military-text-secondary/60">/</span>
<span className="flex items-center gap-0.5">
<Bandage className="h-3 w-3 text-amber-500" />
<span className="text-base font-semibold text-amber-500">{formatMillions(civ.wounded)}</span>
</span>
<div className="flex flex-col gap-0.5 tabular-nums">
<div className="flex min-w-0 items-baseline gap-1">
<span className="shrink-0 text-military-us"></span>
<strong>{usLosses[key]}</strong>
</div>
<div className="flex min-w-0 items-baseline gap-1">
<span className="shrink-0 text-military-iran"></span>
<strong>{iranLosses[key]}</strong>
</div>
</div>
</div>
))}
</div>
{/* 其它 - 标签+图标+数字,单独容器 */}
<div className="min-h-0 min-w-0 flex-1 overflow-hidden rounded border border-military-border/50 bg-military-dark/30 px-2 py-1.5">
<div className="mb-1 text-[8px] text-military-text-secondary">:</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 overflow-hidden text-[11px] tabular-nums lg:grid-cols-3">
{otherRows.map(({ label, icon: Icon, iconColor, ...rest }, i) => (
<div key={i} className="flex min-w-0 items-center justify-between gap-1 overflow-hidden">
<span className="flex shrink-0 items-center gap-0.5 text-military-text-primary">
<Icon className={`h-3 w-3 ${iconColor}`} />
{label}
</span>
{'value' in rest ? (
<span className="min-w-0 truncate text-right text-amber-400">{rest.value}</span>
) : (
<span className="min-w-0 truncate text-right">
<span className="text-military-us">{rest.us}</span>
<span className="text-military-text-secondary/60">:</span>
<span className="text-military-iran">{rest.ir}</span>
</span>
)}
</div>
))}
</div>
</div>
</div>
</div>
)

View File

@@ -0,0 +1,105 @@
import * as React from 'react'
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
import { History, RefreshCw } from 'lucide-react'
import { fetchAndSetSituation } from '@/store/situationStore'
interface EventTimelinePanelProps {
updates: SituationUpdate[]
conflictEvents?: ConflictEvent[]
className?: string
}
const CAT_LABELS: Record<string, string> = {
deployment: '部署',
alert: '警报',
intel: '情报',
diplomatic: '外交',
other: '其他',
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
}
type TimelineItem = {
id: string
summary: string
timestamp: string
source: 'gdelt' | 'rss'
category?: string
severity?: string
}
export function EventTimelinePanel({ updates = [], conflictEvents = [], className = '' }: EventTimelinePanelProps) {
const [refreshing, setRefreshing] = React.useState(false)
const handleRefresh = React.useCallback(async () => {
setRefreshing(true)
await fetchAndSetSituation().finally(() => setRefreshing(false))
}, [])
// 合并 GDELT + RSS按时间倒序最新在前
const merged: TimelineItem[] = [
...(conflictEvents || []).map((e) => ({
id: e.event_id,
summary: e.title,
timestamp: e.event_time,
source: 'gdelt' as const,
category: 'alert',
severity: e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low',
})),
...(updates || []).map((u) => ({
id: u.id,
summary: u.summary,
timestamp: u.timestamp,
source: 'rss' as const,
category: u.category,
severity: u.severity,
})),
]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 8)
return (
<div className={`flex min-w-0 max-w-[280px] shrink flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
<div className="flex shrink-0 items-center justify-between border-b border-military-border px-3 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
<History className="h-3.5 w-3.5 shrink-0 text-military-accent" />
</span>
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-1 rounded p-0.5 text-[8px] text-military-text-secondary/80 hover:bg-military-border/30 hover:text-cyan-400 disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="min-h-0 max-h-[140px] flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
{merged.length === 0 ? (
<p className="py-4 text-center text-[11px] text-military-text-secondary"></p>
) : (
<ul className="space-y-2">
{merged.map((item) => (
<li key={item.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
<span className="shrink-0 pt-0.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${item.source === 'gdelt' ? 'bg-cyan-500' : 'bg-amber-500'}`} />
</span>
<div className="min-w-0 flex-1 text-right">
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{item.summary}</p>
<span className="mt-0.5 flex items-center justify-end gap-1 text-[9px] text-military-text-secondary">
{formatTime(item.timestamp)}
<span className="text-military-text-secondary/60">
{item.source === 'gdelt' ? 'GDELT' : CAT_LABELS[item.category ?? ''] ?? '新闻'}
</span>
</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -1,10 +1,15 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore'
import { Wifi, WifiOff, Clock } from 'lucide-react'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore'
import { Wifi, WifiOff, Clock, Database } from 'lucide-react'
export function HeaderPanel() {
const { situation, isConnected } = useSituationStore()
const situation = useReplaySituation()
const isConnected = useSituationStore((s) => s.isConnected)
const isReplayMode = usePlaybackStore((s) => s.isReplayMode)
const { usForces, iranForces } = situation
const [now, setNow] = useState(() => new Date())
@@ -47,14 +52,21 @@ export function HeaderPanel() {
<Clock className="h-4 w-4 shrink-0" />
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
</div>
{isConnected && (
<span className="text-[10px] text-green-500/90">
{formatDataTime(situation.lastUpdated)} ()
{(isConnected || isReplayMode) && (
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-3">
<Link
to="/db"
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
>
<Database className="h-3 w-3" />
</Link>
{isConnected ? (
<>
<Wifi className="h-3.5 w-3.5 text-green-500" />

View File

@@ -0,0 +1,71 @@
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
import { Newspaper, AlertTriangle } from 'lucide-react'
interface RecentUpdatesPanelProps {
updates: SituationUpdate[]
conflictEvents?: ConflictEvent[]
className?: string
}
const CAT_LABELS: Record<string, string> = {
deployment: '部署',
alert: '警报',
intel: '情报',
diplomatic: '外交',
other: '其他',
}
const SEV_COLORS: Record<string, string> = {
low: 'text-military-text-secondary',
medium: 'text-amber-400',
high: 'text-orange-500',
critical: 'text-red-500',
}
function formatTime(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export function RecentUpdatesPanel({ updates, conflictEvents = [], className = '' }: RecentUpdatesPanelProps) {
// 优先展示 GDELT 冲突事件(最新 10 条),无则用 updates
const fromConflict = (conflictEvents || [])
.slice(0, 10)
.map((e) => ({ id: e.event_id, summary: e.title, timestamp: e.event_time, category: 'alert' as const, severity: (e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low') as const }))
const list = fromConflict.length > 0 ? fromConflict : (updates || []).slice(0, 8)
return (
<div className={`flex min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
<div className="flex shrink-0 items-center gap-1.5 border-b border-military-border px-3 py-1.5">
<Newspaper className="h-3.5 w-3.5 shrink-0 text-military-accent" />
<span className="truncate text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
{list.length === 0 ? (
<p className="py-4 text-center text-[11px] text-military-text-secondary"></p>
) : (
<ul className="space-y-2">
{list.map((u) => (
<li key={u.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
<span className={`shrink-0 text-[9px] ${SEV_COLORS[u.severity] ?? 'text-military-text-secondary'}`}>
{u.severity === 'critical' && <AlertTriangle className="inline h-2.5 w-2.5" />}
{CAT_LABELS[u.category] ?? u.category}
</span>
<div className="min-w-0 flex-1 text-right">
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{u.summary}</p>
<span className="mt-0.5 block text-[9px] text-military-text-secondary">{formatTime(u.timestamp)}</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef } from 'react'
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
function formatTick(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
export function TimelinePanel() {
const {
isReplayMode,
playbackTime,
isPlaying,
speedSecPerTick,
setReplayMode,
setPlaybackTime,
setIsPlaying,
stepForward,
stepBack,
setSpeed,
} = usePlaybackStore()
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!isPlaying || !isReplayMode) {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
return
}
timerRef.current = setInterval(() => {
const current = usePlaybackStore.getState().playbackTime
const i = REPLAY_TICKS.indexOf(current)
if (i >= REPLAY_TICKS.length - 1) {
setIsPlaying(false)
return
}
setPlaybackTime(REPLAY_TICKS[i + 1])
}, speedSecPerTick * 1000)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
const index = REPLAY_TICKS.indexOf(playbackTime)
const value = index >= 0 ? index : REPLAY_TICKS.length - 1
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const i = parseInt(e.target.value, 10)
setPlaybackTime(REPLAY_TICKS[i])
}
return (
<div className="shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setReplayMode(!isReplayMode)}
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors ${
isReplayMode
? 'bg-military-accent/30 text-military-accent'
: 'bg-military-border/50 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary'
}`}
>
<History className="h-3.5 w-3.5" />
</button>
{isReplayMode && (
<>
<div className="flex items-center gap-1">
<button
type="button"
onClick={stepBack}
disabled={index <= 0}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
title="上一步"
>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setIsPlaying(!isPlaying)}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary"
title={isPlaying ? '暂停' : '播放'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={stepForward}
disabled={index >= REPLAY_TICKS.length - 1}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
title="下一步"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
<input
type="range"
min={0}
max={REPLAY_TICKS.length - 1}
value={value}
onChange={handleSliderChange}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
/>
</div>
<div className="flex items-center gap-2 text-[11px] tabular-nums text-military-text-secondary">
<span>{formatTick(REPLAY_START)}</span>
<span className="font-medium text-military-accent">{formatTick(playbackTime)}</span>
<span>{formatTick(REPLAY_END)}</span>
</div>
<select
value={speedSecPerTick}
onChange={(e) => setSpeed(Number(e.target.value))}
className="rounded border border-military-border bg-military-dark/80 px-2 py-1 text-[11px] text-military-text-secondary focus:border-military-accent focus:outline-none"
>
<option value={0.5}>0.5 /</option>
<option value={1}>1 /</option>
<option value={2}>2 /</option>
<option value={3}>3 /</option>
<option value={5}>5 /</option>
</select>
</>
)}
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import Map, { Source, Layer } from 'react-map-gl'
import type { MapRef } from 'react-map-gl'
import type { Map as MapboxMap } from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { useSituationStore } from '@/store/situationStore'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import {
ATTACKED_TARGETS,
ALLIED_STRIKE_LOCATIONS,
@@ -129,8 +129,8 @@ export function WarMap() {
const lincolnPathsRef = useRef<[number, number][][]>([])
const fordPathsRef = useRef<[number, number][][]>([])
const israelPathsRef = useRef<[number, number][][]>([])
const { situation } = useSituationStore()
const { usForces, iranForces } = situation
const situation = useReplaySituation()
const { usForces, iranForces, conflictEvents = [] } = situation
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
@@ -239,6 +239,29 @@ export function WarMap() {
[attackPaths]
)
// GDELT 冲突事件13 绿, 46 橙闪, 710 红脉
const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => {
const green: GeoJSON.Feature<GeoJSON.Point>[] = []
const orange: GeoJSON.Feature<GeoJSON.Point>[] = []
const red: GeoJSON.Feature<GeoJSON.Point>[] = []
for (const e of conflictEvents) {
const score = e.impact_score ?? 1
const f: GeoJSON.Feature<GeoJSON.Point> = {
type: 'Feature',
properties: { event_id: e.event_id, impact_score: score },
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
}
if (score <= 3) green.push(f)
else if (score <= 6) orange.push(f)
else red.push(f)
}
return {
conflictEventsGreen: { type: 'FeatureCollection' as const, features: green },
conflictEventsOrange: { type: 'FeatureCollection' as const, features: orange },
conflictEventsRed: { type: 'FeatureCollection' as const, features: red },
}
}, [conflictEvents])
const hideNonBelligerentLabels = (map: MapboxMap) => {
const labelLayers = [
'country-label',
@@ -362,6 +385,20 @@ export function WarMap() {
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
}
// GDELT 橙色 (46):闪烁
if (map.getLayer('gdelt-events-orange')) {
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
}
// GDELT 红色 (710):脉冲扩散
if (map.getLayer('gdelt-events-red-pulse')) {
const cycle = 2200
const phase = (elapsed % cycle) / cycle
const r = 30 * phase
const opacity = Math.max(0, 1 - phase * 1.1)
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
}
} catch (_) {}
animRef.current = requestAnimationFrame(tick)
}
@@ -376,7 +413,10 @@ export function WarMap() {
(map.getSource('attack-dots') && attackPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0)
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0) ||
map.getSource('gdelt-events-green') ||
map.getSource('gdelt-events-orange') ||
map.getSource('gdelt-events-red')
if (hasAnim) {
animRef.current = requestAnimationFrame(tick)
} else {
@@ -439,6 +479,15 @@ export function WarMap() {
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#F97316]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
</span>
</div>
<Map
ref={mapRef}
@@ -537,6 +586,52 @@ export function WarMap() {
/>
</Source>
{/* GDELT 冲突事件13 绿点, 46 橙闪, 710 红脉 */}
<Source id="gdelt-events-green" type="geojson" data={conflictEventsGreen}>
<Layer
id="gdelt-events-green"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
'circle-color': '#22C55E',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
<Source id="gdelt-events-orange" type="geojson" data={conflictEventsOrange}>
<Layer
id="gdelt-events-orange"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 5, 12, 8],
'circle-color': '#F97316',
'circle-opacity': 0.8,
}}
/>
</Source>
<Source id="gdelt-events-red" type="geojson" data={conflictEventsRed}>
<Layer
id="gdelt-events-red-dot"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
'circle-color': '#EF4444',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="gdelt-events-red-pulse"
type="circle"
paint={{
'circle-radius': 0,
'circle-color': 'rgba(239, 68, 68, 0.5)',
'circle-opacity': 0,
}}
/>
</Source>
{/* 美以联军打击伊朗:路径线 */}
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
<Layer

View File

@@ -31,6 +31,8 @@ export interface PowerIndex {
export interface CombatLosses {
bases: { destroyed: number; damaged: number }
personnelCasualties: { killed: number; wounded: number }
/** 平民伤亡 */
civilianCasualties?: { killed: number; wounded: number }
aircraft: number
warships: number
armor: number
@@ -45,6 +47,23 @@ export interface SituationUpdate {
severity: 'low' | 'medium' | 'high' | 'critical'
}
export interface ConflictEvent {
event_id: string
event_time: string
title: string
lat: number
lng: number
impact_score: number
url?: string
}
export interface ConflictStats {
total_events: number
high_impact_events: number
estimated_casualties: number
estimated_strike_count: number
}
export interface MilitarySituation {
lastUpdated: string
usForces: {
@@ -86,6 +105,12 @@ export interface MilitarySituation {
retaliationSentimentHistory: { time: string; value: number }[]
}
recentUpdates: SituationUpdate[]
/** GDELT 冲突事件 (13 绿点, 46 橙闪, 710 红脉) */
conflictEvents?: ConflictEvent[]
/** 战损统计(展示用) */
conflictStats?: ConflictStats
/** 平民伤亡合计(不区分阵营) */
civilianCasualtiesTotal?: { killed: number; wounded: number }
}
export const INITIAL_MOCK_DATA: MilitarySituation = {
@@ -122,6 +147,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
combatLosses: {
bases: { destroyed: 0, damaged: 2 },
personnelCasualties: { killed: 127, wounded: 384 },
civilianCasualties: { killed: 18, wounded: 52 },
aircraft: 2,
warships: 0,
armor: 0,
@@ -171,6 +197,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
combatLosses: {
bases: { destroyed: 3, damaged: 8 },
personnelCasualties: { killed: 2847, wounded: 5620 },
civilianCasualties: { killed: 412, wounded: 1203 },
aircraft: 24,
warships: 12,
armor: 18,
@@ -219,4 +246,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
severity: 'low',
},
],
conflictEvents: [],
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
civilianCasualtiesTotal: { killed: 430, wounded: 1255 },
}

View File

@@ -0,0 +1,146 @@
import { useMemo } from 'react'
import type { MilitarySituation } from '@/data/mockData'
import { useSituationStore } from '@/store/situationStore'
import { usePlaybackStore } from '@/store/playbackStore'
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
function toReplayDay(iso: string, baseDay: string): string {
const d = new Date(iso)
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number)
return new Date(y, (m || 1) - 1, day || 1, d.getUTCHours(), d.getUTCMinutes(), 0, 0).toISOString()
}
function interpolateAt(
series: { time: string; value: number }[],
at: string,
baseDay = '2026-03-01'
): number {
if (series.length === 0) return 0
const t = new Date(at).getTime()
const mapped = series.map((p) => ({
time: toReplayDay(p.time, baseDay),
value: p.value,
}))
const sorted = [...mapped].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
const before = sorted.filter((p) => new Date(p.time).getTime() <= t)
const after = sorted.filter((p) => new Date(p.time).getTime() > t)
if (before.length === 0) return sorted[0].value
if (after.length === 0) return sorted[sorted.length - 1].value
const a = before[before.length - 1]
const b = after[0]
const ta = new Date(a.time).getTime()
const tb = new Date(b.time).getTime()
const f = tb === ta ? 1 : (t - ta) / (tb - ta)
return a.value + f * (b.value - a.value)
}
function linearProgress(start: string, end: string, at: string): number {
const ts = new Date(start).getTime()
const te = new Date(end).getTime()
const ta = new Date(at).getTime()
if (ta <= ts) return 0
if (ta >= te) return 1
return (ta - ts) / (te - ts)
}
/** 根据回放时刻派生态势数据 */
export function useReplaySituation(): MilitarySituation {
const situation = useSituationStore((s) => s.situation)
const { isReplayMode, playbackTime } = usePlaybackStore()
return useMemo(() => {
if (!isReplayMode) return situation
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
// 华尔街趋势、反击情绪:按时间插值
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
// 战斗损失:从 0 线性增长到当前值
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
const usLoss = situation.usForces.combatLosses
const irLoss = situation.iranForces.combatLosses
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
const usLossesAt = {
bases: {
destroyed: lerp(0, usLoss.bases.destroyed),
damaged: lerp(0, usLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, usLoss.personnelCasualties.killed),
wounded: lerp(0, usLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, usLoss.aircraft),
warships: lerp(0, usLoss.warships),
armor: lerp(0, usLoss.armor),
vehicles: lerp(0, usLoss.vehicles),
}
const irLossesAt = {
bases: {
destroyed: lerp(0, irLoss.bases.destroyed),
damaged: lerp(0, irLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, irLoss.personnelCasualties.killed),
wounded: lerp(0, irLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, irLoss.aircraft),
warships: lerp(0, irLoss.warships),
armor: lerp(0, irLoss.armor),
vehicles: lerp(0, irLoss.vehicles),
}
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
const usLocs = situation.usForces.keyLocations || []
const attackedBases = usLocs
.filter((loc) => loc.status === 'attacked')
.sort((a, b) => (b.damage_level ?? 0) - (a.damage_level ?? 0))
const totalAttacked = attackedBases.length
const shownAttackedCount = Math.round(progress * totalAttacked)
const attackedNames = new Set(
attackedBases.slice(0, shownAttackedCount).map((l) => l.name)
)
const usLocsAt = usLocs.map((loc) => {
if (loc.status === 'attacked' && !attackedNames.has(loc.name)) {
return { ...loc, status: 'operational' as const }
}
return { ...loc }
})
return {
...situation,
lastUpdated: playbackTime,
civilianCasualtiesTotal: {
killed: lerp(0, civTotal.killed),
wounded: lerp(0, civTotal.wounded),
},
usForces: {
...situation.usForces,
keyLocations: usLocsAt,
combatLosses: usLossesAt,
wallStreetInvestmentTrend: [
...situation.usForces.wallStreetInvestmentTrend.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
{ time: playbackTime, value: wsValue },
].slice(-20),
},
iranForces: {
...situation.iranForces,
combatLosses: irLossesAt,
retaliationSentiment: retValue,
retaliationSentimentHistory: [
...situation.iranForces.retaliationSentimentHistory.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
{ time: playbackTime, value: retValue },
].slice(-20),
},
recentUpdates: (situation.recentUpdates || []).filter(
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
),
conflictEvents: situation.conflictEvents || [],
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
}
}, [situation, isReplayMode, playbackTime])
}

View File

@@ -31,6 +31,11 @@ body,
font-family: 'Orbitron', sans-serif;
}
/* 数据库面板:易读字体 */
.font-db {
font-family: 'Noto Sans SC', system-ui, -apple-system, sans-serif;
}
/* Tabular numbers for aligned stat display */
.tabular-nums {
font-variant-numeric: tabular-nums;

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View File

@@ -1,17 +1,20 @@
import { useEffect } from 'react'
import { HeaderPanel } from '@/components/HeaderPanel'
import { TimelinePanel } from '@/components/TimelinePanel'
import { ForcePanel } from '@/components/ForcePanel'
import { WarMap } from '@/components/WarMap'
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
import { EventTimelinePanel } from '@/components/EventTimelinePanel'
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
import { PowerChart } from '@/components/PowerChart'
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
import { RetaliationGauge } from '@/components/RetaliationGauge'
import { useSituationStore } from '@/store/situationStore'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import { fetchAndSetSituation, startSituationWebSocket, stopSituationWebSocket } from '@/store/situationStore'
export function Dashboard() {
const situation = useSituationStore((s) => s.situation)
const situation = useReplaySituation()
const isLoading = useSituationStore((s) => s.isLoading)
const lastError = useSituationStore((s) => s.lastError)
@@ -28,6 +31,7 @@ export function Dashboard() {
</div>
)}
<HeaderPanel />
<TimelinePanel />
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-b border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-b-0 lg:border-r lg:p-4">
@@ -63,8 +67,11 @@ export function Dashboard() {
<CombatLossesPanel
usLosses={situation.usForces.combatLosses}
iranLosses={situation.iranForces.combatLosses}
className="min-w-0 flex-1 border-t-0"
conflictStats={situation.conflictStats}
civilianTotal={situation.civilianCasualtiesTotal}
className="min-w-0 flex-1 py-1"
/>
<EventTimelinePanel updates={situation.recentUpdates} conflictEvents={situation.conflictEvents} className="min-w-0 shrink-0 min-h-[80px] overflow-hidden lg:min-w-[240px]" />
</div>
</main>

161
src/pages/DbDashboard.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Database, Table, ArrowLeft, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
interface TableData {
[table: string]: Record<string, unknown>[] | { error: string }
}
export function DbDashboard() {
const [data, setData] = useState<TableData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set(['situation_update', 'combat_losses', 'conflict_stats']))
useEffect(() => {
fetchData()
const t = setInterval(fetchData, 30000)
return () => clearInterval(t)
}, [])
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/db/dashboard')
if (!res.ok) throw new Error(res.statusText)
const json = await res.json()
setData(json)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}
const toggle = (name: string) => {
setExpanded((s) => {
const next = new Set(s)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
if (loading && !data) {
return (
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
<RefreshCw className="h-6 w-6 animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-military-dark font-db text-military-text-primary">
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-military-border bg-military-panel/95 px-4 py-3">
<div className="flex items-center gap-4">
<Link
to="/"
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 hover:text-military-text-primary"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="flex items-center gap-2 text-lg">
<Database className="h-5 w-5 text-cyan-400" />
</span>
</div>
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
{error && (
<div className="mx-4 mt-4 rounded border border-amber-600/50 bg-amber-950/30 px-4 py-2 text-amber-400">
{error} API npm run api
</div>
)}
<main className="max-w-6xl space-y-4 p-4">
{data &&
Object.entries(data).map(([name, rows]) => {
const isExpanded = expanded.has(name)
const isError = rows && typeof rows === 'object' && 'error' in rows
const arr = Array.isArray(rows) ? rows : []
return (
<section
key={name}
className="rounded border border-military-border bg-military-panel/80 overflow-hidden"
>
<button
onClick={() => toggle(name)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="flex items-center gap-2 font-medium">
<Table className="h-4 w-4 text-cyan-400" />
{name}
<span className="text-military-text-secondary font-normal">
{isError ? '(错误)' : `(${arr.length} 条)`}
</span>
</span>
<span className="text-military-text-secondary">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="border-t border-military-border overflow-x-auto">
{isError ? (
<pre className="p-4 text-sm text-amber-400">{(rows as { error: string }).error}</pre>
) : arr.length === 0 ? (
<p className="p-4 text-sm text-military-text-secondary"></p>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-military-border bg-military-dark/50">
{Object.keys(arr[0] as object).map((col) => (
<th
key={col}
className="whitespace-nowrap px-3 py-2 font-medium text-cyan-400"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{arr.map((row, i) => (
<tr
key={i}
className="border-b border-military-border/50 hover:bg-military-border/10"
>
{Object.values(row as object).map((v, j) => (
<td
key={j}
className="max-w-xs truncate px-3 py-2 text-military-text-secondary"
title={String(v ?? '')}
>
{v === null || v === undefined
? '—'
: typeof v === 'object'
? JSON.stringify(v)
: String(v)}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</section>
)
})}
</main>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { create } from 'zustand'
const REPLAY_DAY = '2026-03-01'
const TICK_MS = 30 * 60 * 1000 // 30 minutes
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
function parseTime(iso: string): number {
return new Date(iso).getTime()
}
export function getTicks(): string[] {
const ticks: string[] = []
let t = parseTime(REPLAY_START)
const end = parseTime(REPLAY_END)
while (t <= end) {
ticks.push(new Date(t).toISOString())
t += TICK_MS
}
return ticks
}
export const REPLAY_TICKS = getTicks()
export interface PlaybackState {
/** 是否开启回放模式 */
isReplayMode: boolean
/** 当前回放时刻 (ISO) */
playbackTime: string
/** 是否正在自动播放 */
isPlaying: boolean
/** 播放速度 (秒/刻度) */
speedSecPerTick: number
setReplayMode: (v: boolean) => void
setPlaybackTime: (iso: string) => void
setIsPlaying: (v: boolean) => void
stepForward: () => void
stepBack: () => void
setSpeed: (sec: number) => void
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
isReplayMode: false,
playbackTime: REPLAY_END,
isPlaying: false,
speedSecPerTick: 2,
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
setPlaybackTime: (iso) => {
const ticks = REPLAY_TICKS
if (ticks.includes(iso)) {
set({ playbackTime: iso })
return
}
const idx = ticks.findIndex((t) => t >= iso)
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
set({ playbackTime: ticks[clamp] })
},
setIsPlaying: (v) => set({ isPlaying: v }),
stepForward: () => {
const { playbackTime } = get()
const ticks = REPLAY_TICKS
const i = ticks.indexOf(playbackTime)
if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] })
else set({ isPlaying: false })
},
stepBack: () => {
const { playbackTime } = get()
const ticks = REPLAY_TICKS
const i = ticks.indexOf(playbackTime)
if (i > 0) set({ playbackTime: ticks[i - 1] })
},
setSpeed: (sec) => set({ speedSecPerTick: sec }),
}))

View File

@@ -47,20 +47,36 @@ export function fetchAndSetSituation(): Promise<void> {
}
let disconnectWs: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL_MS = 5000
function pollSituation() {
fetchSituation()
.then((situation) => useSituationStore.getState().setSituation(situation))
.catch(() => {})
}
export function startSituationWebSocket(): () => void {
useSituationStore.getState().setConnected(true)
useSituationStore.getState().setLastError(null)
disconnectWs = connectSituationWebSocket((data) => {
useSituationStore.getState().setConnected(true)
useSituationStore.getState().setSituation(data as MilitarySituation)
})
pollSituation()
pollInterval = setInterval(pollSituation, POLL_INTERVAL_MS)
return stopSituationWebSocket
}
export function stopSituationWebSocket(): void {
disconnectWs?.()
disconnectWs = null
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
useSituationStore.getState().setConnected(false)
}

48
start.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# 一键启动 US-Iran 态势面板API + 前端 + 爬虫服务
set -e
cd "$(dirname "$0")"
# 无 Ollama 时禁用 AIGDELT 国内常超时,仅用 RSS 更新
export CLEANER_AI_DISABLED=1
export PARSER_AI_DISABLED=1
export GDELT_DISABLED=1
export RSS_INTERVAL_SEC=60
echo "==> Checking dependencies..."
[ ! -d node_modules ] && npm install
echo "==> Checking crawler Python deps..."
pip install -q -r crawler/requirements.txt 2>/dev/null || true
echo "==> Seeding database (if needed)..."
[ ! -f server/data.db ] && npm run api:seed
echo "==> Starting API (http://localhost:3001)..."
npm run api &
API_PID=$!
# 等待 API 就绪后再启动爬虫
sleep 2
echo "==> Starting GDELT/RSS crawler (http://localhost:8000)..."
npm run gdelt &
GDELT_PID=$!
echo "==> Starting frontend (Vite dev server)..."
npm run dev &
DEV_PID=$!
cleanup() {
echo ""
echo "==> Shutting down..."
kill $API_PID $GDELT_PID $DEV_PID 2>/dev/null || true
exit 0
}
trap cleanup SIGINT SIGTERM
echo ""
echo "==> All services running. Frontend: http://localhost:5173 | API: http://localhost:3001"
echo " Press Ctrl+C to stop all."
echo ""
wait