This commit is contained in:
Daniel
2026-03-04 16:48:17 +08:00
parent 64f4c438c3
commit 26938449f0
34 changed files with 956 additions and 500 deletions

View File

@@ -14,16 +14,17 @@
**数据偏老原因**:未传 `timespan``sort=datedesc`API 返回 3 个月内“最相关”文章,不保证最新。 **数据偏老原因**:未传 `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 等) | | 过滤 | 标题/摘要需含 `KEYWORDS` 之一iran、usa、strike、military 等) |
| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 | | 更新 | 爬虫 `RSS_INTERVAL_SEC` 拉取;每 `BACKFILL_CYCLES` 轮会从近期事件回填一次战损/据点,保证面板数据与最新内容一致。 |
| 优先级 | 启动时先拉 RSS再拉 GDELT |
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS 新闻即可维持事件脉络。部分境外源可能受网络限制 **GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS部分境外源可能需代理
### 3. AI 新闻清洗与分类(可选) ### 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 | | **地图冲突点** (conflictEvents) | gdelt_events 或 RSS→gdelt 回填 | ✅ 是 | GDELT 或 GDELT 禁用时由 situation_update 同步到 gdelt_events |
| **战损/装备毁伤** (combatLosses) | combat_losses | ⚠️ 有条件 | 仅当 AI/规则从新闻中提取到数字如「2 名美军死亡」merge 才写入增量 | | **战损/装备毁伤** (combatLosses) | combat_losses | ⚠️ 有条件 | 仅当 AI/规则从新闻中提取到数字如「2 名美军死亡」merge 才写入增量 |
| **基地/地点状态** (keyLocations) | key_location | ⚠️ 有条件 | 仅当提取到 key_location_updates如某基地遭袭时更新 | | **基地/地点状态** (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 初始化,爬虫不写 | | **力量摘要/指数/资产** (summary, powerIndex, assets) | force_summary, power_index, force_asset | ❌ 否 | 仅 seed 初始化,爬虫不写 |
| **华尔街/报复情绪** (wallStreet, retaliation) | wall_street_trend, retaliation_* | ⚠️ 有条件 | 仅当提取器输出对应字段时更新 | | **华尔街/报复情绪** (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 精确提取 + 增量与地点更新」优化后,单条新闻从输入到前端展示的完整示例,便于对照验证。 以下为「正文抓取 + 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 等条目。 期望:`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) ## 冲突强度 (impact_score)

View File

