fix: bug
This commit is contained in:
@@ -14,16 +14,17 @@
|
||||
|
||||
**数据偏老原因**:未传 `timespan` 和 `sort=datedesc`,API 返回 3 个月内“最相关”文章,不保证最新。
|
||||
|
||||
### 2. RSS 新闻 (situation_update) — 主事件脉络来源
|
||||
### 2. RSS 新闻 → 看板实时数据(主输出)+ 事件脉络
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 源 | 多国主流媒体:美(Reuters/NYT)、英(BBC)、法(France 24)、俄(TASS/RT)、中(Xinhua/CGTN)、伊(Press TV)、卡塔尔(Al Jazeera) |
|
||||
| **主输出** | **看板实时数据**:战损(combat_losses)、据点状态(key_location)、冲突事件(gdelt_events)、统计(conflict_stats)等,供前端战损/基地/地图等面板展示。 |
|
||||
| 辅助输出 | 事件脉络(situation_update):时间线摘要,非主展示目标。 |
|
||||
| 源 | 多国主流媒体:美/英/法/俄/中/伊/卡塔尔等(见 `config.RSS_FEEDS`) |
|
||||
| 过滤 | 标题/摘要需含 `KEYWORDS` 之一(iran、usa、strike、military 等) |
|
||||
| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 |
|
||||
| 优先级 | 启动时先拉 RSS,再拉 GDELT |
|
||||
| 更新 | 爬虫按 `RSS_INTERVAL_SEC` 拉取;每 `BACKFILL_CYCLES` 轮会从近期事件回填一次战损/据点,保证面板数据与最新内容一致。 |
|
||||
|
||||
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS 新闻即可维持事件脉络。部分境外源可能受网络限制。
|
||||
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS;部分境外源可能需代理。
|
||||
|
||||
### 3. AI 新闻清洗与分类(可选)
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
---
|
||||
|
||||
**事件脉络可实时更新**:爬虫抓取后 → 写入 SQLite → 调用 Node 通知 → WebSocket 广播 → 前端自动刷新。
|
||||
**看板实时数据更新**:爬虫抓取 → 提取战损/据点等 → 写入 combat_losses、key_location 等 → 调用 Node 通知 → WebSocket 广播 → 前端战损/基地/地图等面板刷新。事件脉络(时间线)为同一流水线的辅助输出。
|
||||
|
||||
## 依赖
|
||||
|
||||
@@ -141,6 +142,7 @@ npm run crawler:test:extraction # 规则/db_merge 测试
|
||||
| **地图冲突点** (conflictEvents) | gdelt_events 或 RSS→gdelt 回填 | ✅ 是 | GDELT 或 GDELT 禁用时由 situation_update 同步到 gdelt_events |
|
||||
| **战损/装备毁伤** (combatLosses) | combat_losses | ⚠️ 有条件 | 仅当 AI/规则从新闻中提取到数字(如「2 名美军死亡」)时,merge 才写入增量 |
|
||||
| **基地/地点状态** (keyLocations) | key_location | ⚠️ 有条件 | 仅当提取到 key_location_updates(如某基地遭袭)时更新 |
|
||||
| **地图打击/攻击动画** (mapData.strikeSources, strikeLines) | map_strike_source, map_strike_line | ⚠️ 有条件 | 仅当提取到 map_strike_sources / map_strike_lines 时写入;格式见下「地图打击数据」 |
|
||||
| **力量摘要/指数/资产** (summary, powerIndex, assets) | force_summary, power_index, force_asset | ❌ 否 | 仅 seed 初始化,爬虫不写 |
|
||||
| **华尔街/报复情绪** (wallStreet, retaliation) | wall_street_trend, retaliation_* | ⚠️ 有条件 | 仅当提取器输出对应字段时更新 |
|
||||
|
||||
@@ -255,6 +257,32 @@ npm run crawler:test
|
||||
|
||||
---
|
||||
|
||||
## 数据流与 AI 自检
|
||||
|
||||
**完整链路**:RSS 抓取 → 关键词过滤 → 翻译/清洗 → 去重(news_content)→ 写 situation_update → 正文抓取(可选)→ **AI 提取**(战损/基地等)→ db_merge 写 combat_losses/key_location 等 → POST /api/crawler/notify → Node 重载并广播。
|
||||
|
||||
| 环节 | 说明 | 自检 |
|
||||
|------|------|------|
|
||||
| 抓取 | `scrapers/rss_scraper.fetch_all()`,按 KEYWORDS 过滤 | `npm run crawler:test` 看条数 |
|
||||
| 去重 | `news_storage.save_and_dedup()`,content_hash 落库 news_content | 查 `news_content` 表条数 |
|
||||
| 事件脉络 | `db_writer.write_updates()` 写 situation_update(与 pipeline 使用同一 db_path) | 查 `situation_update` 表 |
|
||||
| AI 提取 | 战损/基地等:**有 DASHSCOPE_API_KEY 用通义**,**否则 CLEANER_AI_DISABLED=1 用规则**,否则用 **Ollama**(extractor_ai) | 见下 |
|
||||
| 分类/严重度 | 每条 RSS 的 category/severity:**PARSER_AI_DISABLED=1 用规则**,否则 DashScope 或 Ollama | 无 AI 时设 `PARSER_AI_DISABLED=1` 可正常跑 |
|
||||
|
||||
**如何保证「面板实时数据」有更新**(战损、据点等):
|
||||
|
||||
- **推荐**:设 `CLEANER_AI_DISABLED=1` → 使用 `extractor_rules`(纯规则),无需 Ollama/通义,即可从新闻中提取战损/基地并写入 combat_losses、key_location。
|
||||
- 或设 `DASHSCOPE_API_KEY` → 用通义做更细的提取。
|
||||
- 否则用 `extractor_ai`(需本机 `ollama run llama3.1`),未起则提取静默失败、面板数字不更新。
|
||||
- 服务会每 `BACKFILL_CYCLES` 轮(默认 2 轮)从近期事件再跑一次提取并合并,保证战损/据点与最新内容一致。
|
||||
|
||||
**常见 bug 与修复**:
|
||||
|
||||
- **事件脉络有、战损/基地不更新**:多为 AI 未跑通(Ollama 未起且未设 DashScope、未设 CLEANER_AI_DISABLED)。可设 `CLEANER_AI_DISABLED=1` 用规则提取,或起 Ollama / 配置 DashScope。
|
||||
- **多 DB 路径不一致**:pipeline 已统一 `db_path`,`write_updates`、`save_and_dedup`、`merge` 均使用同一 path(或 `config.DB_PATH`)。
|
||||
|
||||
---
|
||||
|
||||
## 优化后验证效果示例
|
||||
|
||||
以下为「正文抓取 + AI 精确提取 + 增量与地点更新」优化后,单条新闻从输入到前端展示的完整示例,便于对照验证。
|
||||
@@ -321,6 +349,15 @@ print('key_location_updates:', out.get('key_location_updates'))
|
||||
|
||||
期望:`combat_losses_delta.us` 含 personnel_killed=2、personnel_wounded=14、aircraft=1 等增量;`key_location_updates` 含阿萨德 side=us 等条目。
|
||||
|
||||
### 地图打击数据(与前端攻击动画统一格式)
|
||||
|
||||
爬虫/AI 若输出以下字段,`db_merge` 会写入 `map_strike_source`、`map_strike_line`,`GET /api/situation` 的 `mapData.strikeSources` / `mapData.strikeLines` 会更新,前端可直接追加打击线与飞行动画。
|
||||
|
||||
- **map_strike_sources**(可选):`[{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng": 经度, "lat": 纬度 }]`,与 seed 中打击源 id 一致时可覆盖位置。
|
||||
- **map_strike_lines**(可选):`[{ "source_id": "israel"|"lincoln"|"ford", "target_lng", "target_lat", "target_name": "目标名", "struck_at": "ISO 时间" }]`,每条追加一条打击线(不删已有),便于按时间回放。
|
||||
|
||||
示例:`{ "map_strike_lines": [{ "source_id": "israel", "target_lng": 51.916, "target_lat": 33.666, "target_name": "纳坦兹", "struck_at": "2026-03-01T02:04:00.000Z" }] }`
|
||||
|
||||
---
|
||||
|
||||
## 冲突强度 (impact_score)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将 AI 提取的结构化数据合并到 SQLite
|
||||
与 panel schema 及 situationData.getSituation 对齐,支持回放
|
||||
与 panel schema 及 situationData.getSituation 对齐,支持回放。
|
||||
|
||||
地图打击数据(与前端攻击动画一致):
|
||||
- map_strike_sources: [{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng", "lat" }] 写入 map_strike_source
|
||||
- map_strike_lines: [{ "source_id", "target_lng", "target_lat", "target_name?", "struck_at?" }] 追加到 map_strike_line
|
||||
爬虫/AI 可按此格式输出,落库后 GET /api/situation 的 mapData.strikeSources/strikeLines 会更新,前端直接追加攻击动画。
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -67,6 +72,37 @@ def _ensure_tables(conn: sqlite3.Connection) -> None:
|
||||
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)")
|
||||
# 地图打击源与打击线(与 server/db.js 一致),供 getSituation mapData 与前端攻击动画使用
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS map_strike_source (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
lat REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS map_strike_line (
|
||||
source_id TEXT NOT NULL,
|
||||
target_lng REAL NOT NULL,
|
||||
target_lat REAL NOT NULL,
|
||||
target_name TEXT,
|
||||
struck_at TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES map_strike_source(id)
|
||||
)
|
||||
""")
|
||||
try:
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
for col in ("struck_at",):
|
||||
cur = conn.execute("PRAGMA table_info(map_strike_line)")
|
||||
cols = [r[1] for r in cur.fetchall()]
|
||||
if col not in cols:
|
||||
conn.execute(f"ALTER TABLE map_strike_line ADD COLUMN {col} TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -183,6 +219,41 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
|
||||
updated = True
|
||||
except Exception:
|
||||
pass
|
||||
# map_strike_source:打击源(与前端 mapData.strikeSources 一致),爬虫可补充或覆盖
|
||||
if "map_strike_sources" in extracted:
|
||||
try:
|
||||
for s in extracted["map_strike_sources"]:
|
||||
sid = (s.get("id") or "").strip()
|
||||
name = (s.get("name") or "").strip() or sid
|
||||
lng = float(s.get("lng", 0))
|
||||
lat = float(s.get("lat", 0))
|
||||
if sid:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)",
|
||||
(sid, name[:200], lng, lat),
|
||||
)
|
||||
if conn.total_changes > 0:
|
||||
updated = True
|
||||
except Exception:
|
||||
pass
|
||||
# map_strike_lines:打击线(与前端 mapData.strikeLines 一致),爬虫可追加新打击,便于前端追加攻击动画
|
||||
if "map_strike_lines" in extracted:
|
||||
try:
|
||||
for line in extracted["map_strike_lines"]:
|
||||
source_id = (line.get("source_id") or "").strip()
|
||||
target_lng = float(line.get("target_lng", 0))
|
||||
target_lat = float(line.get("target_lat", 0))
|
||||
target_name = (line.get("target_name") or "").strip()[:200] or None
|
||||
struck_at = (line.get("struck_at") or "").strip() or None
|
||||
if source_id:
|
||||
conn.execute(
|
||||
"INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(source_id, target_lng, target_lat, target_name, struck_at),
|
||||
)
|
||||
if conn.total_changes > 0:
|
||||
updated = True
|
||||
except Exception:
|
||||
pass
|
||||
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()
|
||||
|
||||
@@ -4,6 +4,7 @@ import sqlite3
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from config import DB_PATH
|
||||
|
||||
@@ -73,14 +74,29 @@ def touch_situation_updated_at(conn: sqlite3.Connection) -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def write_updates(updates: list[dict]) -> int:
|
||||
def touch_situation_updated_at_path(db_path: Optional[str] = None) -> bool:
|
||||
"""仅更新 situation.updated_at 为当前时间(每次爬虫运行都调用,便于前端显示「最后抓取时间」)。返回是否成功。"""
|
||||
path = db_path or DB_PATH
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
conn = sqlite3.connect(path, timeout=10)
|
||||
try:
|
||||
touch_situation_updated_at(conn)
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_updates(updates: list[dict], db_path: Optional[str] = None) -> int:
|
||||
"""
|
||||
updates: [{"title","summary","url","published","category","severity"}, ...]
|
||||
db_path: 与 pipeline 一致,缺省用 config.DB_PATH
|
||||
返回新增条数。
|
||||
"""
|
||||
if not os.path.exists(DB_PATH):
|
||||
path = db_path or DB_PATH
|
||||
if not os.path.exists(path):
|
||||
return 0
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn = sqlite3.connect(path, timeout=10)
|
||||
try:
|
||||
count = 0
|
||||
for u in updates:
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from config import DB_PATH, API_BASE
|
||||
from db_writer import touch_situation_updated_at_path
|
||||
|
||||
|
||||
def _notify_api(api_base: str) -> bool:
|
||||
@@ -172,15 +173,18 @@ def run_full_pipeline(
|
||||
except Exception as e:
|
||||
print(f" [warn] 正文抓取: {e}")
|
||||
|
||||
# 4. 映射到前端库字段并更新表
|
||||
n_panel = write_updates(new_items) if new_items else 0
|
||||
# 4. 映射到前端库字段并更新表(与去重/AI 使用同一 db path)
|
||||
n_panel = write_updates(new_items, db_path=path) if new_items else 0
|
||||
if new_items:
|
||||
_extract_and_merge(new_items, path)
|
||||
|
||||
# 5. 通知(有新增时才通知;可选:先执行外部逻辑如 GDELT 回填,再通知)
|
||||
# 4.5 每次运行都刷新 situation.updated_at,便于前端显示「最后抓取时间」(否则只有新增条目时才更新,数据会一直停在旧日期)
|
||||
touch_situation_updated_at_path(db_path=path)
|
||||
|
||||
# 5. 通知(每次运行都通知,让 API 重载并广播最新 lastUpdated)
|
||||
if on_notify:
|
||||
on_notify()
|
||||
if notify and (n_panel > 0 or n_news > 0):
|
||||
if notify:
|
||||
_notify_api(base)
|
||||
|
||||
return len(items), n_news, n_panel
|
||||
|
||||
@@ -297,8 +297,43 @@ def _rss_to_gdelt_fallback() -> None:
|
||||
LAST_FETCH = {"items": 0, "inserted": 0, "error": None}
|
||||
|
||||
|
||||
def _refresh_panel_data() -> int:
|
||||
"""从近期事件重新提取并合并战损/据点等面板实时数据,不依赖本轮是否有新 RSS。返回合并条数。"""
|
||||
if not os.path.exists(DB_PATH):
|
||||
return 0
|
||||
try:
|
||||
from db_merge import merge
|
||||
use_dashscope = bool(os.environ.get("DASHSCOPE_API_KEY", "").strip())
|
||||
if use_dashscope:
|
||||
from extractor_dashscope import extract_from_news
|
||||
elif os.environ.get("CLEANER_AI_DISABLED", "0") == "1":
|
||||
from extractor_rules import extract_from_news
|
||||
else:
|
||||
from extractor_ai import extract_from_news
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
rows = conn.execute(
|
||||
"SELECT id, timestamp, category, summary FROM situation_update ORDER BY timestamp DESC LIMIT 50"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
merged = 0
|
||||
for r in rows:
|
||||
uid, ts, cat, summary = r
|
||||
text = ((cat or "") + " " + (summary or "")).strip()
|
||||
if len(text) < 20:
|
||||
continue
|
||||
try:
|
||||
extracted = extract_from_news(text, timestamp=ts)
|
||||
if extracted and merge(extracted, db_path=DB_PATH):
|
||||
merged += 1
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_news() -> None:
|
||||
"""执行完整写库流水线;GDELT 禁用时用 RSS 回填 gdelt_events,再通知 Node。"""
|
||||
"""执行完整写库流水线;产出看板实时数据(战损、据点、冲突事件)+ 事件脉络。GDELT 禁用时用 RSS 回填 gdelt_events。"""
|
||||
try:
|
||||
from pipeline import run_full_pipeline
|
||||
LAST_FETCH["error"] = None
|
||||
@@ -314,7 +349,7 @@ def fetch_news() -> None:
|
||||
_rss_to_gdelt_fallback()
|
||||
_notify_node()
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] RSS 抓取 {n_fetched} 条,去重后新增 {n_news} 条资讯,写入事件脉络 {n_panel} 条")
|
||||
print(f"[{ts}] 抓取 {n_fetched} 条,去重新增 {n_news} 条,写脉络 {n_panel} 条 → 面板实时数据(战损/据点)已由本批提取更新")
|
||||
if n_fetched == 0:
|
||||
print(f"[{ts}] (0 条:检查网络、RSS 源或 KEYWORDS 过滤)")
|
||||
except Exception as e:
|
||||
@@ -322,6 +357,10 @@ def fetch_news() -> None:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻抓取失败: {e}")
|
||||
|
||||
|
||||
# 每 N 轮做一次「从近期事件回填面板实时数据」,保证战损/据点等与最新内容一致
|
||||
BACKFILL_CYCLES = int(os.environ.get("BACKFILL_CYCLES", "2"))
|
||||
_cycle_count = 0
|
||||
|
||||
# ==========================
|
||||
# 定时任务(asyncio 后台任务,避免 APScheduler executor 关闭竞态)
|
||||
# ==========================
|
||||
@@ -329,11 +368,20 @@ _bg_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
async def _periodic_fetch() -> None:
|
||||
global _cycle_count
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
try:
|
||||
await loop.run_in_executor(None, fetch_news)
|
||||
await loop.run_in_executor(None, fetch_gdelt_events)
|
||||
_cycle_count += 1
|
||||
if _cycle_count >= BACKFILL_CYCLES:
|
||||
_cycle_count = 0
|
||||
merged = _refresh_panel_data()
|
||||
if merged > 0:
|
||||
_notify_node()
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] 面板实时数据回填:从近期事件合并 {merged} 条(战损/据点)")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
|
||||
Binary file not shown.
@@ -88,7 +88,6 @@ def fetch_all() -> list[dict]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
# 写入 DB 的 schema 不包含 source,可后续扩展
|
||||
items.append({k: v for k, v in item.items() if k != "source"})
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
39
scripts/check-db-and-crawler.sh
Executable file
39
scripts/check-db-and-crawler.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# 查看数据库中的 lastUpdated 与条数,并提示如何用爬虫更新
|
||||
# 用法: ./scripts/check-db-and-crawler.sh
|
||||
|
||||
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "数据库与爬虫状态"
|
||||
echo "DB: $DB_PATH"
|
||||
echo "=========================================="
|
||||
|
||||
if [[ ! -f "$DB_PATH" ]]; then
|
||||
echo "数据库文件不存在。请先执行: node server/seed.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v sqlite3 &>/dev/null; then
|
||||
UPDATED_AT=$(sqlite3 "$DB_PATH" "SELECT updated_at FROM situation WHERE id = 1;" 2>/dev/null || echo "?")
|
||||
SU_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM situation_update;" 2>/dev/null || echo "?")
|
||||
NEWS_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM news_content;" 2>/dev/null || echo "?")
|
||||
echo "situation.updated_at (前端 lastUpdated): $UPDATED_AT"
|
||||
echo "situation_update 条数: $SU_COUNT"
|
||||
echo "news_content 条数: $NEWS_COUNT"
|
||||
else
|
||||
echo "未安装 sqlite3,无法直接查库。可安装: brew install sqlite3"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 为何数据停在旧日期? ---"
|
||||
echo " • lastUpdated 来自 situation.updated_at。"
|
||||
echo " • 已改为:每次爬虫运行都会更新该时间(不再仅在有新资讯时更新)。"
|
||||
echo " • 若从未跑爬虫或很久没跑,请执行一次爬虫:"
|
||||
echo ""
|
||||
echo " cd $PROJECT_ROOT && python crawler/run_once.py"
|
||||
echo " 或: npm run crawler:once"
|
||||
echo ""
|
||||
echo " 若需定时更新,可启动常驻爬虫: python crawler/main.py"
|
||||
echo "=========================================="
|
||||
BIN
server/data.db-shm
Normal file
BIN
server/data.db-shm
Normal file
Binary file not shown.
0
server/data.db-wal
Normal file
0
server/data.db-wal
Normal file
33
server/db.js
33
server/db.js
@@ -174,6 +174,7 @@ function runMigrations(db) {
|
||||
if (!names.includes('region')) exec('ALTER TABLE key_location ADD COLUMN region TEXT')
|
||||
if (!names.includes('status')) exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
|
||||
if (!names.includes('damage_level')) exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
|
||||
if (!names.includes('attacked_at')) exec('ALTER TABLE key_location ADD COLUMN attacked_at TEXT')
|
||||
} catch (_) {}
|
||||
try {
|
||||
const lossCols = prepare('PRAGMA table_info(combat_losses)').all()
|
||||
@@ -258,6 +259,38 @@ function runMigrations(db) {
|
||||
INSERT OR IGNORE INTO display_stats (id) VALUES (1);
|
||||
`)
|
||||
} catch (_) {}
|
||||
try {
|
||||
const dsCols = prepare('PRAGMA table_info(display_stats)').all()
|
||||
const dsNames = dsCols.map((c) => c.name)
|
||||
if (!dsNames.includes('override_enabled')) {
|
||||
exec('ALTER TABLE display_stats ADD COLUMN override_enabled INTEGER NOT NULL DEFAULT 0')
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
exec(`
|
||||
CREATE TABLE IF NOT EXISTS map_strike_source (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
lat REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_strike_line (
|
||||
source_id TEXT NOT NULL,
|
||||
target_lng REAL NOT NULL,
|
||||
target_lat REAL NOT NULL,
|
||||
target_name TEXT,
|
||||
struck_at TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES map_strike_source(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id);
|
||||
`)
|
||||
} catch (_) {}
|
||||
try {
|
||||
const lineCols = prepare('PRAGMA table_info(map_strike_line)').all()
|
||||
if (!lineCols.some((c) => c.name === 'struck_at')) {
|
||||
exec('ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT')
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function initDb() {
|
||||
|
||||
@@ -92,7 +92,7 @@ if (BROADCAST_INTERVAL_MS > 0) {
|
||||
setInterval(() => broadcastSituation(false), BROADCAST_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// 供爬虫调用:先从磁盘重载 DB(纳入爬虫写入),再更新 updated_at 并立即广播
|
||||
// 供爬虫调用:先从磁盘重载 DB(纳入爬虫写入),再更新 situation.updated_at 并立即广播;前端据此显示「实时更新」时间
|
||||
function notifyCrawlerUpdate() {
|
||||
try {
|
||||
const db = require('./db')
|
||||
|
||||
@@ -219,14 +219,14 @@ router.get('/edit/raw', (req, res) => {
|
||||
try {
|
||||
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
|
||||
const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran')
|
||||
const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us')
|
||||
const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran')
|
||||
const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('us')
|
||||
const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('iran')
|
||||
const updates = db.prepare('SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 80').all()
|
||||
const summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
|
||||
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
|
||||
let displayStats = null
|
||||
try {
|
||||
displayStats = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
} catch (_) {}
|
||||
const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
|
||||
const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
|
||||
@@ -244,6 +244,7 @@ router.get('/edit/raw', (req, res) => {
|
||||
situationUpdates: updates || [],
|
||||
forceSummary: { us: summaryUs || null, iran: summaryIr || null },
|
||||
displayStats: {
|
||||
overrideEnabled: displayStats?.override_enabled === 1,
|
||||
viewers: displayStats?.viewers ?? liveViewers,
|
||||
cumulative: displayStats?.cumulative ?? realCumulative,
|
||||
shareCount: displayStats?.share_count ?? realShare,
|
||||
@@ -296,7 +297,7 @@ router.patch('/edit/key-location/:id', (req, res) => {
|
||||
if (!Number.isFinite(id)) return res.status(400).json({ error: 'invalid id' })
|
||||
const row = db.prepare('SELECT id FROM key_location WHERE id = ?').get(id)
|
||||
if (!row) return res.status(404).json({ error: 'key_location not found' })
|
||||
const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level']
|
||||
const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level', 'attacked_at']
|
||||
const updates = []
|
||||
const values = []
|
||||
for (const k of allowed) {
|
||||
@@ -383,14 +384,31 @@ router.put('/edit/force-summary', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除覆盖、改用实时统计 */
|
||||
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除该字段;clearOverride=true 或全部为 null 时关闭覆盖、恢复实时统计 */
|
||||
router.put('/edit/display-stats', (req, res) => {
|
||||
try {
|
||||
db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run()
|
||||
const body = req.body || {}
|
||||
const clearOverride = body.clearOverride === true || body.clearOverride === 'true'
|
||||
const viewers = body.viewers
|
||||
const cumulative = body.cumulative
|
||||
const shareCount = body.shareCount
|
||||
const likeCount = body.likeCount
|
||||
const feedbackCount = body.feedbackCount
|
||||
const allNull = viewers === null && cumulative === null && shareCount === null && likeCount === null && feedbackCount === null
|
||||
|
||||
if (clearOverride || allNull) {
|
||||
db.prepare(
|
||||
'UPDATE display_stats SET override_enabled = 0, viewers = NULL, cumulative = NULL, share_count = NULL, like_count = NULL, feedback_count = NULL WHERE id = 1'
|
||||
).run()
|
||||
broadcastAfterEdit(req)
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
const updates = []
|
||||
const values = []
|
||||
const setField = (key, bodyKey) => {
|
||||
const v = req.body?.[bodyKey ?? key]
|
||||
const v = body[bodyKey ?? key]
|
||||
if (v === undefined) return
|
||||
if (v === null) {
|
||||
updates.push(`${key} = ?`)
|
||||
@@ -408,6 +426,8 @@ router.put('/edit/display-stats', (req, res) => {
|
||||
setField('like_count', 'likeCount')
|
||||
setField('feedback_count', 'feedbackCount')
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
|
||||
updates.push('override_enabled = ?')
|
||||
values.push(1)
|
||||
values.push(1)
|
||||
db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||
broadcastAfterEdit(req)
|
||||
|
||||
219
server/seed.js
219
server/seed.js
@@ -3,84 +3,84 @@ const db = require('./db')
|
||||
// 与 src/data/mapLocations.ts 同步:62 基地,27 被袭 (严重6 中度12 轻度9)
|
||||
function getUsLocations() {
|
||||
const naval = [
|
||||
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null },
|
||||
{ name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null },
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
|
||||
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
|
||||
]
|
||||
const attacked = [
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3 },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3 },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3 },
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2 },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2 },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2 },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2 },
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1 },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1 },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1 },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1 },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1 },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1 },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1 },
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:00:00.000Z' },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:15:00.000Z' },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:30:00.000Z' },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:45:00.000Z' },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:00:00.000Z' },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:15:00.000Z' },
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:30:00.000Z' },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:45:00.000Z' },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:00:00.000Z' },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:15:00.000Z' },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:30:00.000Z' },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:45:00.000Z' },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:00:00.000Z' },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:15:00.000Z' },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:30:00.000Z' },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:45:00.000Z' },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:00:00.000Z' },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:15:00.000Z' },
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:30:00.000Z' },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:45:00.000Z' },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:00:00.000Z' },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:15:00.000Z' },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:30:00.000Z' },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:45:00.000Z' },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:00:00.000Z' },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:15:00.000Z' },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:30:00.000Z' },
|
||||
]
|
||||
const newBases = [
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
|
||||
]
|
||||
return [...naval, ...attacked, ...newBases]
|
||||
}
|
||||
@@ -126,27 +126,74 @@ function seed() {
|
||||
;[...usAssets, ...iranAssets].forEach((row) => insertAsset.run(...row))
|
||||
|
||||
const insertLoc = db.prepare(`
|
||||
INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level, attacked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
db.exec('DELETE FROM key_location')
|
||||
|
||||
for (const loc of getUsLocations()) {
|
||||
insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level)
|
||||
insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level, loc.attacked_at ?? null)
|
||||
}
|
||||
// 盟军先打击伊朗(2月28日 02:00–04:00),回放时先出现
|
||||
const iranLocs = [
|
||||
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3],
|
||||
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3],
|
||||
['iran', '布什尔核电站', 28.9681, 50.838, 'Nuclear', '伊朗', 'attacked', 2],
|
||||
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2],
|
||||
['iran', '纳坦兹铀浓缩', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2],
|
||||
['iran', '米纳布岸防', 27.13, 57.08, 'Base', '伊朗', 'damaged', 2],
|
||||
['iran', '卡拉季无人机厂', 35.808, 51.002, 'Base', '伊朗', 'attacked', 2],
|
||||
['iran', '克尔曼沙赫导弹阵地', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2],
|
||||
['iran', '大不里士空军基地', 38.08, 46.29, 'Base', '伊朗', 'damaged', 1],
|
||||
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Base', '伊朗', 'operational', null],
|
||||
['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', 'operational', null, null],
|
||||
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Naval', '伊朗', 'attacked', 3, '2026-02-28T02:15:00.000Z'],
|
||||
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3, '2026-02-28T02:00:00.000Z'],
|
||||
['iran', '哈梅内伊官邸', 35.69, 51.42, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:30:00.000Z'],
|
||||
['iran', '总统府/情报部', 35.72, 51.41, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:32:00.000Z'],
|
||||
['iran', '梅赫拉巴德机场', 35.69, 51.15, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:34:00.000Z'],
|
||||
['iran', '库姆', 34.64, 50.88, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:00:00.000Z'],
|
||||
['iran', '伊朗专家会议秘书处', 34.625448, 50.876409, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:02:00.000Z'],
|
||||
['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
|
||||
['iran', '布什尔雷达站', 28.968, 50.838, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
|
||||
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:22:00.000Z'],
|
||||
['iran', '纳坦兹', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:04:00.000Z'],
|
||||
['iran', '卡拉季无人机厂', 35.808, 51.002, 'UAV', '伊朗', 'attacked', 2, '2026-02-28T02:06:00.000Z'],
|
||||
['iran', '克尔曼沙赫导弹掩体', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:36:00.000Z'],
|
||||
['iran', '大不里士空军基地', 38.08, 46.29, 'Missile', '伊朗', 'damaged', 1, '2026-02-28T02:38:00.000Z'],
|
||||
['iran', '伊拉姆导弹阵地', 33.64, 46.42, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:40:00.000Z'],
|
||||
['iran', '霍拉马巴德储备库', 33.48, 48.35, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:42:00.000Z'],
|
||||
['iran', '米纳布', 27.13, 57.08, 'Naval', '伊朗', 'damaged', 2, '2026-02-28T02:18:00.000Z'],
|
||||
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Naval', '伊朗', 'operational', null, null],
|
||||
]
|
||||
iranLocs.forEach((r) => insertLoc.run(...r))
|
||||
|
||||
const insertStrikeSource = db.prepare('INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)')
|
||||
const strikeSources = [
|
||||
['israel', '以色列', 34.78, 32.08],
|
||||
['lincoln', '林肯号航母', 58.4215, 24.1568],
|
||||
['ford', '福特号航母', 24.1002, 35.7397],
|
||||
]
|
||||
strikeSources.forEach((r) => insertStrikeSource.run(...r))
|
||||
|
||||
const insertStrikeLine = db.prepare('INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)')
|
||||
db.prepare('DELETE FROM map_strike_line').run()
|
||||
// 盟军先打击伊朗(2月28日 02:00–04:00),随后伊朗反击(06:00–12:00)
|
||||
const israelTargets = [
|
||||
[50.88, 34.64, '库姆', '2026-02-28T02:00:00.000Z'],
|
||||
[50.876409, 34.625448, '伊朗专家会议秘书处', '2026-02-28T02:02:00.000Z'],
|
||||
[51.916, 33.666, '纳坦兹', '2026-02-28T02:04:00.000Z'],
|
||||
[51.002, 35.808, '卡拉季无人机厂', '2026-02-28T02:06:00.000Z'],
|
||||
]
|
||||
const lincolnTargets = [
|
||||
[56.27, 27.18, '阿巴斯港海军司令部', '2026-02-28T02:15:00.000Z'],
|
||||
[57.08, 27.13, '米纳布', '2026-02-28T02:18:00.000Z'],
|
||||
[56.5, 27.0, '霍尔木兹岸防阵地', '2026-02-28T02:19:00.000Z'],
|
||||
[50.838, 28.968, '布什尔雷达站', '2026-02-28T02:20:00.000Z'],
|
||||
[51.667, 32.654, '伊斯法罕核设施', '2026-02-28T02:22:00.000Z'],
|
||||
]
|
||||
const fordTargets = [
|
||||
[51.42, 35.69, '哈梅内伊官邸', '2026-02-28T02:30:00.000Z'],
|
||||
[51.41, 35.72, '总统府/情报部', '2026-02-28T02:32:00.000Z'],
|
||||
[51.15, 35.69, '梅赫拉巴德机场', '2026-02-28T02:34:00.000Z'],
|
||||
[46.29, 38.08, '大不里士空军基地', '2026-02-28T02:38:00.000Z'],
|
||||
[47.076, 34.314, '克尔曼沙赫导弹掩体', '2026-02-28T02:36:00.000Z'],
|
||||
[46.42, 33.64, '伊拉姆导弹阵地', '2026-02-28T02:40:00.000Z'],
|
||||
[48.35, 33.48, '霍拉马巴德储备库', '2026-02-28T02:42:00.000Z'],
|
||||
]
|
||||
israelTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('israel', lng, lat, name, struckAt))
|
||||
lincolnTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('lincoln', lng, lat, name, struckAt))
|
||||
fordTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('ford', lng, lat, name, struckAt))
|
||||
|
||||
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, drones, missiles, helicopters, submarines, tanks, carriers, civilian_ships, airport_port) VALUES
|
||||
|
||||
@@ -54,14 +54,33 @@ function getSituation() {
|
||||
const powerIr = db.prepare('SELECT * FROM power_index WHERE side = ?').get('iran')
|
||||
const assetsUs = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('us')
|
||||
const assetsIr = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('iran')
|
||||
const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us')
|
||||
const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran')
|
||||
const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('us')
|
||||
const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('iran')
|
||||
let mapStrikeSources = []
|
||||
let mapStrikeLines = []
|
||||
try {
|
||||
mapStrikeSources = db.prepare('SELECT id, name, lng, lat FROM map_strike_source').all()
|
||||
const lines = db.prepare('SELECT source_id, target_lng, target_lat, target_name, struck_at FROM map_strike_line ORDER BY source_id, struck_at').all()
|
||||
const bySource = {}
|
||||
for (const row of lines) {
|
||||
if (!bySource[row.source_id]) bySource[row.source_id] = []
|
||||
bySource[row.source_id].push({
|
||||
lng: row.target_lng,
|
||||
lat: row.target_lat,
|
||||
name: row.target_name || '',
|
||||
struck_at: row.struck_at || null,
|
||||
})
|
||||
}
|
||||
mapStrikeLines = Object.entries(bySource).map(([sourceId, targets]) => ({ sourceId, targets }))
|
||||
} catch (_) {}
|
||||
const attackedTargets = (locUs || []).filter((l) => l.status === 'attacked').map((l) => [l.lng, l.lat])
|
||||
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
|
||||
const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran')
|
||||
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 LIMIT 50').all()
|
||||
// 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新
|
||||
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
|
||||
|
||||
let conflictEvents = []
|
||||
@@ -155,8 +174,12 @@ function getSituation() {
|
||||
})),
|
||||
conflictStats,
|
||||
civilianCasualtiesTotal,
|
||||
// 顶层聚合,便于 sit.combatLosses.us / sit.combatLosses.iran 与 usForces/iranForces 内保持一致
|
||||
combatLosses: { us: usLosses, iran: irLosses },
|
||||
mapData: {
|
||||
attackedTargets,
|
||||
strikeSources: mapStrikeSources,
|
||||
strikeLines: mapStrikeLines,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ function getStats() {
|
||||
let likeCount = realLikeCount
|
||||
let display = null
|
||||
try {
|
||||
display = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
display = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||
} catch (_) {}
|
||||
if (display) {
|
||||
const useOverride = display && display.override_enabled === 1
|
||||
if (useOverride && display) {
|
||||
if (display.viewers != null) viewers = toNum(display.viewers)
|
||||
if (display.cumulative != null) cumulative = toNum(display.cumulative)
|
||||
if (display.share_count != null) shareCount = toNum(display.share_count)
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ForceSummaryRow {
|
||||
}
|
||||
|
||||
export interface DisplayStatsRow {
|
||||
overrideEnabled?: boolean
|
||||
viewers: number
|
||||
cumulative: number
|
||||
shareCount: number
|
||||
@@ -140,7 +141,10 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSu
|
||||
}
|
||||
|
||||
/** 传 null 的字段会清除覆盖,改回实时统计 */
|
||||
export async function putDisplayStats(body: Partial<{ [K in keyof DisplayStatsRow]: number | null }>): Promise<void> {
|
||||
/** 传 clearOverride: true 可关闭覆盖、恢复实时统计 */
|
||||
export async function putDisplayStats(
|
||||
body: Partial<{ [K in keyof DisplayStatsRow]: number | null }> & { clearOverride?: boolean }
|
||||
): Promise<void> {
|
||||
const res = await fetch('/api/edit/display-stats', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { StatCard } from './StatCard'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { useStatsStore } from '@/store/statsStore'
|
||||
@@ -39,6 +39,8 @@ export function HeaderPanel() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
const [likes, setLikes] = useState(getStoredLikes)
|
||||
const [liked, setLiked] = useState(false)
|
||||
const [likeBurst, setLikeBurst] = useState(0)
|
||||
const pendingLikesRef = useRef(0)
|
||||
const stats = useStatsStore((s) => s.stats)
|
||||
const setStats = useStatsStore((s) => s.setStats)
|
||||
const viewers = stats.viewers ?? 0
|
||||
@@ -140,10 +142,7 @@ export function HeaderPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (liked || likeSending) return
|
||||
setLiked(true)
|
||||
setLikeSending(true)
|
||||
const sendOneLike = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/like', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
@@ -159,21 +158,28 @@ export function HeaderPanel() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_LIKES, String(data.likeCount))
|
||||
} catch {}
|
||||
} else {
|
||||
const next = likes + 1
|
||||
setLikes(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_LIKES, String(next))
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
const next = likes + 1
|
||||
setLikes(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_LIKES, String(next))
|
||||
} catch {}
|
||||
setLikes((prev) => Math.max(0, prev - 1))
|
||||
} finally {
|
||||
setLikeSending(false)
|
||||
if (pendingLikesRef.current > 0) {
|
||||
pendingLikesRef.current -= 1
|
||||
sendOneLike()
|
||||
} else {
|
||||
setLikeSending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLike = () => {
|
||||
setLiked(true)
|
||||
setLikes((prev) => (serverLikeCount ?? prev) + 1)
|
||||
setLikeBurst((n) => n + 1)
|
||||
if (!likeSending) {
|
||||
setLikeSending(true)
|
||||
sendOneLike()
|
||||
} else {
|
||||
pendingLikesRef.current += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,9 +219,14 @@ export function HeaderPanel() {
|
||||
{formatDateTime(now)}
|
||||
</span>
|
||||
</div>
|
||||
{(isConnected || isReplayMode) && (
|
||||
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
|
||||
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
|
||||
{/* 非回放时显示数据更新时间,与后端 situation.updated_at 一致(爬虫 notify / 编辑保存时后端更新并广播) */}
|
||||
{isReplayMode ? (
|
||||
<span className="text-[10px] text-military-accent">
|
||||
{formatDataTime(situation.lastUpdated)} (回放)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-green-500/90">
|
||||
{formatDataTime(situation.lastUpdated)} (实时更新)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,14 +257,25 @@ export function HeaderPanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLike}
|
||||
disabled={likeSending}
|
||||
className={`flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors sm:px-2 sm:py-1 sm:text-[10px] disabled:opacity-50 ${
|
||||
className={`relative flex shrink-0 select-none items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors active:scale-95 sm:px-2 sm:py-1 sm:text-[10px] ${
|
||||
liked
|
||||
? 'border-red-500/50 bg-red-500/20 text-red-400'
|
||||
: 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 ${liked ? 'fill-current' : ''}`} />
|
||||
<span className="relative inline-flex">
|
||||
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 transition-transform duration-150 ${liked ? 'fill-current' : ''} ${likeBurst ? 'scale-125' : ''}`} />
|
||||
{likeBurst > 0 && (
|
||||
<span
|
||||
key={likeBurst}
|
||||
className="absolute -top-1 -right-1 min-w-[14px] rounded bg-red-500 px-0.5 text-[10px] font-bold text-white"
|
||||
style={{ animation: 'likePop 0.5s ease-out forwards' }}
|
||||
onAnimationEnd={() => setLikeBurst(0)}
|
||||
>
|
||||
+1
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
点赞 {(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? likes}</span>}
|
||||
</button>
|
||||
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function RetaliationGauge({ value, history, className = '' }: Retaliation
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#EF4444',
|
||||
formatter: '{value}',
|
||||
formatter: (val: number) => Number(val).toFixed(2),
|
||||
},
|
||||
data: [{ value }],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||
import { usePlaybackStore, getTicks, REPLAY_START, REPLAY_END, type ReplayScale } from '@/store/playbackStore'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { NewsTicker } from './NewsTicker'
|
||||
import { config } from '@/config'
|
||||
@@ -33,16 +33,20 @@ export function TimelinePanel() {
|
||||
const {
|
||||
isReplayMode,
|
||||
playbackTime,
|
||||
replayScale,
|
||||
isPlaying,
|
||||
speedSecPerTick,
|
||||
setReplayMode,
|
||||
setPlaybackTime,
|
||||
setReplayScale,
|
||||
setIsPlaying,
|
||||
stepForward,
|
||||
stepBack,
|
||||
setSpeed,
|
||||
} = usePlaybackStore()
|
||||
|
||||
const replayTicks = getTicks(replayScale)
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,27 +66,30 @@ export function TimelinePanel() {
|
||||
return
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
const current = usePlaybackStore.getState().playbackTime
|
||||
const i = REPLAY_TICKS.indexOf(current)
|
||||
if (i >= REPLAY_TICKS.length - 1) {
|
||||
const { playbackTime: current, replayScale: scale } = usePlaybackStore.getState()
|
||||
const ticks = getTicks(scale)
|
||||
const i = ticks.indexOf(current)
|
||||
if (i >= ticks.length - 1) {
|
||||
setIsPlaying(false)
|
||||
return
|
||||
}
|
||||
setPlaybackTime(REPLAY_TICKS[i + 1])
|
||||
setPlaybackTime(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 index = replayTicks.indexOf(playbackTime)
|
||||
const value = index >= 0 ? index : replayTicks.length - 1
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const i = parseInt(e.target.value, 10)
|
||||
setPlaybackTime(REPLAY_TICKS[i])
|
||||
setPlaybackTime(replayTicks[i])
|
||||
}
|
||||
|
||||
const scaleLabels: Record<ReplayScale, string> = { '30m': '30分钟', '1h': '1小时', '1d': '1天' }
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
||||
{!isReplayMode && (
|
||||
@@ -142,7 +149,7 @@ export function TimelinePanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={stepForward}
|
||||
disabled={index >= REPLAY_TICKS.length - 1}
|
||||
disabled={index >= replayTicks.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="下一步"
|
||||
>
|
||||
@@ -150,21 +157,45 @@ export function TimelinePanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
|
||||
<div className="flex items-center gap-1 text-[11px] text-military-text-secondary">
|
||||
<span>时间刻度</span>
|
||||
<select
|
||||
value={replayScale}
|
||||
onChange={(e) => setReplayScale(e.target.value as ReplayScale)}
|
||||
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"
|
||||
title="回放刻度"
|
||||
>
|
||||
{(['30m', '1h', '1d'] as const).map((s) => (
|
||||
<option key={s} value={s}>{scaleLabels[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:min-w-[320px]">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={REPLAY_TICKS.length - 1}
|
||||
max={replayTicks.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"
|
||||
className="h-1.5 w-full 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>
|
||||
{/* 按刻度划分的时间轴:均匀取约 5~6 个刻度标签 */}
|
||||
<div className="flex justify-between text-[10px] tabular-nums text-military-text-secondary">
|
||||
{replayTicks.length <= 1 ? (
|
||||
<span>{formatTick(replayTicks[0] ?? REPLAY_START)}</span>
|
||||
) : (
|
||||
(() => {
|
||||
const n = replayTicks.length - 1
|
||||
const maxLabels = 6
|
||||
const step = Math.max(1, Math.floor(n / (maxLabels - 1)))
|
||||
const indices = [0, ...Array.from({ length: maxLabels - 2 }, (_, j) => Math.min((j + 1) * step, n)), n]
|
||||
return [...new Set(indices)].map((i) => (
|
||||
<span key={i}>{formatTick(replayTicks[i])}</span>
|
||||
))
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
|
||||
@@ -4,17 +4,8 @@ import type { MapRef } from 'react-map-gl'
|
||||
import type { Map as MapboxMap } from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||
import { usePlaybackStore } from '@/store/playbackStore'
|
||||
import { config } from '@/config'
|
||||
import {
|
||||
ATTACKED_TARGETS,
|
||||
ALLIED_STRIKE_LOCATIONS,
|
||||
LINCOLN_COORDS,
|
||||
LINCOLN_STRIKE_TARGETS,
|
||||
FORD_COORDS,
|
||||
FORD_STRIKE_TARGETS,
|
||||
ISRAEL_STRIKE_SOURCE,
|
||||
ISRAEL_STRIKE_TARGETS,
|
||||
} from '@/data/mapLocations'
|
||||
import { EXTENDED_WAR_ZONES } from '@/data/extendedWarData'
|
||||
|
||||
const MAPBOX_TOKEN = config.mapboxAccessToken || ''
|
||||
@@ -65,8 +56,45 @@ const ALLIES_ADMIN = [
|
||||
// 伊朗攻击源 德黑兰 [lng, lat]
|
||||
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
|
||||
|
||||
// 真主党打击源(黎巴嫩南部大致位置),用于绘制向以色列北部的攻击矢量
|
||||
const HEZBOLLAH_SOURCE: [number, number] = [35.3, 33.2]
|
||||
// API 未返回 mapData 时的静态 fallback,保证美/以打击线与动画不消失(与 server/seed.js 一致)
|
||||
const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [
|
||||
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
|
||||
{ id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 },
|
||||
{ id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 },
|
||||
]
|
||||
const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: number; name?: string }[] }[] = [
|
||||
{
|
||||
sourceId: 'israel',
|
||||
targets: [
|
||||
{ lng: 50.88, lat: 34.64, name: '库姆' },
|
||||
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
||||
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
||||
{ lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'lincoln',
|
||||
targets: [
|
||||
{ lng: 56.27, lat: 27.18, name: '阿巴斯港海军司令部' },
|
||||
{ lng: 57.08, lat: 27.13, name: '米纳布' },
|
||||
{ lng: 56.5, lat: 27.0, name: '霍尔木兹岸防阵地' },
|
||||
{ lng: 50.838, lat: 28.968, name: '布什尔雷达站' },
|
||||
{ lng: 51.667, lat: 32.654, name: '伊斯法罕核设施' },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'ford',
|
||||
targets: [
|
||||
{ lng: 51.42, lat: 35.69, name: '哈梅内伊官邸' },
|
||||
{ lng: 51.41, lat: 35.72, name: '总统府/情报部' },
|
||||
{ lng: 51.15, lat: 35.69, name: '梅赫拉巴德机场' },
|
||||
{ lng: 46.29, lat: 38.08, name: '大不里士空军基地' },
|
||||
{ lng: 47.076, lat: 34.314, name: '克尔曼沙赫导弹掩体' },
|
||||
{ lng: 46.42, lat: 33.64, name: '伊拉姆导弹阵地' },
|
||||
{ lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 二次贝塞尔曲线路径,更平滑的弧线 height 控制弧高 */
|
||||
function parabolaPath(
|
||||
@@ -157,6 +185,7 @@ export function WarMap() {
|
||||
const hezbollahPathsRef = useRef<[number, number][][]>([])
|
||||
const hormuzPathsRef = useRef<[number, number][][]>([])
|
||||
const situation = useReplaySituation()
|
||||
const { isReplayMode } = usePlaybackStore()
|
||||
const { usForces, iranForces, conflictEvents = [] } = situation
|
||||
|
||||
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
||||
@@ -196,30 +225,52 @@ export function WarMap() {
|
||||
}
|
||||
}, [usForces.keyLocations, iranForces.keyLocations])
|
||||
|
||||
// 德黑兰到 27 个被袭目标的攻击路径(静态线条)
|
||||
const mapData = situation.mapData
|
||||
const attackedTargets = mapData?.attackedTargets ?? []
|
||||
const strikeSources =
|
||||
mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES
|
||||
const strikeLines =
|
||||
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
|
||||
|
||||
const attackPaths = useMemo(
|
||||
() => ATTACKED_TARGETS.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])),
|
||||
[]
|
||||
() => attackedTargets.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])),
|
||||
[attackedTargets]
|
||||
)
|
||||
|
||||
attackPathsRef.current = attackPaths
|
||||
|
||||
const lincolnPaths = useMemo(
|
||||
() => LINCOLN_STRIKE_TARGETS.map((t) => parabolaPath(LINCOLN_COORDS, t)),
|
||||
[]
|
||||
)
|
||||
const fordPaths = useMemo(
|
||||
() => FORD_STRIKE_TARGETS.map((t) => parabolaPath(FORD_COORDS, t)),
|
||||
[]
|
||||
)
|
||||
const israelPaths = useMemo(
|
||||
() => ISRAEL_STRIKE_TARGETS.map((t) => parabolaPath(ISRAEL_STRIKE_SOURCE, t)),
|
||||
[]
|
||||
)
|
||||
// 真主党 → 以色列北部三处目标(低平弧线)
|
||||
const sourceCoords = useMemo(() => {
|
||||
const m: Record<string, [number, number]> = {}
|
||||
strikeSources.forEach((s) => { m[s.id] = [s.lng, s.lat] })
|
||||
return m
|
||||
}, [strikeSources])
|
||||
|
||||
const lincolnPaths = useMemo(() => {
|
||||
const line = strikeLines.find((l) => l.sourceId === 'lincoln')
|
||||
const coords = sourceCoords.lincoln
|
||||
if (!coords || !line) return []
|
||||
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
|
||||
}, [strikeLines, sourceCoords])
|
||||
const fordPaths = useMemo(() => {
|
||||
const line = strikeLines.find((l) => l.sourceId === 'ford')
|
||||
const coords = sourceCoords.ford
|
||||
if (!coords || !line) return []
|
||||
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
|
||||
}, [strikeLines, sourceCoords])
|
||||
const israelPaths = useMemo(() => {
|
||||
const line = strikeLines.find((l) => l.sourceId === 'israel')
|
||||
const coords = sourceCoords.israel
|
||||
if (!coords || !line) return []
|
||||
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
|
||||
}, [strikeLines, sourceCoords])
|
||||
// 真主党 → 以色列北部三处目标(与美/以打击弧线一致:同一 parabola 高度与动画方式)
|
||||
const hezbollahSource = EXTENDED_WAR_ZONES.hezbollahStrikeSource
|
||||
const hezbollahPaths = useMemo(
|
||||
() => EXTENDED_WAR_ZONES.activeAttacks.map((t) => parabolaPath(HEZBOLLAH_SOURCE, t.coords, 1.5)),
|
||||
[]
|
||||
() =>
|
||||
isReplayMode
|
||||
? []
|
||||
: EXTENDED_WAR_ZONES.activeAttacks.map((t) => parabolaPath(hezbollahSource, t.coords, 3)),
|
||||
[hezbollahSource, isReplayMode]
|
||||
)
|
||||
// 伊朗不同地点 → 霍尔木兹海峡多点攻击(黄色轨迹)
|
||||
const hormuzTargetPoints = useMemo(
|
||||
@@ -232,6 +283,7 @@ export function WarMap() {
|
||||
[]
|
||||
)
|
||||
const hormuzPaths = useMemo(() => {
|
||||
if (isReplayMode) return []
|
||||
// 使用更远的伊朗腹地/纵深位置,弧线更明显
|
||||
const sources: [number, number][] = [
|
||||
TEHRAN_SOURCE, // 德黑兰
|
||||
@@ -241,7 +293,7 @@ export function WarMap() {
|
||||
return hormuzTargetPoints.map((target, idx) =>
|
||||
parabolaPath(sources[idx % sources.length], target, 3)
|
||||
)
|
||||
}, [hormuzTargetPoints])
|
||||
}, [hormuzTargetPoints, isReplayMode])
|
||||
lincolnPathsRef.current = lincolnPaths
|
||||
fordPathsRef.current = fordPaths
|
||||
israelPathsRef.current = israelPaths
|
||||
@@ -319,28 +371,34 @@ export function WarMap() {
|
||||
|
||||
// 真主党当前攻击目标点
|
||||
const hezbollahTargetsGeoJson = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { name: t.name, type: t.type, damage: t.damage },
|
||||
geometry: { type: 'Point' as const, coordinates: t.coords },
|
||||
})),
|
||||
}),
|
||||
[]
|
||||
() =>
|
||||
isReplayMode
|
||||
? { type: 'FeatureCollection' as const, features: [] }
|
||||
: {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { name: t.name, type: t.type, damage: t.damage },
|
||||
geometry: { type: 'Point' as const, coordinates: t.coords },
|
||||
})),
|
||||
},
|
||||
[isReplayMode]
|
||||
)
|
||||
|
||||
// 霍尔木兹海峡被持续打击的海面目标(用于脉冲与标记)
|
||||
const hormuzTargetsGeoJson = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: hormuzTargetPoints.map((coords, idx) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { id: `H${idx + 1}` },
|
||||
geometry: { type: 'Point' as const, coordinates: coords },
|
||||
})),
|
||||
}),
|
||||
[hormuzTargetPoints]
|
||||
() =>
|
||||
isReplayMode
|
||||
? { type: 'FeatureCollection' as const, features: [] }
|
||||
: {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: hormuzTargetPoints.map((coords, idx) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { id: `H${idx + 1}` },
|
||||
geometry: { type: 'Point' as const, coordinates: coords },
|
||||
})),
|
||||
},
|
||||
[hormuzTargetPoints, isReplayMode]
|
||||
)
|
||||
|
||||
// 霍尔木兹海峡交战区 & 真主党势力范围(静态面)
|
||||
@@ -491,7 +549,7 @@ export function WarMap() {
|
||||
)
|
||||
israelSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
// 真主党打击以色列北部:橙红色光点,低平飞行
|
||||
// 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
|
||||
const hezSrc = map.getSource('hezbollah-strike-dots') as
|
||||
| { setData: (d: GeoJSON.FeatureCollection) => void }
|
||||
| undefined
|
||||
@@ -550,12 +608,12 @@ export function WarMap() {
|
||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
// 真主党攻击目标:橙红脉冲,效果与 allied-strike-targets 保持一致
|
||||
// 真主党攻击目标:橙红脉冲,与 allied-strike-targets 同一周期与半径
|
||||
if (map.getLayer('hezbollah-attack-targets-pulse')) {
|
||||
const cycle = 2000
|
||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||
const r = Math.max(0, 30 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.15))
|
||||
const r = Math.max(0, 35 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
|
||||
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
@@ -891,18 +949,18 @@ export function WarMap() {
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 真主党对以色列北部的攻击矢量线(低平红线) */}
|
||||
{/* 真主党对以色列北部的攻击矢量线(与林肯/福特/以色列线宽一致) */}
|
||||
<Source id="hezbollah-attack-lines" type="geojson" data={hezbollahLinesGeoJson}>
|
||||
<Layer
|
||||
id="hezbollah-attack-lines"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': 'rgba(248, 113, 113, 0.7)',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.6, 8, 1.2, 12, 2],
|
||||
'line-color': 'rgba(248, 113, 113, 0.45)',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
{/* 真主党打击光点(沿矢量路径移动) */}
|
||||
{/* 真主党打击光点(与林肯/福特/以色列光点半径与动画一致) */}
|
||||
<Source
|
||||
id="hezbollah-strike-dots"
|
||||
type="geojson"
|
||||
@@ -919,17 +977,17 @@ export function WarMap() {
|
||||
id="hezbollah-strike-dots-glow"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2.5, 8, 4.5, 12, 7],
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
|
||||
'circle-color': 'rgba(248, 113, 113, 0.6)',
|
||||
'circle-blur': 0.25,
|
||||
'circle-blur': 0.3,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="hezbollah-strike-dots-core"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 3.5],
|
||||
'circle-color': '#fb923c',
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
|
||||
'circle-color': '#F97316',
|
||||
'circle-stroke-width': 0.5,
|
||||
'circle-stroke-color': '#fff',
|
||||
}}
|
||||
@@ -1093,10 +1151,10 @@ export function WarMap() {
|
||||
type="geojson"
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: ALLIED_STRIKE_LOCATIONS.map((s) => ({
|
||||
features: (situation.iranForces?.keyLocations ?? []).map((s) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { name: s.name },
|
||||
geometry: { type: 'Point' as const, coordinates: s.coords },
|
||||
geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] },
|
||||
})),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// 仅用于前端展示,不参与任何真实评估
|
||||
|
||||
export const EXTENDED_WAR_ZONES = {
|
||||
// 1. 霍尔木兹海峡交战区 (Strait of Hormuz) — 多边形,包络海峡水道及两侧水域 [lng, lat]
|
||||
// 1. 霍尔木兹海峡交战区 — 伊朗国境线沿岸(波斯湾→海峡→阿曼湾)+ 阿曼穆桑代姆 + 波斯湾出口 [lng, lat]
|
||||
hormuzCombatZone: {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
name: '霍尔木兹海峡交战区',
|
||||
name_en: 'Strait of Hormuz Area',
|
||||
status: 'BLOCKED / ENGAGED',
|
||||
style: {
|
||||
fillColor: '#FFD700',
|
||||
@@ -18,24 +19,33 @@ export const EXTENDED_WAR_ZONES = {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [
|
||||
[
|
||||
[55.0, 25.0],
|
||||
[55.5, 25.4],
|
||||
[56.2, 26.0],
|
||||
[56.8, 26.6],
|
||||
[57.2, 27.0],
|
||||
[57.0, 27.4],
|
||||
[56.4, 27.2],
|
||||
[55.8, 26.6],
|
||||
[55.2, 25.9],
|
||||
[54.8, 25.4],
|
||||
[55.0, 25.0],
|
||||
[55.92, 27.02], // 波斯湾入口(伊朗西侧,近阿联酋水道)
|
||||
[56.12, 27.08], // 伊朗沿岸向东
|
||||
[56.27, 27.18], // 阿巴斯港一带(伊朗国境线)
|
||||
[56.35, 27.05], // 格什姆岛西北侧伊朗主陆
|
||||
[56.28, 26.92], // 格什姆岛北缘(伊朗海岸)
|
||||
[56.45, 26.88], // 格什姆东侧水道(伊朗岸)
|
||||
[56.62, 26.78], // 伊朗沿岸向东
|
||||
[56.88, 26.58], // 米纳布方向(伊朗海岸)
|
||||
[57.08, 26.42], // 锡里克(Sirik)附近伊朗国境线
|
||||
[57.38, 25.88], // 库角(Ras al Kuh)前
|
||||
[57.52, 25.72], // 库角(Ras al Kuh)
|
||||
[57.77, 25.64], // 贾斯克(Jask)— 阿曼湾开口伊朗侧
|
||||
[56.26, 25.61], // 迪巴(Dibba)— 阿曼湾开口阿曼侧
|
||||
[56.34, 25.92], // 穆桑代姆东海岸
|
||||
[56.38, 26.18], // 穆桑代姆东海岸
|
||||
[56.4, 26.35], // 拉斯·穆桑代姆最北端
|
||||
[56.24, 26.22], // 穆桑代姆西侧狭窄水道
|
||||
[56.08, 26.0], // 穆桑代姆西海岸
|
||||
[55.96, 26.05], // 波斯湾出口(阿曼/阿联酋侧)
|
||||
[55.92, 27.02], // 闭合:回到起点
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 霍尔木兹区域标注点(多边形中心附近,用于显示文字)
|
||||
hormuzLabelCenter: [56.0, 26.2] as [number, number],
|
||||
hormuzLabelCenter: [56.5, 26.35] as [number, number],
|
||||
|
||||
// 2. 真主党势力范围 (Hezbollah) — 黎巴嫩南部 + 贝卡谷地,多边形 [lng, lat]
|
||||
hezbollahZone: {
|
||||
@@ -81,6 +91,9 @@ export const EXTENDED_WAR_ZONES = {
|
||||
// 真主党区域标注点(用于显示文字)
|
||||
hezbollahLabelCenter: [35.7, 33.7] as [number, number],
|
||||
|
||||
// 真主党打击源(黎巴嫩南部,与势力范围一致,用于攻击矢量起点)[lng, lat]
|
||||
hezbollahStrikeSource: [35.32, 33.28] as [number, number],
|
||||
|
||||
// 3. 真主党当前攻击目标 (North Israel Targets)
|
||||
activeAttacks: [
|
||||
{
|
||||
|
||||
@@ -1,188 +1,15 @@
|
||||
/** 航母标记 - 全部中文 */
|
||||
export const CARRIER_MARKERS = [
|
||||
{
|
||||
id: 'CVN-72',
|
||||
name: '林肯号航母',
|
||||
coordinates: [58.4215, 24.1568] as [number, number],
|
||||
type: 'Aircraft Carrier',
|
||||
status: 'Active - Combat Readiness',
|
||||
details: '林肯号航母打击群 (CSG-3) 部署于北阿拉伯海。',
|
||||
},
|
||||
{
|
||||
id: 'CVN-78',
|
||||
name: '福特号航母',
|
||||
coordinates: [24.1002, 35.7397] as [number, number],
|
||||
type: 'Aircraft Carrier',
|
||||
status: 'Active - Forward Deployed',
|
||||
details: '距克里特苏达湾约 15 公里。',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 地图点位类型定义。实际数据来自 API(getSituation 的 usForces.keyLocations / iranForces.keyLocations 与 mapData)。
|
||||
*/
|
||||
export type KeyLocItem = {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
type?: string
|
||||
region?: string
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
/** 遭袭时间 ISO 字符串,用于状态管理与数据回放 */
|
||||
attacked_at?: string | null
|
||||
}
|
||||
|
||||
/** 美军基地总数 62,被袭击 27 个。损毁程度:严重 6 / 中度 12 / 轻度 9 */
|
||||
const ATTACKED_BASES = [
|
||||
// 严重损毁 (6): 高价值目标,近伊朗
|
||||
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, region: '卡塔尔', damage_level: 3 },
|
||||
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, region: '伊拉克', damage_level: 3 },
|
||||
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, region: '土耳其', damage_level: 3 },
|
||||
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, region: '沙特', damage_level: 3 },
|
||||
// 中度损毁 (12)
|
||||
{ name: '塔吉军营', lat: 33.556, lng: 44.256, region: '伊拉克', damage_level: 2 },
|
||||
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, region: '伊拉克', damage_level: 2 },
|
||||
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, region: '叙利亚', damage_level: 2 },
|
||||
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, region: '科威特', damage_level: 2 },
|
||||
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, region: '科威特', damage_level: 2 },
|
||||
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, region: '巴林', damage_level: 2 },
|
||||
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, region: '阿联酋', damage_level: 2 },
|
||||
{ name: '埃斯康村', lat: 24.774, lng: 46.738, region: '沙特', damage_level: 2 },
|
||||
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, region: '以色列', damage_level: 2 },
|
||||
// 轻度损毁 (9)
|
||||
{ name: '布林军营', lat: 29.603, lng: 47.456, region: '科威特', damage_level: 1 },
|
||||
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, region: '卡塔尔', damage_level: 1 },
|
||||
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, region: '以色列', damage_level: 1 },
|
||||
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, region: '约旦', damage_level: 1 },
|
||||
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, region: '土耳其', damage_level: 1 },
|
||||
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, region: '阿曼', damage_level: 1 },
|
||||
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, region: '阿曼', damage_level: 1 },
|
||||
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, region: '埃及', damage_level: 1 },
|
||||
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, region: '吉布提', damage_level: 1 },
|
||||
]
|
||||
|
||||
/** 35 个新增 operational 基地 */
|
||||
const NEW_BASES: KeyLocItem[] = [
|
||||
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔' },
|
||||
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林' },
|
||||
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特' },
|
||||
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特' },
|
||||
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋' },
|
||||
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋' },
|
||||
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特' },
|
||||
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特' },
|
||||
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特' },
|
||||
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克' },
|
||||
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克' },
|
||||
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克' },
|
||||
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克' },
|
||||
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚' },
|
||||
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚' },
|
||||
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦' },
|
||||
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其' },
|
||||
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列' },
|
||||
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼' },
|
||||
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼' },
|
||||
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及' },
|
||||
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及' },
|
||||
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提' },
|
||||
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔' },
|
||||
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特' },
|
||||
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克' },
|
||||
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚' },
|
||||
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦' },
|
||||
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其' },
|
||||
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列' },
|
||||
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼' },
|
||||
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及' },
|
||||
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提' },
|
||||
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋' },
|
||||
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克' },
|
||||
]
|
||||
|
||||
/** 美军全部地图点位:2 航母 + 9 海军 + 62 基地 */
|
||||
export const US_KEY_LOCATIONS: KeyLocItem[] = [
|
||||
...CARRIER_MARKERS.map((c) => ({
|
||||
name: c.name + ` (${c.id})`,
|
||||
lat: c.coordinates[1],
|
||||
lng: c.coordinates[0],
|
||||
type: 'Aircraft Carrier' as const,
|
||||
region: c.id === 'CVN-72' ? '北阿拉伯海' : '东地中海',
|
||||
status: 'operational' as const,
|
||||
damage_level: undefined as number | undefined,
|
||||
})),
|
||||
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
|
||||
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
|
||||
...ATTACKED_BASES.map((b) => ({
|
||||
...b,
|
||||
type: 'Base' as const,
|
||||
status: 'attacked' as const,
|
||||
})),
|
||||
...NEW_BASES,
|
||||
]
|
||||
|
||||
/** 被袭击的 27 个基地坐标 [lng, lat],用于绘制攻击曲线 */
|
||||
export const ATTACKED_TARGETS: [number, number][] = ATTACKED_BASES.map((b) => [b.lng, b.lat])
|
||||
|
||||
/** 美以联军打击伊朗目标 (2026.03.01) - 中文标注,coords [lng, lat] */
|
||||
export const ALLIED_STRIKE_LOCATIONS = [
|
||||
// 1. 核心指挥与政治中枢
|
||||
{ name: '哈梅内伊官邸', coords: [51.42, 35.69] as [number, number], type: 'Leadership' },
|
||||
{ name: '总统府/情报部', coords: [51.41, 35.72] as [number, number], type: 'Leadership' },
|
||||
{ name: '梅赫拉巴德机场', coords: [51.15, 35.69] as [number, number], type: 'Leadership' },
|
||||
{ name: '库姆', coords: [50.88, 34.64] as [number, number], type: 'Leadership' },
|
||||
// 2. 核设施与战略研究点
|
||||
{ name: '伊斯法罕核设施', coords: [51.667, 32.654] as [number, number], type: 'Nuclear' },
|
||||
{ name: '纳坦兹', coords: [51.916, 33.666] as [number, number], type: 'Nuclear' },
|
||||
{ name: '布什尔雷达站', coords: [50.838, 28.968] as [number, number], type: 'Nuclear' },
|
||||
// 3. 导弹与无人机基地
|
||||
{ name: '卡拉季无人机厂', coords: [51.002, 35.808] as [number, number], type: 'UAV' },
|
||||
{ name: '克尔曼沙赫导弹掩体', coords: [47.076, 34.314] as [number, number], type: 'Missile' },
|
||||
{ name: '大不里士空军基地', coords: [46.29, 38.08] as [number, number], type: 'Missile' },
|
||||
{ name: '伊拉姆导弹阵地', coords: [46.42, 33.64] as [number, number], type: 'Missile' },
|
||||
{ name: '霍拉马巴德储备库', coords: [48.35, 33.48] as [number, number], type: 'Missile' },
|
||||
// 4. 海军与南部封锁节点
|
||||
{ name: '阿巴斯港海军司令部', coords: [56.27, 27.18] as [number, number], type: 'Naval' },
|
||||
{ name: '米纳布', coords: [57.08, 27.13] as [number, number], type: 'Naval' },
|
||||
{ name: '霍尔木兹岸防阵地', coords: [56.5, 27.0] as [number, number], type: 'Naval' },
|
||||
]
|
||||
|
||||
/** 盟军打击目标坐标 [lng, lat] */
|
||||
export const ALLIED_STRIKE_TARGETS: [number, number][] = ALLIED_STRIKE_LOCATIONS.map((s) => s.coords)
|
||||
|
||||
/** 林肯号航母位置 [lng, lat] - 北阿拉伯海 */
|
||||
export const LINCOLN_COORDS: [number, number] = [58.4215, 24.1568]
|
||||
/** 福特号航母位置 [lng, lat] - 东地中海 */
|
||||
export const FORD_COORDS: [number, number] = [24.1002, 35.7397]
|
||||
/** 以色列打击源 [lng, lat] - 特拉维夫附近 */
|
||||
export const ISRAEL_STRIKE_SOURCE: [number, number] = [34.78, 32.08]
|
||||
|
||||
/** 林肯号打击目标:南部海军/核设施 */
|
||||
export const LINCOLN_STRIKE_TARGETS: [number, number][] = [
|
||||
[56.27, 27.18], [57.08, 27.13], [56.5, 27.0], // 阿巴斯港、米纳布、霍尔木兹
|
||||
[50.838, 28.968], [51.667, 32.654], // 布什尔、伊斯法罕
|
||||
]
|
||||
/** 福特号打击目标:北部/西部 */
|
||||
export const FORD_STRIKE_TARGETS: [number, number][] = [
|
||||
[51.42, 35.69], [51.41, 35.72], [51.15, 35.69], // 德黑兰核心
|
||||
[46.29, 38.08], [47.076, 34.314], [46.42, 33.64], [48.35, 33.48], // 大不里士、克尔曼沙赫、伊拉姆、霍拉马巴德
|
||||
]
|
||||
/** 以色列打击目标:中部核设施/指挥 */
|
||||
export const ISRAEL_STRIKE_TARGETS: [number, number][] = [
|
||||
[50.88, 34.64], [51.916, 33.666], [51.002, 35.808], // 库姆、纳坦兹、卡拉季
|
||||
]
|
||||
|
||||
export const IRAN_KEY_LOCATIONS: KeyLocItem[] = [
|
||||
{ name: '阿巴斯港', lat: 27.1832, lng: 56.2666, type: 'Port', region: '伊朗' },
|
||||
{ name: '德黑兰', lat: 35.6892, lng: 51.389, type: 'Capital', region: '伊朗' },
|
||||
{ name: '布什尔', lat: 28.9681, lng: 50.838, type: 'Base', region: '伊朗' },
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// TypeScript interfaces for military situation data
|
||||
import { US_KEY_LOCATIONS, IRAN_KEY_LOCATIONS } from './mapLocations'
|
||||
|
||||
export interface ForceAsset {
|
||||
id: string
|
||||
@@ -90,6 +89,8 @@ export interface MilitarySituation {
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
/** 遭袭时间 ISO 字符串,用于状态管理与回放 */
|
||||
attacked_at?: string | null
|
||||
}[]
|
||||
combatLosses: CombatLosses
|
||||
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
|
||||
@@ -108,6 +109,7 @@ export interface MilitarySituation {
|
||||
id?: number
|
||||
status?: 'operational' | 'damaged' | 'attacked'
|
||||
damage_level?: number
|
||||
attacked_at?: string | null
|
||||
}[]
|
||||
combatLosses: CombatLosses
|
||||
/** 反击情绪指标 0-100 */
|
||||
@@ -122,6 +124,15 @@ export interface MilitarySituation {
|
||||
conflictStats?: ConflictStats
|
||||
/** 平民伤亡合计(不区分阵营) */
|
||||
civilianCasualtiesTotal?: { killed: number; wounded: number }
|
||||
/** 地图打击数据(来自 DB:被袭美军基地、打击源、源→目标连线;含攻击时间便于回放) */
|
||||
mapData?: {
|
||||
attackedTargets: [number, number][]
|
||||
strikeSources: { id: string; name: string; lng: number; lat: number }[]
|
||||
strikeLines: {
|
||||
sourceId: string
|
||||
targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[]
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
@@ -154,7 +165,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
{ id: 'us-8', name: 'MQ-9 死神', type: '无人机', count: 28, status: 'active' },
|
||||
{ id: 'us-9', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' },
|
||||
],
|
||||
keyLocations: US_KEY_LOCATIONS,
|
||||
keyLocations: [],
|
||||
combatLosses: {
|
||||
bases: { destroyed: 0, damaged: 2 },
|
||||
personnelCasualties: { killed: 127, wounded: 384 },
|
||||
@@ -211,7 +222,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
{ id: 'ir-8', name: '法塔赫 (Fattah)', type: '导弹', count: 12, status: 'alert' },
|
||||
{ id: 'ir-9', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' },
|
||||
],
|
||||
keyLocations: IRAN_KEY_LOCATIONS,
|
||||
keyLocations: [],
|
||||
combatLosses: {
|
||||
bases: { destroyed: 3, damaged: 8 },
|
||||
personnelCasualties: { killed: 2847, wounded: 5620 },
|
||||
@@ -274,4 +285,74 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||
conflictEvents: [],
|
||||
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||
civilianCasualtiesTotal: { killed: 430, wounded: 1255 },
|
||||
// 与 server/seed.js 一致,首屏与未连 API 时也有打击数据,前端可正常绘制攻击线与动画
|
||||
mapData: {
|
||||
attackedTargets: [
|
||||
[42.441, 33.785],
|
||||
[44.366, 33.315],
|
||||
[51.314, 25.117],
|
||||
[43.963, 36.237],
|
||||
[35.425, 37.002],
|
||||
[47.58, 24.062],
|
||||
[44.256, 33.556],
|
||||
[42.45, 33.8],
|
||||
[38.618, 33.49],
|
||||
[40.73, 36.058],
|
||||
[40.295, 35.336],
|
||||
[41.885, 37.015],
|
||||
[47.799, 28.832],
|
||||
[47.52, 29.346],
|
||||
[50.608, 26.236],
|
||||
[54.547, 24.248],
|
||||
[46.738, 24.774],
|
||||
[35.012, 31.208],
|
||||
[47.456, 29.603],
|
||||
[51.52, 25.275],
|
||||
[34.666, 30.776],
|
||||
[36.259, 32.356],
|
||||
[37.794, 38.354],
|
||||
[54.024, 17.666],
|
||||
[58.89, 20.675],
|
||||
[30.298, 30.915],
|
||||
[43.159, 11.547],
|
||||
] as [number, number][],
|
||||
strikeSources: [
|
||||
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
|
||||
{ id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 },
|
||||
{ id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 },
|
||||
],
|
||||
strikeLines: [
|
||||
{
|
||||
sourceId: 'israel',
|
||||
targets: [
|
||||
{ lng: 50.88, lat: 34.64, name: '库姆' },
|
||||
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
||||
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
||||
{ lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'lincoln',
|
||||
targets: [
|
||||
{ lng: 56.27, lat: 27.18, name: '阿巴斯港海军司令部' },
|
||||
{ lng: 57.08, lat: 27.13, name: '米纳布' },
|
||||
{ lng: 56.5, lat: 27.0, name: '霍尔木兹岸防阵地' },
|
||||
{ lng: 50.838, lat: 28.968, name: '布什尔雷达站' },
|
||||
{ lng: 51.667, lat: 32.654, name: '伊斯法罕核设施' },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'ford',
|
||||
targets: [
|
||||
{ lng: 51.42, lat: 35.69, name: '哈梅内伊官邸' },
|
||||
{ lng: 51.41, lat: 35.72, name: '总统府/情报部' },
|
||||
{ lng: 51.15, lat: 35.69, name: '梅赫拉巴德机场' },
|
||||
{ lng: 46.29, lat: 38.08, name: '大不里士空军基地' },
|
||||
{ lng: 47.076, lat: 34.314, name: '克尔曼沙赫导弹掩体' },
|
||||
{ lng: 46.42, lat: 33.64, name: '伊拉姆导弹阵地' },
|
||||
{ lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { usePlaybackStore } from '@/store/playbackStore'
|
||||
import { usePlaybackStore, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||
|
||||
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
|
||||
/** 盟军打击伊朗结束时刻:伊朗战损在此阶段增长 */
|
||||
const ALLIED_STRIKE_END = '2026-02-28T04:00:00.000Z'
|
||||
/** 伊朗反击开始时刻:美军战损在此阶段增长 */
|
||||
const IRAN_RETALIATION_START = '2026-02-28T06:00:00.000Z'
|
||||
|
||||
/** 将系列时间映射到回放日以便按当天时刻插值 */
|
||||
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 atOrBefore(ts: string | null | undefined, playbackTime: string): boolean {
|
||||
if (!ts) return true
|
||||
return new Date(ts).getTime() <= new Date(playbackTime).getTime()
|
||||
}
|
||||
|
||||
function interpolateAt(
|
||||
series: { time: string; value: number }[],
|
||||
at: string,
|
||||
@@ -43,7 +54,7 @@ function linearProgress(start: string, end: string, at: string): number {
|
||||
return (ta - ts) / (te - ts)
|
||||
}
|
||||
|
||||
/** 根据回放时刻派生态势数据 */
|
||||
/** 根据回放时刻派生态势数据:按日期刻度过滤,只显示 attacked_at / struck_at / timestamp 不晚于 playbackTime 的数据库数据 */
|
||||
export function useReplaySituation(): MilitarySituation {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||
@@ -51,86 +62,110 @@ export function useReplaySituation(): MilitarySituation {
|
||||
return useMemo(() => {
|
||||
if (!isReplayMode) return situation
|
||||
|
||||
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
|
||||
// 战损阶段:伊朗(盟军打击 02:00–04:00)先行,美军(伊朗反击 06:00 起)随后
|
||||
const progressIran = linearProgress(REPLAY_START, ALLIED_STRIKE_END, playbackTime)
|
||||
const progressUs = linearProgress(IRAN_RETALIATION_START, REPLAY_END, playbackTime)
|
||||
const lerpIran = (a: number, b: number) => Math.round(a + progressIran * (b - a))
|
||||
const lerpUs = (a: number, b: number) => Math.round(a + progressUs * (b - a))
|
||||
|
||||
// 华尔街趋势、反击情绪:按时间插值
|
||||
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
|
||||
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
|
||||
// 华尔街趋势、反击情绪:按回放日当天的时刻插值,保留两位小数避免穿模
|
||||
const replayDay = playbackTime.slice(0, 10)
|
||||
const wsValue = Math.round(interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime, replayDay) * 100) / 100
|
||||
const retValue = Math.round(interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime, replayDay) * 100) / 100
|
||||
|
||||
// 战斗损失:从 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),
|
||||
destroyed: lerpUs(0, usLoss.bases.destroyed),
|
||||
damaged: lerpUs(0, usLoss.bases.damaged),
|
||||
},
|
||||
personnelCasualties: {
|
||||
killed: lerp(0, usLoss.personnelCasualties.killed),
|
||||
wounded: lerp(0, usLoss.personnelCasualties.wounded),
|
||||
killed: lerpUs(0, usLoss.personnelCasualties.killed),
|
||||
wounded: lerpUs(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),
|
||||
drones: lerp(0, usLoss.drones ?? 0),
|
||||
missiles: lerp(0, usLoss.missiles ?? 0),
|
||||
helicopters: lerp(0, usLoss.helicopters ?? 0),
|
||||
submarines: lerp(0, usLoss.submarines ?? 0),
|
||||
carriers: lerp(0, usLoss.carriers ?? 0),
|
||||
civilianShips: lerp(0, usLoss.civilianShips ?? 0),
|
||||
airportPort: lerp(0, usLoss.airportPort ?? 0),
|
||||
aircraft: lerpUs(0, usLoss.aircraft),
|
||||
warships: lerpUs(0, usLoss.warships),
|
||||
armor: lerpUs(0, usLoss.armor),
|
||||
vehicles: lerpUs(0, usLoss.vehicles),
|
||||
drones: lerpUs(0, usLoss.drones ?? 0),
|
||||
missiles: lerpUs(0, usLoss.missiles ?? 0),
|
||||
helicopters: lerpUs(0, usLoss.helicopters ?? 0),
|
||||
submarines: lerpUs(0, usLoss.submarines ?? 0),
|
||||
carriers: lerpUs(0, usLoss.carriers ?? 0),
|
||||
civilianShips: lerpUs(0, usLoss.civilianShips ?? 0),
|
||||
airportPort: lerpUs(0, usLoss.airportPort ?? 0),
|
||||
}
|
||||
const irLossesAt = {
|
||||
bases: {
|
||||
destroyed: lerp(0, irLoss.bases.destroyed),
|
||||
damaged: lerp(0, irLoss.bases.damaged),
|
||||
destroyed: lerpIran(0, irLoss.bases.destroyed),
|
||||
damaged: lerpIran(0, irLoss.bases.damaged),
|
||||
},
|
||||
personnelCasualties: {
|
||||
killed: lerp(0, irLoss.personnelCasualties.killed),
|
||||
wounded: lerp(0, irLoss.personnelCasualties.wounded),
|
||||
killed: lerpIran(0, irLoss.personnelCasualties.killed),
|
||||
wounded: lerpIran(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),
|
||||
drones: lerp(0, irLoss.drones ?? 0),
|
||||
missiles: lerp(0, irLoss.missiles ?? 0),
|
||||
helicopters: lerp(0, irLoss.helicopters ?? 0),
|
||||
submarines: lerp(0, irLoss.submarines ?? 0),
|
||||
carriers: lerp(0, irLoss.carriers ?? 0),
|
||||
civilianShips: lerp(0, irLoss.civilianShips ?? 0),
|
||||
airportPort: lerp(0, irLoss.airportPort ?? 0),
|
||||
aircraft: lerpIran(0, irLoss.aircraft),
|
||||
warships: lerpIran(0, irLoss.warships),
|
||||
armor: lerpIran(0, irLoss.armor),
|
||||
vehicles: lerpIran(0, irLoss.vehicles),
|
||||
drones: lerpIran(0, irLoss.drones ?? 0),
|
||||
missiles: lerpIran(0, irLoss.missiles ?? 0),
|
||||
helicopters: lerpIran(0, irLoss.helicopters ?? 0),
|
||||
submarines: lerpIran(0, irLoss.submarines ?? 0),
|
||||
carriers: lerpIran(0, irLoss.carriers ?? 0),
|
||||
civilianShips: lerpIran(0, irLoss.civilianShips ?? 0),
|
||||
airportPort: lerpIran(0, irLoss.airportPort ?? 0),
|
||||
}
|
||||
|
||||
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
|
||||
// 被袭基地:按 DB attacked_at 过滤,只显示已到回放时刻的遭袭点
|
||||
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)) {
|
||||
if (loc.status === 'attacked' && !atOrBefore(loc.attacked_at, playbackTime)) {
|
||||
return { ...loc, status: 'operational' as const }
|
||||
}
|
||||
return { ...loc }
|
||||
})
|
||||
|
||||
const irLocs = situation.iranForces.keyLocations || []
|
||||
const irLocsAt = irLocs.map((loc) => {
|
||||
if ((loc.status === 'attacked' || loc.status === 'damaged') && !atOrBefore(loc.attacked_at, playbackTime)) {
|
||||
return { ...loc, status: 'operational' as const }
|
||||
}
|
||||
return { ...loc }
|
||||
})
|
||||
|
||||
// mapData:按 struck_at / attacked_at 过滤;打击线固定顺序 以色列 → 林肯 → 福特,保证回放时以色列动画先出现
|
||||
const mapData = situation.mapData
|
||||
const mapDataAt = mapData
|
||||
? (() => {
|
||||
const filtered = mapData.strikeLines.map((line) => ({
|
||||
sourceId: line.sourceId,
|
||||
targets: line.targets.filter((t) => atOrBefore(t.struck_at, playbackTime)),
|
||||
}))
|
||||
const order = ['israel', 'lincoln', 'ford']
|
||||
const ordered = order.map((id) => filtered.find((l) => l.sourceId === id)).filter(Boolean) as typeof filtered
|
||||
const rest = filtered.filter((l) => !order.includes(l.sourceId))
|
||||
const strikeLines = [...ordered, ...rest]
|
||||
return {
|
||||
attackedTargets: (usLocsAt.filter((l) => l.status === 'attacked') as { lng: number; lat: number }[]).map(
|
||||
(l) => [l.lng, l.lat] as [number, number]
|
||||
),
|
||||
strikeSources: mapData.strikeSources,
|
||||
strikeLines,
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...situation,
|
||||
lastUpdated: playbackTime,
|
||||
civilianCasualtiesTotal: {
|
||||
killed: lerp(0, civTotal.killed),
|
||||
wounded: lerp(0, civTotal.wounded),
|
||||
killed: lerpIran(0, civTotal.killed),
|
||||
wounded: lerpIran(0, civTotal.wounded),
|
||||
},
|
||||
usForces: {
|
||||
...situation.usForces,
|
||||
@@ -143,6 +178,7 @@ export function useReplaySituation(): MilitarySituation {
|
||||
},
|
||||
iranForces: {
|
||||
...situation.iranForces,
|
||||
keyLocations: irLocsAt,
|
||||
combatLosses: irLossesAt,
|
||||
retaliationSentiment: retValue,
|
||||
retaliationSentimentHistory: [
|
||||
@@ -153,8 +189,11 @@ export function useReplaySituation(): MilitarySituation {
|
||||
recentUpdates: (situation.recentUpdates || []).filter(
|
||||
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
|
||||
),
|
||||
conflictEvents: situation.conflictEvents || [],
|
||||
conflictEvents: (situation.conflictEvents || []).filter(
|
||||
(e) => new Date(e.event_time).getTime() <= new Date(playbackTime).getTime()
|
||||
),
|
||||
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||
mapData: mapDataAt,
|
||||
}
|
||||
}, [situation, isReplayMode, playbackTime])
|
||||
}
|
||||
|
||||
@@ -61,5 +61,16 @@ body,
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes likePop {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.5) translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端横屏使用单列+滚动,不再做 zoom 缩放,保持比例正常 */
|
||||
|
||||
|
||||
@@ -190,13 +190,7 @@ export function EditDashboard() {
|
||||
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数,看过=累计访问等)。')) return
|
||||
setSaving('displayStats')
|
||||
try {
|
||||
await putDisplayStats({
|
||||
viewers: null,
|
||||
cumulative: null,
|
||||
shareCount: null,
|
||||
likeCount: null,
|
||||
feedbackCount: null,
|
||||
})
|
||||
await putDisplayStats({ clearOverride: true })
|
||||
await load()
|
||||
const res = await fetch('/api/stats', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
@@ -263,10 +257,13 @@ export function EditDashboard() {
|
||||
{openSections.has('displayStats') && data && (
|
||||
<div className="border-t border-military-border p-4 space-y-3">
|
||||
<p className="text-military-text-secondary text-xs">
|
||||
统计方式:在看 = 近 2 分钟内不同设备数(按设备 ID 去重);看过 = 累计访问次数。手动填写会覆盖实时值;点「恢复实时统计」可清除覆盖。
|
||||
{data.displayStats?.overrideEnabled
|
||||
? '当前看板显示为覆盖值。'
|
||||
: '当前看板显示为实时统计。'}
|
||||
在看 = 近 2 分钟访问数,看过 = 累计访问。保存后开启覆盖;点「恢复实时统计」关闭覆盖。
|
||||
</p>
|
||||
<DisplayStatsForm
|
||||
row={data.displayStats ?? { viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
|
||||
row={data.displayStats ?? { overrideEnabled: false, viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
|
||||
onSave={handleSaveDisplayStats}
|
||||
onClearOverrides={handleClearDisplayStatsOverrides}
|
||||
saving={saving === 'displayStats'}
|
||||
|
||||
@@ -1,39 +1,51 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const REPLAY_DAY = '2026-03-01'
|
||||
const TICK_MS = 30 * 60 * 1000 // 30 minutes
|
||||
/** 回放日期范围:覆盖 2 月 28 日(盟军打击伊朗 → 伊朗反击),与 DB attacked_at / struck_at 对齐 */
|
||||
const REPLAY_START_ISO = '2026-02-28T00:00:00.000Z'
|
||||
const REPLAY_END_ISO = '2026-03-01T23:30:00.000Z'
|
||||
|
||||
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
|
||||
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
|
||||
export const REPLAY_START = REPLAY_START_ISO
|
||||
export const REPLAY_END = REPLAY_END_ISO
|
||||
|
||||
export type ReplayScale = '30m' | '1h' | '1d'
|
||||
|
||||
const SCALE_MS: Record<ReplayScale, number> = {
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'1d': 24 * 60 * 60 * 1000,
|
||||
}
|
||||
|
||||
function parseTime(iso: string): number {
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
export function getTicks(): string[] {
|
||||
/** 按刻度生成回放时间点;不传 scale 时默认 30 分钟 */
|
||||
export function getTicks(scale: ReplayScale = '30m'): string[] {
|
||||
const step = SCALE_MS[scale]
|
||||
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
|
||||
t += step
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
export const REPLAY_TICKS = getTicks()
|
||||
|
||||
export interface PlaybackState {
|
||||
/** 是否开启回放模式 */
|
||||
isReplayMode: boolean
|
||||
/** 当前回放时刻 (ISO) */
|
||||
playbackTime: string
|
||||
/** 回放刻度:30分钟 / 1小时 / 1天 */
|
||||
replayScale: ReplayScale
|
||||
/** 是否正在自动播放 */
|
||||
isPlaying: boolean
|
||||
/** 播放速度 (秒/刻度) */
|
||||
speedSecPerTick: number
|
||||
setReplayMode: (v: boolean) => void
|
||||
setPlaybackTime: (iso: string) => void
|
||||
setReplayScale: (scale: ReplayScale) => void
|
||||
setIsPlaying: (v: boolean) => void
|
||||
stepForward: () => void
|
||||
stepBack: () => void
|
||||
@@ -43,13 +55,20 @@ export interface PlaybackState {
|
||||
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
isReplayMode: false,
|
||||
playbackTime: REPLAY_END,
|
||||
replayScale: '30m',
|
||||
isPlaying: false,
|
||||
speedSecPerTick: 2,
|
||||
|
||||
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
|
||||
setReplayMode: (v) =>
|
||||
set({
|
||||
isReplayMode: v,
|
||||
isPlaying: false,
|
||||
...(v ? { playbackTime: REPLAY_START } : {}),
|
||||
}),
|
||||
|
||||
setPlaybackTime: (iso) => {
|
||||
const ticks = REPLAY_TICKS
|
||||
const { replayScale } = get()
|
||||
const ticks = getTicks(replayScale)
|
||||
if (ticks.includes(iso)) {
|
||||
set({ playbackTime: iso })
|
||||
return
|
||||
@@ -59,19 +78,27 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
set({ playbackTime: ticks[clamp] })
|
||||
},
|
||||
|
||||
setReplayScale: (scale) => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = getTicks(scale)
|
||||
const idx = ticks.findIndex((t) => t >= playbackTime)
|
||||
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
|
||||
set({ replayScale: scale, playbackTime: ticks[clamp] })
|
||||
},
|
||||
|
||||
setIsPlaying: (v) => set({ isPlaying: v }),
|
||||
|
||||
stepForward: () => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = REPLAY_TICKS
|
||||
const { playbackTime, replayScale } = get()
|
||||
const ticks = getTicks(replayScale)
|
||||
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 { playbackTime, replayScale } = get()
|
||||
const ticks = getTicks(replayScale)
|
||||
const i = ticks.indexOf(playbackTime)
|
||||
if (i > 0) set({ playbackTime: ticks[i - 1] })
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
// situation.lastUpdated 与后端 situation.updated_at 一致,后端在爬虫 notify、编辑保存时更新并广播
|
||||
function pollSituation() {
|
||||
fetchSituation()
|
||||
.then((situation) => useSituationStore.getState().setSituation(situation))
|
||||
|
||||
4
start.sh
4
start.sh
@@ -9,8 +9,8 @@ cd "$(dirname "$0")"
|
||||
# 若存在 .env 则加载(可在此设置 DASHSCOPE_API_KEY=sk-xxx,勿提交 .env)
|
||||
[ -f .env ] && set -a && . ./.env && set +a
|
||||
|
||||
# AI 模式:有 DASHSCOPE_API_KEY 时用通义(商业模型,无需 Ollama);否则用 Ollama 或规则
|
||||
export CLEANER_AI_DISABLED=0
|
||||
# 面板实时数据:1=仅用规则提取战损/据点(无需 Ollama),0=用 Ollama/通义。有 DASHSCOPE_API_KEY 时自动用通义
|
||||
export CLEANER_AI_DISABLED=1
|
||||
export PARSER_AI_DISABLED=0
|
||||
# GDELT 国内常超时,仅用 RSS 更新(如需 GDELT 可改为 0)
|
||||
export GDELT_DISABLED=1
|
||||
|
||||
@@ -22,11 +22,18 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('error', () => {}) // 后端未启动时静默失败
|
||||
// 抑制关标签/刷新时代理底层 socket 的 ECONNRESET,避免刷屏
|
||||
proxy.on('proxyReqWs', (proxyReq, req, socket, head) => {
|
||||
socket?.on?.('error', () => {})
|
||||
const out = (proxyReq as { socket?: { on?: (e: string, fn: () => void) => void } })?.socket
|
||||
out?.on?.('error', () => {})
|
||||
// 吞掉 ECONNRESET,防止 Vite 打印 "ws proxy socket error"(关标签/刷新时常见)
|
||||
function swallowEconnreset(s: { emit?: (ev: string, ...a: unknown[]) => boolean } | null) {
|
||||
if (!s?.emit) return
|
||||
const orig = s.emit.bind(s)
|
||||
s.emit = function (ev: string, ...a: unknown[]) {
|
||||
if (ev === 'error' && (a[0] as { code?: string })?.code === 'ECONNRESET') return false
|
||||
return orig(ev, ...a)
|
||||
}
|
||||
}
|
||||
proxy.on('proxyReqWs', (proxyReq, _req, socket, _head) => {
|
||||
swallowEconnreset(socket ?? null)
|
||||
swallowEconnreset((proxyReq as { socket?: unknown })?.socket ?? null)
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user