@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
将 AI 提取的结构化数据合并到 SQLite 将 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 os
import sqlite3 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_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 retaliation_history (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS situation (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, updated_at TEXT NOT NULL)") conn.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() conn.commit()
@@ -183,6 +219,41 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
updated = True updated = True
except Exception: except Exception:
pass 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: 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.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
conn.commit() conn.commit()

View File

@@ -4,6 +4,7 @@ import sqlite3
import hashlib import hashlib
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from config import DB_PATH from config import DB_PATH
@@ -73,14 +74,29 @@ def touch_situation_updated_at(conn: sqlite3.Connection) -> None:
conn.commit() 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"}, ...] 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 return 0
conn = sqlite3.connect(DB_PATH, timeout=10) conn = sqlite3.connect(path, timeout=10)
try: try:
count = 0 count = 0
for u in updates: for u in updates:

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timezone
from typing import Callable, Optional, Tuple from typing import Callable, Optional, Tuple
from config import DB_PATH, API_BASE from config import DB_PATH, API_BASE
from db_writer import touch_situation_updated_at_path
def _notify_api(api_base: str) -> bool: def _notify_api(api_base: str) -> bool:
@@ -172,15 +173,18 @@ def run_full_pipeline(
except Exception as e: except Exception as e:
print(f" [warn] 正文抓取: {e}") print(f" [warn] 正文抓取: {e}")
# 4. 映射到前端库字段并更新表 # 4. 映射到前端库字段并更新表(与去重/AI 使用同一 db path
n_panel = write_updates(new_items) if new_items else 0 n_panel = write_updates(new_items, db_path=path) if new_items else 0
if new_items: if new_items:
_extract_and_merge(new_items, path) _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: if on_notify:
on_notify() on_notify()
if notify and (n_panel > 0 or n_news > 0): if notify:
_notify_api(base) _notify_api(base)
return len(items), n_news, n_panel return len(items), n_news, n_panel

View File

@@ -297,8 +297,43 @@ def _rss_to_gdelt_fallback() -> None:
LAST_FETCH = {"items": 0, "inserted": 0, "error": 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: def fetch_news() -> None:
"""执行完整写库流水线GDELT 禁用时用 RSS 回填 gdelt_events,再通知 Node""" """执行完整写库流水线;产出看板实时数据(战损、据点、冲突事件)+ 事件脉络。GDELT 禁用时用 RSS 回填 gdelt_events。"""
try: try:
from pipeline import run_full_pipeline from pipeline import run_full_pipeline
LAST_FETCH["error"] = None LAST_FETCH["error"] = None
@@ -314,7 +349,7 @@ def fetch_news() -> None:
_rss_to_gdelt_fallback() _rss_to_gdelt_fallback()
_notify_node() _notify_node()
ts = datetime.now().strftime("%H:%M:%S") 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: if n_fetched == 0:
print(f"[{ts}] 0 条检查网络、RSS 源或 KEYWORDS 过滤)") print(f"[{ts}] 0 条检查网络、RSS 源或 KEYWORDS 过滤)")
except Exception as e: except Exception as e:
@@ -322,6 +357,10 @@ def fetch_news() -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻抓取失败: {e}") 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 关闭竞态) # 定时任务asyncio 后台任务,避免 APScheduler executor 关闭竞态)
# ========================== # ==========================
@@ -329,11 +368,20 @@ _bg_task: Optional[asyncio.Task] = None
async def _periodic_fetch() -> None: async def _periodic_fetch() -> None:
global _cycle_count
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
while True: while True:
try: try:
await loop.run_in_executor(None, fetch_news) await loop.run_in_executor(None, fetch_news)
await loop.run_in_executor(None, fetch_gdelt_events) 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: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:

View File

@@ -88,7 +88,6 @@ def fetch_all() -> list[dict]:
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
# 写入 DB 的 schema 不包含 source可后续扩展 items.append(item)
items.append({k: v for k, v in item.items() if k != "source"})
return items return items

39
scripts/check-db-and-crawler.sh Executable file
View 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

Binary file not shown.

0
server/data.db-wal Normal file
View File

View File

@@ -174,6 +174,7 @@ function runMigrations(db) {
if (!names.includes('region')) exec('ALTER TABLE key_location ADD COLUMN region TEXT') 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('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('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 (_) {} } catch (_) {}
try { try {
const lossCols = prepare('PRAGMA table_info(combat_losses)').all() 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); INSERT OR IGNORE INTO display_stats (id) VALUES (1);
`) `)
} catch (_) {} } 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() { async function initDb() {

View File

@@ -92,7 +92,7 @@ if (BROADCAST_INTERVAL_MS > 0) {
setInterval(() => broadcastSituation(false), BROADCAST_INTERVAL_MS) setInterval(() => broadcastSituation(false), BROADCAST_INTERVAL_MS)
} }
// 供爬虫调用:先从磁盘重载 DB纳入爬虫写入再更新 updated_at 并立即广播 // 供爬虫调用:先从磁盘重载 DB纳入爬虫写入再更新 situation.updated_at 并立即广播;前端据此显示「实时更新」时间
function notifyCrawlerUpdate() { function notifyCrawlerUpdate() {
try { try {
const db = require('./db') const db = require('./db')

View File

@@ -219,14 +219,14 @@ router.get('/edit/raw', (req, res) => {
try { try {
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us') 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 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 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 FROM key_location WHERE side = ?').all('iran') 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 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 summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran') const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
let displayStats = null let displayStats = null
try { 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 (_) {} } catch (_) {}
const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0 const 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 || [], situationUpdates: updates || [],
forceSummary: { us: summaryUs || null, iran: summaryIr || null }, forceSummary: { us: summaryUs || null, iran: summaryIr || null },
displayStats: { displayStats: {
overrideEnabled: displayStats?.override_enabled === 1,
viewers: displayStats?.viewers ?? liveViewers, viewers: displayStats?.viewers ?? liveViewers,
cumulative: displayStats?.cumulative ?? realCumulative, cumulative: displayStats?.cumulative ?? realCumulative,
shareCount: displayStats?.share_count ?? realShare, 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' }) 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) 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' }) 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 updates = []
const values = [] const values = []
for (const k of allowed) { 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) => { router.put('/edit/display-stats', (req, res) => {
try { try {
db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run() 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 updates = []
const values = [] const values = []
const setField = (key, bodyKey) => { const setField = (key, bodyKey) => {
const v = req.body?.[bodyKey ?? key] const v = body[bodyKey ?? key]
if (v === undefined) return if (v === undefined) return
if (v === null) { if (v === null) {
updates.push(`${key} = ?`) updates.push(`${key} = ?`)
@@ -408,6 +426,8 @@ router.put('/edit/display-stats', (req, res) => {
setField('like_count', 'likeCount') setField('like_count', 'likeCount')
setField('feedback_count', 'feedbackCount') setField('feedback_count', 'feedbackCount')
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' }) if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
updates.push('override_enabled = ?')
values.push(1)
values.push(1) values.push(1)
db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values) db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values)
broadcastAfterEdit(req) broadcastAfterEdit(req)

View File

@@ -3,84 +3,84 @@ const db = require('./db')
// 与 src/data/mapLocations.ts 同步62 基地27 被袭 (严重6 中度12 轻度9) // 与 src/data/mapLocations.ts 同步62 基地27 被袭 (严重6 中度12 轻度9)
function getUsLocations() { function getUsLocations() {
const naval = [ const naval = [
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
] ]
const attacked = [ const attacked = [
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 = [ const newBases = [
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
] ]
return [...naval, ...attacked, ...newBases] return [...naval, ...attacked, ...newBases]
} }
@@ -126,27 +126,74 @@ function seed() {
;[...usAssets, ...iranAssets].forEach((row) => insertAsset.run(...row)) ;[...usAssets, ...iranAssets].forEach((row) => insertAsset.run(...row))
const insertLoc = db.prepare(` 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') db.exec('DELETE FROM key_location')
for (const loc of getUsLocations()) { 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:0004:00回放时先出现
const iranLocs = [ const iranLocs = [
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3], ['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', 'operational', null, null],
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3], ['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Naval', '伊朗', 'attacked', 3, '2026-02-28T02:15:00.000Z'],
['iran', '布什尔核电站', 28.9681, 50.838, 'Nuclear', '伊朗', 'attacked', 2], ['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3, '2026-02-28T02:00:00.000Z'],
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2], ['iran', '哈梅内伊官邸', 35.69, 51.42, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:30:00.000Z'],
['iran', '纳坦兹铀浓缩', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2], ['iran', '总统府/情报部', 35.72, 51.41, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:32:00.000Z'],
['iran', '米纳布岸防', 27.13, 57.08, 'Base', '伊朗', 'damaged', 2], ['iran', '梅赫拉巴德机场', 35.69, 51.15, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:34:00.000Z'],
['iran', '卡拉季无人机厂', 35.808, 51.002, 'Base', '伊朗', 'attacked', 2], ['iran', '库姆', 34.64, 50.88, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:00:00.000Z'],
['iran', '克尔曼沙赫导弹阵地', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2], ['iran', '伊朗专家会议秘书处', 34.625448, 50.876409, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:02:00.000Z'],
['iran', '大不里士空军基地', 38.08, 46.29, 'Base', '伊朗', 'damaged', 1], ['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Base', '伊朗', 'operational', null], ['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)) 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:0004:00随后伊朗反击06:0012: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 { try {
db.exec(` 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 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

View File

@@ -54,14 +54,33 @@ function getSituation() {
const powerIr = db.prepare('SELECT * FROM power_index WHERE side = ?').get('iran') 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 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 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 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 FROM key_location WHERE side = ?').all('iran') 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 lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran') 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 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 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 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() 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() const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
let conflictEvents = [] let conflictEvents = []
@@ -155,8 +174,12 @@ function getSituation() {
})), })),
conflictStats, conflictStats,
civilianCasualtiesTotal, civilianCasualtiesTotal,
// 顶层聚合,便于 sit.combatLosses.us / sit.combatLosses.iran 与 usForces/iranForces 内保持一致
combatLosses: { us: usLosses, iran: irLosses }, combatLosses: { us: usLosses, iran: irLosses },
mapData: {
attackedTargets,
strikeSources: mapStrikeSources,
strikeLines: mapStrikeLines,
},
} }
} }

View File

@@ -24,9 +24,10 @@ function getStats() {
let likeCount = realLikeCount let likeCount = realLikeCount
let display = null let display = null
try { 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 (_) {} } catch (_) {}
if (display) { const useOverride = display && display.override_enabled === 1
if (useOverride && display) {
if (display.viewers != null) viewers = toNum(display.viewers) if (display.viewers != null) viewers = toNum(display.viewers)
if (display.cumulative != null) cumulative = toNum(display.cumulative) if (display.cumulative != null) cumulative = toNum(display.cumulative)
if (display.share_count != null) shareCount = toNum(display.share_count) if (display.share_count != null) shareCount = toNum(display.share_count)

View File

@@ -55,6 +55,7 @@ export interface ForceSummaryRow {
} }
export interface DisplayStatsRow { export interface DisplayStatsRow {
overrideEnabled?: boolean
viewers: number viewers: number
cumulative: number cumulative: number
shareCount: number shareCount: number
@@ -140,7 +141,10 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSu
} }
/** 传 null 的字段会清除覆盖,改回实时统计 */ /** 传 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', { const res = await fetch('/api/edit/display-stats', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { StatCard } from './StatCard' import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore' import { useSituationStore } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore' import { useStatsStore } from '@/store/statsStore'
@@ -39,6 +39,8 @@ export function HeaderPanel() {
const [now, setNow] = useState(() => new Date()) const [now, setNow] = useState(() => new Date())
const [likes, setLikes] = useState(getStoredLikes) const [likes, setLikes] = useState(getStoredLikes)
const [liked, setLiked] = useState(false) const [liked, setLiked] = useState(false)
const [likeBurst, setLikeBurst] = useState(0)
const pendingLikesRef = useRef(0)
const stats = useStatsStore((s) => s.stats) const stats = useStatsStore((s) => s.stats)
const setStats = useStatsStore((s) => s.setStats) const setStats = useStatsStore((s) => s.setStats)
const viewers = stats.viewers ?? 0 const viewers = stats.viewers ?? 0
@@ -140,10 +142,7 @@ export function HeaderPanel() {
} }
} }
const handleLike = async () => { const sendOneLike = async () => {
if (liked || likeSending) return
setLiked(true)
setLikeSending(true)
try { try {
const res = await fetch('/api/like', { method: 'POST' }) const res = await fetch('/api/like', { method: 'POST' })
const data = await res.json() const data = await res.json()
@@ -159,23 +158,30 @@ export function HeaderPanel() {
try { try {
localStorage.setItem(STORAGE_LIKES, String(data.likeCount)) localStorage.setItem(STORAGE_LIKES, String(data.likeCount))
} catch {} } catch {}
} else {
const next = likes + 1
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
} }
} catch { } catch {
const next = likes + 1 setLikes((prev) => Math.max(0, prev - 1))
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
} finally { } finally {
if (pendingLikesRef.current > 0) {
pendingLikesRef.current -= 1
sendOneLike()
} else {
setLikeSending(false) setLikeSending(false)
} }
} }
}
const handleLike = () => {
setLiked(true)
setLikes((prev) => (serverLikeCount ?? prev) + 1)
setLikeBurst((n) => n + 1)
if (!likeSending) {
setLikeSending(true)
sendOneLike()
} else {
pendingLikesRef.current += 1
}
}
const formatDateTime = (d: Date) => const formatDateTime = (d: Date) =>
d.toLocaleString('zh-CN', { d.toLocaleString('zh-CN', {
@@ -213,9 +219,14 @@ export function HeaderPanel() {
{formatDateTime(now)} {formatDateTime(now)}
</span> </span>
</div> </div>
{(isConnected || isReplayMode) && ( {/* 非回放时显示数据更新时间,与后端 situation.updated_at 一致(爬虫 notify / 编辑保存时后端更新并广播) */}
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}> {isReplayMode ? (
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'} <span className="text-[10px] text-military-accent">
{formatDataTime(situation.lastUpdated)} ()
</span>
) : (
<span className="text-[10px] text-green-500/90">
{formatDataTime(situation.lastUpdated)} ()
</span> </span>
)} )}
</div> </div>
@@ -246,14 +257,25 @@ export function HeaderPanel() {
<button <button
type="button" type="button"
onClick={handleLike} onClick={handleLike}
disabled={likeSending} 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] ${
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 ${
liked liked
? 'border-red-500/50 bg-red-500/20 text-red-400' ? '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' : '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>} {(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? likes}</span>}
</button> </button>
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}> <span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>

View File

@@ -56,7 +56,7 @@ export function RetaliationGauge({ value, history, className = '' }: Retaliation
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: 'bold',
color: '#EF4444', color: '#EF4444',
formatter: '{value}', formatter: (val: number) => Number(val).toFixed(2),
}, },
data: [{ value }], data: [{ value }],
}, },

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-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 { useSituationStore } from '@/store/situationStore'
import { NewsTicker } from './NewsTicker' import { NewsTicker } from './NewsTicker'
import { config } from '@/config' import { config } from '@/config'
@@ -33,16 +33,20 @@ export function TimelinePanel() {
const { const {
isReplayMode, isReplayMode,
playbackTime, playbackTime,
replayScale,
isPlaying, isPlaying,
speedSecPerTick, speedSecPerTick,
setReplayMode, setReplayMode,
setPlaybackTime, setPlaybackTime,
setReplayScale,
setIsPlaying, setIsPlaying,
stepForward, stepForward,
stepBack, stepBack,
setSpeed, setSpeed,
} = usePlaybackStore() } = usePlaybackStore()
const replayTicks = getTicks(replayScale)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => { useEffect(() => {
@@ -62,27 +66,30 @@ export function TimelinePanel() {
return return
} }
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
const current = usePlaybackStore.getState().playbackTime const { playbackTime: current, replayScale: scale } = usePlaybackStore.getState()
const i = REPLAY_TICKS.indexOf(current) const ticks = getTicks(scale)
if (i >= REPLAY_TICKS.length - 1) { const i = ticks.indexOf(current)
if (i >= ticks.length - 1) {
setIsPlaying(false) setIsPlaying(false)
return return
} }
setPlaybackTime(REPLAY_TICKS[i + 1]) setPlaybackTime(ticks[i + 1])
}, speedSecPerTick * 1000) }, speedSecPerTick * 1000)
return () => { return () => {
if (timerRef.current) clearInterval(timerRef.current) if (timerRef.current) clearInterval(timerRef.current)
} }
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying]) }, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
const index = REPLAY_TICKS.indexOf(playbackTime) const index = replayTicks.indexOf(playbackTime)
const value = index >= 0 ? index : REPLAY_TICKS.length - 1 const value = index >= 0 ? index : replayTicks.length - 1
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const i = parseInt(e.target.value, 10) 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 ( return (
<div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2"> <div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
{!isReplayMode && ( {!isReplayMode && (
@@ -142,7 +149,7 @@ export function TimelinePanel() {
<button <button
type="button" type="button"
onClick={stepForward} 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" 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="下一步" title="下一步"
> >
@@ -150,21 +157,45 @@ export function TimelinePanel() {
</button> </button>
</div> </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 <input
type="range" type="range"
min={0} min={0}
max={REPLAY_TICKS.length - 1} max={replayTicks.length - 1}
value={value} value={value}
onChange={handleSliderChange} 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"
/> />
{/* 按刻度划分的时间轴:均匀取约 56 个刻度标签 */}
<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>
<div className="flex items-center gap-2 text-[11px] tabular-nums text-military-text-secondary">
<span>{formatTick(REPLAY_START)}</span>
<span className="font-medium text-military-accent">{formatTick(playbackTime)}</span>
<span>{formatTick(REPLAY_END)}</span>
</div> </div>
<select <select

View File

@@ -4,17 +4,8 @@ import type { MapRef } from 'react-map-gl'
import type { Map as MapboxMap } from 'mapbox-gl' import type { Map as MapboxMap } from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import { useReplaySituation } from '@/hooks/useReplaySituation' import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore'
import { config } from '@/config' 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' import { EXTENDED_WAR_ZONES } from '@/data/extendedWarData'
const MAPBOX_TOKEN = config.mapboxAccessToken || '' const MAPBOX_TOKEN = config.mapboxAccessToken || ''
@@ -65,8 +56,45 @@ const ALLIES_ADMIN = [
// 伊朗攻击源 德黑兰 [lng, lat] // 伊朗攻击源 德黑兰 [lng, lat]
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892] const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
// 真主党打击源(黎巴嫩南部大致位置),用于绘制向以色列北部的攻击矢量 // API 未返回 mapData 时的静态 fallback保证美/以打击线与动画不消失(与 server/seed.js 一致)
const HEZBOLLAH_SOURCE: [number, number] = [35.3, 33.2] 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 控制弧高 */ /** 二次贝塞尔曲线路径,更平滑的弧线 height 控制弧高 */
function parabolaPath( function parabolaPath(
@@ -157,6 +185,7 @@ export function WarMap() {
const hezbollahPathsRef = useRef<[number, number][][]>([]) const hezbollahPathsRef = useRef<[number, number][][]>([])
const hormuzPathsRef = useRef<[number, number][][]>([]) const hormuzPathsRef = useRef<[number, number][][]>([])
const situation = useReplaySituation() const situation = useReplaySituation()
const { isReplayMode } = usePlaybackStore()
const { usForces, iranForces, conflictEvents = [] } = situation const { usForces, iranForces, conflictEvents = [] } = situation
const usLocs = (usForces.keyLocations || []) as KeyLoc[] const usLocs = (usForces.keyLocations || []) as KeyLoc[]
@@ -196,30 +225,52 @@ export function WarMap() {
} }
}, [usForces.keyLocations, iranForces.keyLocations]) }, [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( 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 attackPathsRef.current = attackPaths
const lincolnPaths = useMemo( const sourceCoords = useMemo(() => {
() => LINCOLN_STRIKE_TARGETS.map((t) => parabolaPath(LINCOLN_COORDS, t)), const m: Record<string, [number, number]> = {}
[] strikeSources.forEach((s) => { m[s.id] = [s.lng, s.lat] })
) return m
const fordPaths = useMemo( }, [strikeSources])
() => FORD_STRIKE_TARGETS.map((t) => parabolaPath(FORD_COORDS, t)),
[] const lincolnPaths = useMemo(() => {
) const line = strikeLines.find((l) => l.sourceId === 'lincoln')
const israelPaths = useMemo( const coords = sourceCoords.lincoln
() => ISRAEL_STRIKE_TARGETS.map((t) => parabolaPath(ISRAEL_STRIKE_SOURCE, t)), 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( 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( const hormuzTargetPoints = useMemo(
@@ -232,6 +283,7 @@ export function WarMap() {
[] []
) )
const hormuzPaths = useMemo(() => { const hormuzPaths = useMemo(() => {
if (isReplayMode) return []
// 使用更远的伊朗腹地/纵深位置,弧线更明显 // 使用更远的伊朗腹地/纵深位置,弧线更明显
const sources: [number, number][] = [ const sources: [number, number][] = [
TEHRAN_SOURCE, // 德黑兰 TEHRAN_SOURCE, // 德黑兰
@@ -241,7 +293,7 @@ export function WarMap() {
return hormuzTargetPoints.map((target, idx) => return hormuzTargetPoints.map((target, idx) =>
parabolaPath(sources[idx % sources.length], target, 3) parabolaPath(sources[idx % sources.length], target, 3)
) )
}, [hormuzTargetPoints]) }, [hormuzTargetPoints, isReplayMode])
lincolnPathsRef.current = lincolnPaths lincolnPathsRef.current = lincolnPaths
fordPathsRef.current = fordPaths fordPathsRef.current = fordPaths
israelPathsRef.current = israelPaths israelPathsRef.current = israelPaths
@@ -319,28 +371,34 @@ export function WarMap() {
// 真主党当前攻击目标点 // 真主党当前攻击目标点
const hezbollahTargetsGeoJson = useMemo( const hezbollahTargetsGeoJson = useMemo(
() => ({ () =>
isReplayMode
? { type: 'FeatureCollection' as const, features: [] }
: {
type: 'FeatureCollection' as const, type: 'FeatureCollection' as const,
features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({ features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { name: t.name, type: t.type, damage: t.damage }, properties: { name: t.name, type: t.type, damage: t.damage },
geometry: { type: 'Point' as const, coordinates: t.coords }, geometry: { type: 'Point' as const, coordinates: t.coords },
})), })),
}), },
[] [isReplayMode]
) )
// 霍尔木兹海峡被持续打击的海面目标(用于脉冲与标记) // 霍尔木兹海峡被持续打击的海面目标(用于脉冲与标记)
const hormuzTargetsGeoJson = useMemo( const hormuzTargetsGeoJson = useMemo(
() => ({ () =>
isReplayMode
? { type: 'FeatureCollection' as const, features: [] }
: {
type: 'FeatureCollection' as const, type: 'FeatureCollection' as const,
features: hormuzTargetPoints.map((coords, idx) => ({ features: hormuzTargetPoints.map((coords, idx) => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { id: `H${idx + 1}` }, properties: { id: `H${idx + 1}` },
geometry: { type: 'Point' as const, coordinates: coords }, geometry: { type: 'Point' as const, coordinates: coords },
})), })),
}), },
[hormuzTargetPoints] [hormuzTargetPoints, isReplayMode]
) )
// 霍尔木兹海峡交战区 & 真主党势力范围(静态面) // 霍尔木兹海峡交战区 & 真主党势力范围(静态面)
@@ -491,7 +549,7 @@ export function WarMap() {
) )
israelSrc.setData({ type: 'FeatureCollection', features }) israelSrc.setData({ type: 'FeatureCollection', features })
} }
// 真主党打击以色列北部:橙红光点,低平飞行 // 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
const hezSrc = map.getSource('hezbollah-strike-dots') as const hezSrc = map.getSource('hezbollah-strike-dots') as
| { setData: (d: GeoJSON.FeatureCollection) => void } | { setData: (d: GeoJSON.FeatureCollection) => void }
| undefined | undefined
@@ -550,12 +608,12 @@ export function WarMap() {
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r) map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity) map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
} }
// 真主党攻击目标:橙红脉冲,效果与 allied-strike-targets 保持一致 // 真主党攻击目标:橙红脉冲,与 allied-strike-targets 同一周期与半径
if (map.getLayer('hezbollah-attack-targets-pulse')) { if (map.getLayer('hezbollah-attack-targets-pulse')) {
const cycle = 2000 const cycle = 2000
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle)) const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
const r = Math.max(0, 30 * phase * zoomScale) const r = Math.max(0, 35 * phase * zoomScale)
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.15)) 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-radius', r)
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity) map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity)
} }
@@ -891,18 +949,18 @@ export function WarMap() {
/> />
</Source> </Source>
{/* 真主党对以色列北部的攻击矢量线(低平红线 */} {/* 真主党对以色列北部的攻击矢量线(与林肯/福特/以色列线宽一致 */}
<Source id="hezbollah-attack-lines" type="geojson" data={hezbollahLinesGeoJson}> <Source id="hezbollah-attack-lines" type="geojson" data={hezbollahLinesGeoJson}>
<Layer <Layer
id="hezbollah-attack-lines" id="hezbollah-attack-lines"
type="line" type="line"
paint={{ paint={{
'line-color': 'rgba(248, 113, 113, 0.7)', 'line-color': 'rgba(248, 113, 113, 0.45)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.6, 8, 1.2, 12, 2], 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}} }}
/> />
</Source> </Source>
{/* 真主党打击光点(沿矢量路径移动 */} {/* 真主党打击光点(与林肯/福特/以色列光点半径与动画一致 */}
<Source <Source
id="hezbollah-strike-dots" id="hezbollah-strike-dots"
type="geojson" type="geojson"
@@ -919,17 +977,17 @@ export function WarMap() {
id="hezbollah-strike-dots-glow" id="hezbollah-strike-dots-glow"
type="circle" type="circle"
paint={{ 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-color': 'rgba(248, 113, 113, 0.6)',
'circle-blur': 0.25, 'circle-blur': 0.3,
}} }}
/> />
<Layer <Layer
id="hezbollah-strike-dots-core" id="hezbollah-strike-dots-core"
type="circle" type="circle"
paint={{ paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 3.5], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#fb923c', 'circle-color': '#F97316',
'circle-stroke-width': 0.5, 'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff', 'circle-stroke-color': '#fff',
}} }}
@@ -1093,10 +1151,10 @@ export function WarMap() {
type="geojson" type="geojson"
data={{ data={{
type: 'FeatureCollection', type: 'FeatureCollection',
features: ALLIED_STRIKE_LOCATIONS.map((s) => ({ features: (situation.iranForces?.keyLocations ?? []).map((s) => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { name: s.name }, properties: { name: s.name },
geometry: { type: 'Point' as const, coordinates: s.coords }, geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] },
})), })),
}} }}
> >

View File

@@ -2,11 +2,12 @@
// 仅用于前端展示,不参与任何真实评估 // 仅用于前端展示,不参与任何真实评估
export const EXTENDED_WAR_ZONES = { export const EXTENDED_WAR_ZONES = {
// 1. 霍尔木兹海峡交战区 (Strait of Hormuz) — 多边形,包络海峡水道及两侧水域 [lng, lat] // 1. 霍尔木兹海峡交战区 — 伊朗国境线沿岸(波斯湾→海峡→阿曼湾)+ 阿曼穆桑代姆 + 波斯湾出口 [lng, lat]
hormuzCombatZone: { hormuzCombatZone: {
type: 'Feature' as const, type: 'Feature' as const,
properties: { properties: {
name: '霍尔木兹海峡交战区', name: '霍尔木兹海峡交战区',
name_en: 'Strait of Hormuz Area',
status: 'BLOCKED / ENGAGED', status: 'BLOCKED / ENGAGED',
style: { style: {
fillColor: '#FFD700', fillColor: '#FFD700',
@@ -18,24 +19,33 @@ export const EXTENDED_WAR_ZONES = {
type: 'Polygon' as const, type: 'Polygon' as const,
coordinates: [ coordinates: [
[ [
[55.0, 25.0], [55.92, 27.02], // 波斯湾入口(伊朗西侧,近阿联酋水道)
[55.5, 25.4], [56.12, 27.08], // 伊朗沿岸向东
[56.2, 26.0], [56.27, 27.18], // 阿巴斯港一带(伊朗国境线)
[56.8, 26.6], [56.35, 27.05], // 格什姆岛西北侧伊朗主陆
[57.2, 27.0], [56.28, 26.92], // 格什姆岛北缘(伊朗海岸)
[57.0, 27.4], [56.45, 26.88], // 格什姆东侧水道(伊朗岸)
[56.4, 27.2], [56.62, 26.78], // 伊朗沿岸向东
[55.8, 26.6], [56.88, 26.58], // 米纳布方向(伊朗海岸)
[55.2, 25.9], [57.08, 26.42], // 锡里克Sirik附近伊朗国境线
[54.8, 25.4], [57.38, 25.88], // 库角Ras al Kuh
[55.0, 25.0], [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] // 2. 真主党势力范围 (Hezbollah) — 黎巴嫩南部 + 贝卡谷地,多边形 [lng, lat]
hezbollahZone: { hezbollahZone: {
@@ -81,6 +91,9 @@ export const EXTENDED_WAR_ZONES = {
// 真主党区域标注点(用于显示文字) // 真主党区域标注点(用于显示文字)
hezbollahLabelCenter: [35.7, 33.7] as [number, number], hezbollahLabelCenter: [35.7, 33.7] as [number, number],
// 真主党打击源(黎巴嫩南部,与势力范围一致,用于攻击矢量起点)[lng, lat]
hezbollahStrikeSource: [35.32, 33.28] as [number, number],
// 3. 真主党当前攻击目标 (North Israel Targets) // 3. 真主党当前攻击目标 (North Israel Targets)
activeAttacks: [ activeAttacks: [
{ {

View File

@@ -1,188 +1,15 @@
/** 航母标记 - 全部中文 */ /**
export const CARRIER_MARKERS = [ * 地图点位类型定义。实际数据来自 APIgetSituation 的 usForces.keyLocations / iranForces.keyLocations 与 mapData
{ */
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 公里。',
},
]
export type KeyLocItem = { export type KeyLocItem = {
name: string name: string
lat: number lat: number
lng: number lng: number
type?: string type?: string
region?: string region?: string
id?: number
status?: 'operational' | 'damaged' | 'attacked' status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number 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: '伊朗' },
]

View File

@@ -1,5 +1,4 @@
// TypeScript interfaces for military situation data // TypeScript interfaces for military situation data
import { US_KEY_LOCATIONS, IRAN_KEY_LOCATIONS } from './mapLocations'
export interface ForceAsset { export interface ForceAsset {
id: string id: string
@@ -90,6 +89,8 @@ export interface MilitarySituation {
id?: number id?: number
status?: 'operational' | 'damaged' | 'attacked' status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number damage_level?: number
/** 遭袭时间 ISO 字符串,用于状态管理与回放 */
attacked_at?: string | null
}[] }[]
combatLosses: CombatLosses combatLosses: CombatLosses
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */ /** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
@@ -108,6 +109,7 @@ export interface MilitarySituation {
id?: number id?: number
status?: 'operational' | 'damaged' | 'attacked' status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number damage_level?: number
attacked_at?: string | null
}[] }[]
combatLosses: CombatLosses combatLosses: CombatLosses
/** 反击情绪指标 0-100 */ /** 反击情绪指标 0-100 */
@@ -122,6 +124,15 @@ export interface MilitarySituation {
conflictStats?: ConflictStats conflictStats?: ConflictStats
/** 平民伤亡合计(不区分阵营) */ /** 平民伤亡合计(不区分阵营) */
civilianCasualtiesTotal?: { killed: number; wounded: number } 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 = { 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-8', name: 'MQ-9 死神', type: '无人机', count: 28, status: 'active' },
{ id: 'us-9', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' }, { id: 'us-9', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' },
], ],
keyLocations: US_KEY_LOCATIONS, keyLocations: [],
combatLosses: { combatLosses: {
bases: { destroyed: 0, damaged: 2 }, bases: { destroyed: 0, damaged: 2 },
personnelCasualties: { killed: 127, wounded: 384 }, 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-8', name: '法塔赫 (Fattah)', type: '导弹', count: 12, status: 'alert' },
{ id: 'ir-9', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' }, { id: 'ir-9', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' },
], ],
keyLocations: IRAN_KEY_LOCATIONS, keyLocations: [],
combatLosses: { combatLosses: {
bases: { destroyed: 3, damaged: 8 }, bases: { destroyed: 3, damaged: 8 },
personnelCasualties: { killed: 2847, wounded: 5620 }, personnelCasualties: { killed: 2847, wounded: 5620 },
@@ -274,4 +285,74 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
conflictEvents: [], conflictEvents: [],
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }, conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
civilianCasualtiesTotal: { killed: 430, wounded: 1255 }, 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: '霍拉马巴德储备库' },
],
},
],
},
} }

View File

@@ -1,15 +1,26 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import type { MilitarySituation } from '@/data/mockData' import type { MilitarySituation } from '@/data/mockData'
import { useSituationStore } from '@/store/situationStore' 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 { function toReplayDay(iso: string, baseDay: string): string {
const d = new Date(iso) const d = new Date(iso)
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number) 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() 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( function interpolateAt(
series: { time: string; value: number }[], series: { time: string; value: number }[],
at: string, at: string,
@@ -43,7 +54,7 @@ function linearProgress(start: string, end: string, at: string): number {
return (ta - ts) / (te - ts) return (ta - ts) / (te - ts)
} }
/** 根据回放时刻派生态势数据 */ /** 根据回放时刻派生态势数据:按日期刻度过滤,只显示 attacked_at / struck_at / timestamp 不晚于 playbackTime 的数据库数据 */
export function useReplaySituation(): MilitarySituation { export function useReplaySituation(): MilitarySituation {
const situation = useSituationStore((s) => s.situation) const situation = useSituationStore((s) => s.situation)
const { isReplayMode, playbackTime } = usePlaybackStore() const { isReplayMode, playbackTime } = usePlaybackStore()
@@ -51,86 +62,110 @@ export function useReplaySituation(): MilitarySituation {
return useMemo(() => { return useMemo(() => {
if (!isReplayMode) return situation if (!isReplayMode) return situation
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime) // 战损阶段:伊朗(盟军打击 02:0004: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 replayDay = playbackTime.slice(0, 10)
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime) 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 usLoss = situation.usForces.combatLosses
const irLoss = situation.iranForces.combatLosses const irLoss = situation.iranForces.combatLosses
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 } const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
const usLossesAt = { const usLossesAt = {
bases: { bases: {
destroyed: lerp(0, usLoss.bases.destroyed), destroyed: lerpUs(0, usLoss.bases.destroyed),
damaged: lerp(0, usLoss.bases.damaged), damaged: lerpUs(0, usLoss.bases.damaged),
}, },
personnelCasualties: { personnelCasualties: {
killed: lerp(0, usLoss.personnelCasualties.killed), killed: lerpUs(0, usLoss.personnelCasualties.killed),
wounded: lerp(0, usLoss.personnelCasualties.wounded), wounded: lerpUs(0, usLoss.personnelCasualties.wounded),
}, },
civilianCasualties: { killed: 0, wounded: 0 }, civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, usLoss.aircraft), aircraft: lerpUs(0, usLoss.aircraft),
warships: lerp(0, usLoss.warships), warships: lerpUs(0, usLoss.warships),
armor: lerp(0, usLoss.armor), armor: lerpUs(0, usLoss.armor),
vehicles: lerp(0, usLoss.vehicles), vehicles: lerpUs(0, usLoss.vehicles),
drones: lerp(0, usLoss.drones ?? 0), drones: lerpUs(0, usLoss.drones ?? 0),
missiles: lerp(0, usLoss.missiles ?? 0), missiles: lerpUs(0, usLoss.missiles ?? 0),
helicopters: lerp(0, usLoss.helicopters ?? 0), helicopters: lerpUs(0, usLoss.helicopters ?? 0),
submarines: lerp(0, usLoss.submarines ?? 0), submarines: lerpUs(0, usLoss.submarines ?? 0),
carriers: lerp(0, usLoss.carriers ?? 0), carriers: lerpUs(0, usLoss.carriers ?? 0),
civilianShips: lerp(0, usLoss.civilianShips ?? 0), civilianShips: lerpUs(0, usLoss.civilianShips ?? 0),
airportPort: lerp(0, usLoss.airportPort ?? 0), airportPort: lerpUs(0, usLoss.airportPort ?? 0),
} }
const irLossesAt = { const irLossesAt = {
bases: { bases: {
destroyed: lerp(0, irLoss.bases.destroyed), destroyed: lerpIran(0, irLoss.bases.destroyed),
damaged: lerp(0, irLoss.bases.damaged), damaged: lerpIran(0, irLoss.bases.damaged),
}, },
personnelCasualties: { personnelCasualties: {
killed: lerp(0, irLoss.personnelCasualties.killed), killed: lerpIran(0, irLoss.personnelCasualties.killed),
wounded: lerp(0, irLoss.personnelCasualties.wounded), wounded: lerpIran(0, irLoss.personnelCasualties.wounded),
}, },
civilianCasualties: { killed: 0, wounded: 0 }, civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, irLoss.aircraft), aircraft: lerpIran(0, irLoss.aircraft),
warships: lerp(0, irLoss.warships), warships: lerpIran(0, irLoss.warships),
armor: lerp(0, irLoss.armor), armor: lerpIran(0, irLoss.armor),
vehicles: lerp(0, irLoss.vehicles), vehicles: lerpIran(0, irLoss.vehicles),
drones: lerp(0, irLoss.drones ?? 0), drones: lerpIran(0, irLoss.drones ?? 0),
missiles: lerp(0, irLoss.missiles ?? 0), missiles: lerpIran(0, irLoss.missiles ?? 0),
helicopters: lerp(0, irLoss.helicopters ?? 0), helicopters: lerpIran(0, irLoss.helicopters ?? 0),
submarines: lerp(0, irLoss.submarines ?? 0), submarines: lerpIran(0, irLoss.submarines ?? 0),
carriers: lerp(0, irLoss.carriers ?? 0), carriers: lerpIran(0, irLoss.carriers ?? 0),
civilianShips: lerp(0, irLoss.civilianShips ?? 0), civilianShips: lerpIran(0, irLoss.civilianShips ?? 0),
airportPort: lerp(0, irLoss.airportPort ?? 0), airportPort: lerpIran(0, irLoss.airportPort ?? 0),
} }
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked // 被袭基地:按 DB attacked_at 过滤,只显示已到回放时刻的遭袭点
const usLocs = situation.usForces.keyLocations || [] 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) => { 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, status: 'operational' as const }
} }
return { ...loc } 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 { return {
...situation, ...situation,
lastUpdated: playbackTime, lastUpdated: playbackTime,
civilianCasualtiesTotal: { civilianCasualtiesTotal: {
killed: lerp(0, civTotal.killed), killed: lerpIran(0, civTotal.killed),
wounded: lerp(0, civTotal.wounded), wounded: lerpIran(0, civTotal.wounded),
}, },
usForces: { usForces: {
...situation.usForces, ...situation.usForces,
@@ -143,6 +178,7 @@ export function useReplaySituation(): MilitarySituation {
}, },
iranForces: { iranForces: {
...situation.iranForces, ...situation.iranForces,
keyLocations: irLocsAt,
combatLosses: irLossesAt, combatLosses: irLossesAt,
retaliationSentiment: retValue, retaliationSentiment: retValue,
retaliationSentimentHistory: [ retaliationSentimentHistory: [
@@ -153,8 +189,11 @@ export function useReplaySituation(): MilitarySituation {
recentUpdates: (situation.recentUpdates || []).filter( recentUpdates: (situation.recentUpdates || []).filter(
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime() (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 }, conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
mapData: mapDataAt,
} }
}, [situation, isReplayMode, playbackTime]) }, [situation, isReplayMode, playbackTime])
} }

View File

@@ -61,5 +61,16 @@ body,
to { transform: translateX(-50%); } to { transform: translateX(-50%); }
} }
@keyframes likePop {
from {
opacity: 1;
transform: scale(1.2);
}
to {
opacity: 0;
transform: scale(1.5) translateY(-8px);
}
}
/* 移动端横屏使用单列+滚动,不再做 zoom 缩放,保持比例正常 */ /* 移动端横屏使用单列+滚动,不再做 zoom 缩放,保持比例正常 */

View File

@@ -190,13 +190,7 @@ export function EditDashboard() {
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数看过=累计访问等)。')) return if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数看过=累计访问等)。')) return
setSaving('displayStats') setSaving('displayStats')
try { try {
await putDisplayStats({ await putDisplayStats({ clearOverride: true })
viewers: null,
cumulative: null,
shareCount: null,
likeCount: null,
feedbackCount: null,
})
await load() await load()
const res = await fetch('/api/stats', { cache: 'no-store' }) const res = await fetch('/api/stats', { cache: 'no-store' })
if (res.ok) { if (res.ok) {
@@ -263,10 +257,13 @@ export function EditDashboard() {
{openSections.has('displayStats') && data && ( {openSections.has('displayStats') && data && (
<div className="border-t border-military-border p-4 space-y-3"> <div className="border-t border-military-border p-4 space-y-3">
<p className="text-military-text-secondary text-xs"> <p className="text-military-text-secondary text-xs">
= 2 ID = 访 {data.displayStats?.overrideEnabled
? '当前看板显示为覆盖值。'
: '当前看板显示为实时统计。'}
= 2 访 = 访
</p> </p>
<DisplayStatsForm <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} onSave={handleSaveDisplayStats}
onClearOverrides={handleClearDisplayStatsOverrides} onClearOverrides={handleClearDisplayStatsOverrides}
saving={saving === 'displayStats'} saving={saving === 'displayStats'}

View File

@@ -1,39 +1,51 @@
import { create } from 'zustand' import { create } from 'zustand'
const REPLAY_DAY = '2026-03-01' /** 回放日期范围:覆盖 2 月 28 日(盟军打击伊朗 → 伊朗反击),与 DB attacked_at / struck_at 对齐 */
const TICK_MS = 30 * 60 * 1000 // 30 minutes 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_START = REPLAY_START_ISO
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z` 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 { function parseTime(iso: string): number {
return new Date(iso).getTime() 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[] = [] const ticks: string[] = []
let t = parseTime(REPLAY_START) let t = parseTime(REPLAY_START)
const end = parseTime(REPLAY_END) const end = parseTime(REPLAY_END)
while (t <= end) { while (t <= end) {
ticks.push(new Date(t).toISOString()) ticks.push(new Date(t).toISOString())
t += TICK_MS t += step
} }
return ticks return ticks
} }
export const REPLAY_TICKS = getTicks()
export interface PlaybackState { export interface PlaybackState {
/** 是否开启回放模式 */ /** 是否开启回放模式 */
isReplayMode: boolean isReplayMode: boolean
/** 当前回放时刻 (ISO) */ /** 当前回放时刻 (ISO) */
playbackTime: string playbackTime: string
/** 回放刻度30分钟 / 1小时 / 1天 */
replayScale: ReplayScale
/** 是否正在自动播放 */ /** 是否正在自动播放 */
isPlaying: boolean isPlaying: boolean
/** 播放速度 (秒/刻度) */ /** 播放速度 (秒/刻度) */
speedSecPerTick: number speedSecPerTick: number
setReplayMode: (v: boolean) => void setReplayMode: (v: boolean) => void
setPlaybackTime: (iso: string) => void setPlaybackTime: (iso: string) => void
setReplayScale: (scale: ReplayScale) => void
setIsPlaying: (v: boolean) => void setIsPlaying: (v: boolean) => void
stepForward: () => void stepForward: () => void
stepBack: () => void stepBack: () => void
@@ -43,13 +55,20 @@ export interface PlaybackState {
export const usePlaybackStore = create<PlaybackState>((set, get) => ({ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
isReplayMode: false, isReplayMode: false,
playbackTime: REPLAY_END, playbackTime: REPLAY_END,
replayScale: '30m',
isPlaying: false, isPlaying: false,
speedSecPerTick: 2, speedSecPerTick: 2,
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }), setReplayMode: (v) =>
set({
isReplayMode: v,
isPlaying: false,
...(v ? { playbackTime: REPLAY_START } : {}),
}),
setPlaybackTime: (iso) => { setPlaybackTime: (iso) => {
const ticks = REPLAY_TICKS const { replayScale } = get()
const ticks = getTicks(replayScale)
if (ticks.includes(iso)) { if (ticks.includes(iso)) {
set({ playbackTime: iso }) set({ playbackTime: iso })
return return
@@ -59,19 +78,27 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
set({ playbackTime: ticks[clamp] }) 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 }), setIsPlaying: (v) => set({ isPlaying: v }),
stepForward: () => { stepForward: () => {
const { playbackTime } = get() const { playbackTime, replayScale } = get()
const ticks = REPLAY_TICKS const ticks = getTicks(replayScale)
const i = ticks.indexOf(playbackTime) const i = ticks.indexOf(playbackTime)
if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] }) if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] })
else set({ isPlaying: false }) else set({ isPlaying: false })
}, },
stepBack: () => { stepBack: () => {
const { playbackTime } = get() const { playbackTime, replayScale } = get()
const ticks = REPLAY_TICKS const ticks = getTicks(replayScale)
const i = ticks.indexOf(playbackTime) const i = ticks.indexOf(playbackTime)
if (i > 0) set({ playbackTime: ticks[i - 1] }) if (i > 0) set({ playbackTime: ticks[i - 1] })
}, },

View File

@@ -52,6 +52,7 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL_MS = 3000 const POLL_INTERVAL_MS = 3000
// situation.lastUpdated 与后端 situation.updated_at 一致,后端在爬虫 notify、编辑保存时更新并广播
function pollSituation() { function pollSituation() {
fetchSituation() fetchSituation()
.then((situation) => useSituationStore.getState().setSituation(situation)) .then((situation) => useSituationStore.getState().setSituation(situation))

View File

@@ -9,8 +9,8 @@ cd "$(dirname "$0")"
# 若存在 .env 则加载(可在此设置 DASHSCOPE_API_KEY=sk-xxx勿提交 .env # 若存在 .env 则加载(可在此设置 DASHSCOPE_API_KEY=sk-xxx勿提交 .env
[ -f .env ] && set -a && . ./.env && set +a [ -f .env ] && set -a && . ./.env && set +a
# AI 模式:有 DASHSCOPE_API_KEY 时用通义(商业模型,无需 Ollama否则用 Ollama 或规则 # 面板实时数据1=仅用规则提取战损/据点(无需 Ollama0=用 Ollama/通义。有 DASHSCOPE_API_KEY 时自动用通义
export CLEANER_AI_DISABLED=0 export CLEANER_AI_DISABLED=1
export PARSER_AI_DISABLED=0 export PARSER_AI_DISABLED=0
# GDELT 国内常超时,仅用 RSS 更新(如需 GDELT 可改为 0 # GDELT 国内常超时,仅用 RSS 更新(如需 GDELT 可改为 0
export GDELT_DISABLED=1 export GDELT_DISABLED=1

View File

@@ -22,11 +22,18 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
configure: (proxy) => { configure: (proxy) => {
proxy.on('error', () => {}) // 后端未启动时静默失败 proxy.on('error', () => {}) // 后端未启动时静默失败
// 抑制关标签/刷新时代理底层 socket 的 ECONNRESET避免刷屏 // 吞掉 ECONNRESET防止 Vite 打印 "ws proxy socket error"(关标签/刷新时常见)
proxy.on('proxyReqWs', (proxyReq, req, socket, head) => { function swallowEconnreset(s: { emit?: (ev: string, ...a: unknown[]) => boolean } | null) {
socket?.on?.('error', () => {}) if (!s?.emit) return
const out = (proxyReq as { socket?: { on?: (e: string, fn: () => void) => void } })?.socket const orig = s.emit.bind(s)
out?.on?.('error', () => {}) 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)
}) })
}, },
}, },