Compare commits

...

25 Commits

Author SHA1 Message Date
Daniel
2ee77d666b fix:修复跳转bug 2026-03-06 17:59:17 +08:00
Daniel
7e0c209d9a fix: 新增处 战役 2026-03-06 17:43:27 +08:00
Daniel
13a8d8af91 fix:优化参数指数 2026-03-06 14:20:18 +08:00
Daniel
3251de6406 fix:优化移动端打击效果 2026-03-06 14:01:47 +08:00
Daniel
9f86de0125 fix:优化数据显示 2026-03-06 13:24:53 +08:00
Daniel
4a51bf0767 fix:新增打击查看方式 2026-03-06 13:04:08 +08:00
Daniel
97b04b6ccc fix:修复相关问题,新增留言查看 2026-03-06 11:41:09 +08:00
Daniel
cbac58af62 fix:优化脚本脚本 2026-03-06 10:47:19 +08:00
Daniel
b7ff505dea fix:修复常见问题,用于更新 2026-03-06 10:43:50 +08:00
Daniel
9f2442f2e3 fix: 优化虫 机制,新增伊朗支援 2026-03-06 10:34:52 +08:00
Daniel
89145a6743 fix:修复启动文件 2026-03-05 20:22:15 +08:00
Daniel
07454b73c2 fix:优化爬虫配置,单独使用docker容器运行 2026-03-05 20:19:24 +08:00
Daniel
bbb9a5e1e1 fix:修复依赖文件报错 2026-03-05 20:00:15 +08:00
Daniel
98d928f457 fix:优化pm2配置项 2026-03-05 19:53:05 +08:00
Daniel
004b03b374 fix:优化爬虫链路 2026-03-05 19:18:45 +08:00
Daniel
475097d372 fix: 优化页面效果 2026-03-05 16:11:33 +08:00
Daniel
af59d6367f fix: 新增态 效果 2026-03-05 15:53:10 +08:00
Daniel
a3bf8abda5 fix:对齐生产环境的数据字段 2026-03-04 19:19:50 +08:00
Daniel
26938449f0 fix: bug 2026-03-04 16:48:17 +08:00
Daniel
64f4c438c3 fix: code update 2026-03-04 09:56:23 +08:00
Daniel
88c37408e8 fix: 化本 2026-03-04 09:43:21 +08:00
Daniel
3264b3252a fix: bug 2026-03-04 00:39:01 +08:00
Daniel
95e2fe1c41 fix: 2026-03-04 00:07:14 +08:00
Daniel
ac24c528f3 Merge branch; keep full EditDashboard 2026-03-03 22:44:24 +08:00
Daniel
86e50debec fix:增面 2026-03-03 22:42:21 +08:00
81 changed files with 6626 additions and 739 deletions

View File

@@ -10,7 +10,7 @@
| crawler| 8000 | RSS 爬虫 + GDELT内部服务 |
- 数据库SQLite挂载到 `app-data` volume`/data/data.db`
- 前端与 API 合并到同一镜像,访问 `http://主机:3001` 即可
- 前端与 API 合并到同一镜像,构建时执行 `npm run build` 生成 dist含修订页 `/edit`访问 `http://主机:3001` 即可
## 快速部署
@@ -109,3 +109,60 @@ docker compose down
# 回填战损数据(从 situation_update 重新提取)
curl -X POST http://localhost:8000/crawler/backfill
```
## 服务器直接部署(不用 Docker
若在服务器上直接跑 Node不用 Docker要能访问修订页 `/edit`,需保证:
1. **先构建、再启动**:在项目根目录执行 `npm run build`,再启动 API如 `npm run api` 或 `node server/index.js`)。
未构建时没有 `dist` 目录,启动会打日志:`dist 目录不存在,前端页面(含 /edit 修订页)不可用`。
2. **若前面有 Nginx**`curl http://127.0.0.1:3001/edit` 已是 200 但浏览器访问 `/edit` 仍 404说明 Nginx 没有把前端路由交给后端或没做 SPA fallback。二选一即可
**方式 ANginx 只反代,所有页面由 Node 提供(推荐)**
```nginx
server {
listen 80;
server_name 你的域名;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
**方式 BNginx 提供 dist 静态,仅 /api、/ws 反代**
```nginx
server {
listen 80;
server_name 你的域名;
root /path/to/项目根目录/dist; # 改成实际路径
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
修改后执行 `sudo nginx -t` 检查配置,再 `sudo systemctl reload nginx`(或 `sudo nginx -s reload`)。

View File

@@ -1,11 +1,23 @@
# 仅后端 API 镜像(前端自行部署
# 前端 + 后端合一镜像:构建阶段产出 dist运行阶段提供静态与 API含修订页 /edit
# 国内服务器拉取慢时,可加 --build-arg REGISTRY=docker.m.daocloud.io/library
ARG REGISTRY=
# ---------- 阶段 1构建前端 ----------
FROM ${REGISTRY}node:20-slim AS frontend-builder
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com
COPY package*.json ./
RUN npm ci
COPY vite.config.ts index.html tsconfig.json tsconfig.app.json ./
COPY postcss.config.js tailwind.config.js ./
COPY src ./src
RUN npm run build
# ---------- 阶段 2运行API + 静态) ----------
FROM ${REGISTRY}node:20-slim
RUN npm config set registry https://registry.npmmirror.com
# Debian 12 使用 sources.list.d改为国内源避免 build 时连接超时
RUN rm -f /etc/apt/sources.list.d/debian.sources && \
echo 'deb http://mirrors.aliyun.com/debian bookworm main' > /etc/apt/sources.list && \
echo 'deb http://mirrors.aliyun.com/debian bookworm-updates main' >> /etc/apt/sources.list && \
@@ -13,13 +25,11 @@ RUN rm -f /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server ./server
COPY --from=frontend-builder /app/dist ./dist
ENV NODE_ENV=production
ENV API_PORT=3001

View File

@@ -1,4 +1,4 @@
# Python 爬虫服务
# Python 3.11+ 爬虫服务(与 requirements.txt / pyproject.toml 一致)
# 国内服务器可加 --build-arg REGISTRY=docker.m.daocloud.io/library
ARG REGISTRY=
FROM ${REGISTRY}python:3.11-slim

View File

@@ -14,16 +14,17 @@
**数据偏老原因**:未传 `timespan``sort=datedesc`API 返回 3 个月内“最相关”文章,不保证最新。
### 2. RSS 新闻 (situation_update) — 主事件脉络来源
### 2. RSS 新闻 → 看板实时数据(主输出)+ 事件脉络
| 项目 | 说明 |
|------|------|
| 源 | 多国主流媒体:美(Reuters/NYT)、英(BBC)、法(France 24)、俄(TASS/RT)、中(Xinhua/CGTN)、伊(Press TV)、卡塔尔(Al Jazeera) |
| **主输出** | **看板实时数据**战损combat_losses、据点状态key_location、冲突事件gdelt_events、统计conflict_stats供前端战损/基地/地图等面板展示。 |
| 辅助输出 | 事件脉络situation_update时间线摘要非主展示目标。 |
| 源 | 多国主流媒体:美/英/法/俄/中/伊/卡塔尔等(见 `config.RSS_FEEDS` |
| 过滤 | 标题/摘要需含 `KEYWORDS` 之一iran、usa、strike、military 等) |
| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 |
| 优先级 | 启动时先拉 RSS再拉 GDELT |
| 更新 | 爬虫 `RSS_INTERVAL_SEC` 拉取;每 `BACKFILL_CYCLES` 轮会从近期事件回填一次战损/据点,保证面板数据与最新内容一致。 |
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS 新闻即可维持事件脉络。部分境外源可能受网络限制
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS部分境外源可能需代理
### 3. AI 新闻清洗与分类(可选)
@@ -34,15 +35,20 @@
---
**事件脉络可实时更新**:爬虫抓取后 → 写入 SQLite → 调用 Node 通知 → WebSocket 广播 → 前端自动刷新
**看板实时数据更新**:爬虫抓取 → 提取战损/据点等 → 写入 combat_losses、key_location 等 → 调用 Node 通知 → WebSocket 广播 → 前端战损/基地/地图等面板刷新。事件脉络(时间线)为同一流水线的辅助输出
## 依赖
- **Python 3.11+**(推荐 3.11 或 3.12
```bash
pip install -r requirements.txt
```
新增 `deep-translator`GDELT 与 RSS 新闻入库前自动翻译为中文
或使用 pyproject`pip install -e crawler/`(在项目根目录)
- `deep-translator`GDELT 与 RSS 新闻入库前自动翻译为中文。
- `dashscope`:可选,配置 `DASHSCOPE_API_KEY` 后启用通义提取/清洗。
## 运行(需同时启动 3 个服务)
@@ -141,6 +147,7 @@ npm run crawler:test:extraction # 规则/db_merge 测试
| **地图冲突点** (conflictEvents) | gdelt_events 或 RSS→gdelt 回填 | ✅ 是 | GDELT 或 GDELT 禁用时由 situation_update 同步到 gdelt_events |
| **战损/装备毁伤** (combatLosses) | combat_losses | ⚠️ 有条件 | 仅当 AI/规则从新闻中提取到数字如「2 名美军死亡」merge 才写入增量 |
| **基地/地点状态** (keyLocations) | key_location | ⚠️ 有条件 | 仅当提取到 key_location_updates如某基地遭袭时更新 |
| **地图打击/攻击动画** (mapData.strikeSources, strikeLines) | map_strike_source, map_strike_line | ⚠️ 有条件 | 仅当提取到 map_strike_sources / map_strike_lines 时写入;格式见下「地图打击数据」 |
| **力量摘要/指数/资产** (summary, powerIndex, assets) | force_summary, power_index, force_asset | ❌ 否 | 仅 seed 初始化,爬虫不写 |
| **华尔街/报复情绪** (wallStreet, retaliation) | wall_street_trend, retaliation_* | ⚠️ 有条件 | 仅当提取器输出对应字段时更新 |
@@ -255,6 +262,32 @@ npm run crawler:test
---
## 数据流与 AI 自检
**完整链路**RSS 抓取 → 关键词过滤 → 翻译/清洗 → 去重news_content→ 写 situation_update → 正文抓取(可选)→ **AI 提取**(战损/基地等)→ db_merge 写 combat_losses/key_location 等 → POST /api/crawler/notify → Node 重载并广播。
| 环节 | 说明 | 自检 |
|------|------|------|
| 抓取 | `scrapers/rss_scraper.fetch_all()`,按 KEYWORDS 过滤 | `npm run crawler:test` 看条数 |
| 去重 | `news_storage.save_and_dedup()`content_hash 落库 news_content | 查 `news_content` 表条数 |
| 事件脉络 | `db_writer.write_updates()` 写 situation_update与 pipeline 使用同一 db_path | 查 `situation_update` 表 |
| AI 提取 | 战损/基地等:**有 DASHSCOPE_API_KEY 用通义****否则 CLEANER_AI_DISABLED=1 用规则**,否则用 **Ollama**extractor_ai | 见下 |
| 分类/严重度 | 每条 RSS 的 category/severity**PARSER_AI_DISABLED=1 用规则**,否则 DashScope 或 Ollama | 无 AI 时设 `PARSER_AI_DISABLED=1` 可正常跑 |
**如何保证「面板实时数据」有更新**(战损、据点等):
- **推荐**:设 `CLEANER_AI_DISABLED=1` → 使用 `extractor_rules`(纯规则),无需 Ollama/通义,即可从新闻中提取战损/基地并写入 combat_losses、key_location。
- 或设 `DASHSCOPE_API_KEY` → 用通义做更细的提取。
- 否则用 `extractor_ai`(需本机 `ollama run llama3.1`),未起则提取静默失败、面板数字不更新。
- 服务会每 `BACKFILL_CYCLES` 轮(默认 2 轮)从近期事件再跑一次提取并合并,保证战损/据点与最新内容一致。
**常见 bug 与修复**
- **事件脉络有、战损/基地不更新**:多为 AI 未跑通Ollama 未起且未设 DashScope、未设 CLEANER_AI_DISABLED。可设 `CLEANER_AI_DISABLED=1` 用规则提取,或起 Ollama / 配置 DashScope。
- **多 DB 路径不一致**pipeline 已统一 `db_path``write_updates``save_and_dedup``merge` 均使用同一 path`config.DB_PATH`)。
---
## 优化后验证效果示例
以下为「正文抓取 + AI 精确提取 + 增量与地点更新」优化后,单条新闻从输入到前端展示的完整示例,便于对照验证。
@@ -321,6 +354,15 @@ print('key_location_updates:', out.get('key_location_updates'))
期望:`combat_losses_delta.us` 含 personnel_killed=2、personnel_wounded=14、aircraft=1 等增量;`key_location_updates` 含阿萨德 side=us 等条目。
### 地图打击数据(与前端攻击动画统一格式)
爬虫/AI 若输出以下字段,`db_merge` 会写入 `map_strike_source``map_strike_line``GET /api/situation``mapData.strikeSources` / `mapData.strikeLines` 会更新,前端可直接追加打击线与飞行动画。
- **map_strike_sources**(可选):`[{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng": 经度, "lat": 纬度 }]`,与 seed 中打击源 id 一致时可覆盖位置。
- **map_strike_lines**(可选):`[{ "source_id": "israel"|"lincoln"|"ford", "target_lng", "target_lat", "target_name": "目标名", "struck_at": "ISO 时间" }]`,每条追加一条打击线(不删已有),便于按时间回放。
示例:`{ "map_strike_lines": [{ "source_id": "israel", "target_lng": 51.916, "target_lat": 33.666, "target_name": "纳坦兹", "struck_at": "2026-03-01T02:04:00.000Z" }] }`
---
## 冲突强度 (impact_score)

Binary file not shown.

View File

@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
"""
将 AI 提取的结构化数据合并到 SQLite
与 panel schema 及 situationData.getSituation 对齐,支持回放
与 panel schema 及 situationData.getSituation 对齐,支持回放
地图打击数据(与前端攻击动画一致):
- map_strike_sources: [{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng", "lat" }] 写入 map_strike_source
- map_strike_lines: [{ "source_id", "target_lng", "target_lat", "target_name?", "struck_at?" }] 追加到 map_strike_line
爬虫/AI 可按此格式输出,落库后 GET /api/situation 的 mapData.strikeSources/strikeLines 会更新,前端直接追加攻击动画。
"""
import os
import sqlite3
@@ -9,6 +14,13 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from crawler.indicator_smooth import (
clamp as _indicator_clamp,
smooth_retaliation as _smooth_retaliation,
smooth_wall_street as _smooth_wall_street,
wall_street_should_append as _wall_street_should_append,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
@@ -21,6 +33,11 @@ MAX_DELTA_PER_MERGE = {
"civilian_ships": 20, "airport_port": 10,
}
# 反击情绪 / 华尔街:限幅与平滑见 crawler.indicator_smooth
RETALIATION_HISTORY_MAX_ROWS = 300 # 反击历史条数上限,供前端曲线与回放使用
WALL_STREET_TREND_MAX_ROWS = 200 # 趋势表保留最近条数,避免无限增长
VALUE_CLAMP_MIN, VALUE_CLAMP_MAX = 1, 99 # 0/100 视为异常,写入前夹在 [1,99]
def _clamp_delta(key: str, value: int) -> int:
"""单次增量上限,避免误提「累计」导致波动"""
@@ -67,6 +84,37 @@ def _ensure_tables(conn: sqlite3.Connection) -> None:
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_current (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_history (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS situation (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, updated_at TEXT NOT NULL)")
# 地图打击源与打击线(与 server/db.js 一致),供 getSituation mapData 与前端攻击动画使用
conn.execute("""
CREATE TABLE IF NOT EXISTS map_strike_source (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lng REAL NOT NULL,
lat REAL NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS map_strike_line (
source_id TEXT NOT NULL,
target_lng REAL NOT NULL,
target_lat REAL NOT NULL,
target_name TEXT,
struck_at TEXT,
FOREIGN KEY (source_id) REFERENCES map_strike_source(id)
)
""")
try:
conn.execute("CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id)")
except sqlite3.OperationalError:
pass
try:
for col in ("struck_at",):
cur = conn.execute("PRAGMA table_info(map_strike_line)")
cols = [r[1] for r in cur.fetchall()]
if col not in cols:
conn.execute(f"ALTER TABLE map_strike_line ADD COLUMN {col} TEXT")
except sqlite3.OperationalError:
pass
conn.commit()
@@ -147,42 +195,127 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
updated = True
except Exception:
pass
# retaliation
# force_summary 增量:导弹消耗(看板「导弹消耗」「导弹库存」由 force_summary 提供)
if "force_summary_delta" in extracted:
for side, delta in extracted["force_summary_delta"].items():
if side not in ("us", "iran"):
continue
mc = delta.get("missile_consumed")
if mc is not None and isinstance(mc, (int, float)) and mc > 0:
mc = min(int(mc), 500)
try:
cur = conn.execute(
"UPDATE force_summary SET missile_consumed = missile_consumed + ?, missile_stock = max(0, missile_stock - ?) WHERE side = ?",
(mc, mc, side),
)
if cur.rowcount > 0:
updated = True
except Exception:
pass
# retaliation由 indicator_smooth 计算平滑值 + 单步变化上限,避免爬虫连续更新导致剧烈波动
if "retaliation" in extracted:
r = extracted["retaliation"]
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (r["value"],))
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (r["time"], r["value"]))
raw = _indicator_clamp(int(r.get("value", 50)))
row = conn.execute("SELECT value FROM retaliation_current WHERE id = 1").fetchone()
current = int(row[0]) if row else 50
new_val = _smooth_retaliation(raw, current)
ts = (r.get("time") or datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"))[:25]
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (new_val,))
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (ts, new_val))
n_ret = conn.execute("SELECT COUNT(*) FROM retaliation_history").fetchone()[0]
if n_ret > RETALIATION_HISTORY_MAX_ROWS:
conn.execute(
"DELETE FROM retaliation_history WHERE id IN (SELECT id FROM retaliation_history ORDER BY time ASC LIMIT ?)",
(n_ret - RETALIATION_HISTORY_MAX_ROWS,),
)
updated = True
# wall_street_trend
# wall_street_trend:由 indicator_smooth 与上一点平滑 + 最小写入间隔,抑制密集报道导致的锯齿
if "wall_street" in extracted:
w = extracted["wall_street"]
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"]))
raw = int(w.get("value", 50))
ts = (w.get("time") or datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"))[:25]
last_row = conn.execute(
"SELECT time, value FROM wall_street_trend ORDER BY time DESC LIMIT 1"
).fetchone()
last_time = last_row[0] if last_row else None
last_val = int(last_row[1]) if last_row else None
if _wall_street_should_append(last_time, ts):
val = _smooth_wall_street(raw, last_val)
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (ts, val))
n = conn.execute("SELECT COUNT(*) FROM wall_street_trend").fetchone()[0]
if n > WALL_STREET_TREND_MAX_ROWS:
conn.execute(
"DELETE FROM wall_street_trend WHERE id IN (SELECT id FROM wall_street_trend ORDER BY time ASC LIMIT ?)",
(n - WALL_STREET_TREND_MAX_ROWS,),
)
updated = True
# key_location更新双方攻击地点美军基地被打击 side=us伊朗设施被打击 side=iran的 status/damage_level
# key_location更新双方攻击地点美军基地被打击 side=us伊朗设施被打击 side=iran的 status/damage_level/attacked_at
event_time = extracted.get("_event_time") or datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
if "key_location_updates" in extracted:
try:
for u in extracted["key_location_updates"]:
kw_raw = (u.get("name_keywords") or "").strip()
if not kw_raw:
continue
# 支持 "a|b|c" 或 "a b c" 分隔
kw = [k.strip() for k in kw_raw.replace("|", " ").split() if k.strip()]
side = u.get("side")
status = (u.get("status") or "attacked")[:20]
dmg = u.get("damage_level", 2)
if not kw or side not in ("us", "iran"):
continue
# 简化name LIKE '%kw%' 对每个关键词 OR 连接,支持中英文
attacked_at = (u.get("attacked_at") or event_time)[:25]
conditions = " OR ".join("name LIKE ?" for _ in kw)
params = [status, dmg, side] + [f"%{k}%" for k in kw]
params_with_at = [status, dmg, attacked_at, side] + [f"%{k}%" for k in kw]
try:
cur = conn.execute(
f"UPDATE key_location SET status=?, damage_level=?, attacked_at=? WHERE side=? AND ({conditions})",
params_with_at,
)
except sqlite3.OperationalError:
params_no_at = [status, dmg, side] + [f"%{k}%" for k in kw]
cur = conn.execute(
f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})",
params,
params_no_at,
)
if cur.rowcount > 0:
updated = True
except Exception:
pass
# map_strike_source打击源与前端 mapData.strikeSources 一致),爬虫可补充或覆盖
if "map_strike_sources" in extracted:
try:
for s in extracted["map_strike_sources"]:
sid = (s.get("id") or "").strip()
name = (s.get("name") or "").strip() or sid
lng = float(s.get("lng", 0))
lat = float(s.get("lat", 0))
if sid:
conn.execute(
"INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)",
(sid, name[:200], lng, lat),
)
if conn.total_changes > 0:
updated = True
except Exception:
pass
# map_strike_lines打击线与前端 mapData.strikeLines 一致),爬虫可追加新打击,便于前端追加攻击动画
if "map_strike_lines" in extracted:
try:
for line in extracted["map_strike_lines"]:
source_id = (line.get("source_id") or "").strip()
target_lng = float(line.get("target_lng", 0))
target_lat = float(line.get("target_lat", 0))
target_name = (line.get("target_name") or "").strip()[:200] or None
struck_at = (line.get("struck_at") or "").strip() or None
if source_id:
conn.execute(
"INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)",
(source_id, target_lng, target_lat, target_name, struck_at),
)
if conn.total_changes > 0:
updated = True
except Exception:
pass
if updated:
conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
conn.commit()

View File

@@ -4,6 +4,7 @@ import sqlite3
import hashlib
import os
from datetime import datetime, timezone
from typing import List, Optional
from config import DB_PATH
@@ -73,14 +74,29 @@ def touch_situation_updated_at(conn: sqlite3.Connection) -> None:
conn.commit()
def write_updates(updates: list[dict]) -> int:
def touch_situation_updated_at_path(db_path: Optional[str] = None) -> bool:
"""仅更新 situation.updated_at 为当前时间(每次爬虫运行都调用,便于前端显示「最后抓取时间」)。返回是否成功。"""
path = db_path or DB_PATH
if not os.path.exists(path):
return False
conn = sqlite3.connect(path, timeout=10)
try:
touch_situation_updated_at(conn)
return True
finally:
conn.close()
def write_updates(updates: List[dict], db_path: Optional[str] = None) -> int:
"""
updates: [{"title","summary","url","published","category","severity"}, ...]
db_path: 与 pipeline 一致,缺省用 config.DB_PATH
返回新增条数。
"""
if not os.path.exists(DB_PATH):
path = db_path or DB_PATH
if not os.path.exists(path):
return 0
conn = sqlite3.connect(DB_PATH, timeout=10)
conn = sqlite3.connect(path, timeout=10)
try:
count = 0
for u in updates:

View File

@@ -26,7 +26,12 @@ def _call_ollama_extract(text: str, timeout: int = 15) -> Optional[Dict[str, Any
try:
import requests
raw = str(text).strip()[:EXTRACT_TEXT_MAX_LEN]
prompt = f"""从以下美伊/中东新闻**全文或摘要**中,提取**报道明确给出的数字与事实**,输出 JSON。规则:
prompt = f"""从以下美伊/中东新闻**全文或摘要**中,提取**报道明确给出的数字与事实**,输出 JSON。
输入说明:
- 原文可能是英文、中文或其他语言English / Chinese / Arabic / Persian 等),请先理解含义,再按要求输出。
规则:
1. 仅填写报道中**直接出现、可核对**的数据,不要推测或估算。
2. 无明确依据的字段**必须省略**,不要填 0 或猜。
3. **战损一律按增量**:只填本则报道中「本次/此次/今日/本轮」**新增**的伤亡或损毁数量。若报道只给「累计总数」「迄今共」「total so far」等**不要填写**该字段(避免与库内已有累计值重复叠加)。
@@ -46,11 +51,13 @@ def _call_ollama_extract(text: str, timeout: int = 15) -> Optional[Dict[str, Any
- retaliation_sentiment: 0-100仅当报道涉及伊朗报复/反击情绪时
- wall_street_value: 0-100仅当报道涉及美股/市场时
- key_location_updates: **双方攻击地点**。每项 {{ "name_keywords": "阿萨德|asad|al-asad", "side": "us或iran被打击方", "status": "attacked", "damage_level": 1-3 }}。美军基地例:阿萨德|asad、乌代德|udeid、埃尔比勒|erbil、因吉尔利克|incirlik。伊朗例德黑兰|tehran、布什尔|bushehr、伊斯法罕|isfahan、阿巴斯|abbas、纳坦兹|natanz
- map_strike_lines仅当报道为**美/以盟军打击伊朗目标**时): 数组,每项 {{ "source_id": "israel或lincoln或ford", "target_lng": 经度, "target_lat": 纬度, "target_name": "目标名", "struck_at": "ISO时间" }}。目标坐标例纳坦兹51.92,33.67伊斯法罕51.67,32.65德黑兰51.39,35.69布什尔50.83,28.97阿巴斯港56.27,27.18
- **导弹消耗增量**(仅当报道明确提到「发射/消耗 了 X 枚导弹」时填,用于看板导弹消耗累计): us_missile_consumed_delta, iran_missile_consumed_delta本则报道中该方新增消耗枚数整数
原文:
{raw}
直接输出 JSON不要解释"""
直接输出 JSON 对象,不要解释,不要加反引号或代码块标记"""
r = requests.post(
"http://localhost:11434/api/chat",
json={
@@ -127,4 +134,37 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
})
if valid:
out["key_location_updates"] = valid
# map_strike_lines盟军打击伊朗目标
if "map_strike_lines" in parsed and isinstance(parsed["map_strike_lines"], list):
valid_lines = []
for line in parsed["map_strike_lines"]:
if not isinstance(line, dict):
continue
sid = str(line.get("source_id") or "").strip().lower()
if sid not in ("israel", "lincoln", "ford"):
continue
try:
lng = float(line.get("target_lng", 0))
lat = float(line.get("target_lat", 0))
except (TypeError, ValueError):
continue
name = str(line.get("target_name") or "")[:200]
struck_at = str(line.get("struck_at") or ts)[:25]
valid_lines.append({
"source_id": sid,
"target_lng": lng,
"target_lat": lat,
"target_name": name or None,
"struck_at": struck_at,
})
if valid_lines:
out["map_strike_lines"] = valid_lines
# force_summary 增量:导弹消耗(看板「导弹消耗」由 force_summary.missile_consumed 提供)
fs_delta = {}
for side_key, side_val in [("us_missile_consumed_delta", "us"), ("iran_missile_consumed_delta", "iran")]:
v = parsed.get(side_key)
if isinstance(v, (int, float)) and v > 0:
fs_delta[side_val] = {"missile_consumed": min(500, int(v))}
if fs_delta:
out["force_summary_delta"] = fs_delta
return out

View File

@@ -42,6 +42,8 @@ def _call_dashscope_extract(text: str, timeout: int = 15) -> Optional[Dict[str,
- retaliation_sentiment: 0-100仅当报道涉及伊朗报复情绪时
- wall_street_value: 0-100仅当报道涉及美股/市场时)
- key_location_updates: **双方攻击地点**。每项 {{"name_keywords":"阿萨德|asad","side":"us或iran被打击方","status":"attacked","damage_level":1-3}}。美军基地:阿萨德|asad、乌代德|udeid、埃尔比勒|erbil、因吉尔利克|incirlik。伊朗德黑兰|tehran、布什尔|bushehr、伊斯法罕|isfahan、阿巴斯|abbas、纳坦兹|natanz
- **map_strike_lines**(仅当报道明确为**美/以盟军打击伊朗或伊朗目标**时): 数组,每项 {{"source_id":"israel或lincoln或ford","target_lng":经度,"target_lat":纬度,"target_name":"目标名如纳坦兹","struck_at":"ISO时间"}}。以色列打击→source_id=israel林肯号→lincoln福特号→ford。目标坐标纳坦兹51.92,33.67伊斯法罕51.67,32.65德黑兰51.39,35.69布什尔50.83,28.97阿巴斯港56.27,27.18
- **导弹消耗增量**(仅当报道明确提到「发射/消耗 了 X 枚导弹」时填): us_missile_consumed_delta, iran_missile_consumed_delta本则该方新增消耗枚数整数
原文:
{raw}
@@ -110,6 +112,15 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if isinstance(v, (int, float)) and 0 <= v <= 100:
out["wall_street"] = {"time": ts, "value": int(v)}
# force_summary 增量:导弹消耗(看板「导弹消耗」)
fs_delta = {}
for key, side in [("us_missile_consumed_delta", "us"), ("iran_missile_consumed_delta", "iran")]:
v = parsed.get(key)
if isinstance(v, (int, float)) and v > 0:
fs_delta[side] = {"missile_consumed": min(500, int(v))}
if fs_delta:
out["force_summary_delta"] = fs_delta
if "key_location_updates" in parsed and isinstance(parsed["key_location_updates"], list):
valid = []
for u in parsed["key_location_updates"]:
@@ -123,4 +134,29 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if valid:
out["key_location_updates"] = valid
if "map_strike_lines" in parsed and isinstance(parsed["map_strike_lines"], list):
valid_lines = []
for line in parsed["map_strike_lines"]:
if not isinstance(line, dict):
continue
sid = str(line.get("source_id") or "").strip().lower()
if sid not in ("israel", "lincoln", "ford"):
continue
try:
lng = float(line.get("target_lng", 0))
lat = float(line.get("target_lat", 0))
except (TypeError, ValueError):
continue
name = str(line.get("target_name") or "")[:200]
struck_at = str(line.get("struck_at") or ts)[:25]
valid_lines.append({
"source_id": sid,
"target_lng": lng,
"target_lat": lat,
"target_name": name or None,
"struck_at": struck_at,
})
if valid_lines:
out["map_strike_lines"] = valid_lines
return out

View File

@@ -1,11 +1,24 @@
# -*- coding: utf-8 -*-
"""
基于规则的新闻数据提取(无需 Ollama
从新闻文本中提取战损、报复情绪等数值,供 db_merge 写入
从新闻文本中提取战损、报复情绪、攻击地点与盟军打击线,供 db_merge 写入
"""
import re
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple
# 伊朗境内常见打击目标: (显示名, 经度, 纬度, 匹配关键词)
IRAN_STRIKE_TARGETS: List[Tuple[str, float, float, str]] = [
("纳坦兹", 51.916, 33.666, "natanz|纳坦兹"),
("伊斯法罕", 51.67, 32.65, "isfahan|esfahan|伊斯法罕"),
("德黑兰", 51.389, 35.689, "tehran|德黑兰"),
("布什尔", 50.83, 28.97, "bushehr|布什尔"),
("阿巴斯港", 56.27, 27.18, "bandar abbas|abbas|阿巴斯|霍尔木兹"),
("克尔曼沙赫", 47.06, 34.31, "kermanshah|克尔曼沙赫"),
("大不里士", 46.29, 38.08, "tabriz|大不里士"),
("卡拉季", 50.99, 35.83, "karaj|卡拉季"),
("米纳布", 57.08, 27.13, "minab|米纳布"),
]
def _first_int(text: str, pattern: str) -> Optional[int]:
@@ -251,4 +264,30 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if updates:
out["key_location_updates"] = updates
# map_strike_lines盟军以色列/林肯/福特)打击伊朗目标,供地图攻击动画更新
strike_verbs = ("strike" in t or "struck" in t or "strikes" in t or "hit" in t or "attack" in t
or "打击" in (text or "") or "空袭" in (text or "") or "袭击" in (text or ""))
if strike_verbs and ("iran" in t or "伊朗" in (text or "") or any(
any(p in t for p in kw.split("|")) for _n, _lng, _lat, kw in IRAN_STRIKE_TARGETS
)):
source_id = "israel"
if "lincoln" in t or "林肯" in (text or ""):
source_id = "lincoln"
elif "ford" in t or "福特" in (text or ""):
source_id = "ford"
elif ("israel" in t or "idf" in t or "以色列" in (text or "")) and ("us " in t or "american" in t or "pentagon" in t):
source_id = "israel" # 多国时优先以色列
lines = []
for name, lng, lat, kw in IRAN_STRIKE_TARGETS:
if any(p in t for p in kw.split("|")):
lines.append({
"source_id": source_id,
"target_lng": lng,
"target_lat": lat,
"target_name": name,
"struck_at": ts,
})
if lines:
out["map_strike_lines"] = lines
return out

View File

@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""
华尔街财团投入指数 & 反击情绪指数:从爬虫实时数据计算稳定指标,抑制单条报道导致的剧烈波动。
供 db_merge.merge() 调用,写入同一批 DB 表,前端契约不变。
"""
from typing import Optional, Tuple
VALUE_CLAMP_MIN = 1
VALUE_CLAMP_MAX = 99
# 华尔街:与上一点平滑,新点权重
WALL_STREET_NEW_WEIGHT = 0.35 # raw 权重1 - 此值 = 上一点权重,越大曲线越平滑
# 华尔街:两次写入最小间隔(分钟),避免短时间多条报道造成密集锯齿
WALL_STREET_MIN_INTERVAL_MINUTES = 20
# 反击情绪当前值权重1 - 此值 = 新 raw 权重)
RETALIATION_CURRENT_WEIGHT = 0.8
# 反击情绪:单次更新相对当前值的最大变化幅度(绝对值)
RETALIATION_MAX_STEP = 5
def clamp(value: int) -> int:
return max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, int(value)))
def smooth_wall_street(
raw_value: int,
last_value: Optional[int],
*,
new_weight: float = WALL_STREET_NEW_WEIGHT,
) -> int:
"""
华尔街投入指数:用上一点做平滑,避免单条报道 30/80 导致曲线骤变。
若尚无上一点,直接使用限幅后的 raw。
"""
raw = clamp(raw_value)
if last_value is None:
return raw
w = 1.0 - new_weight
return clamp(round(w * last_value + new_weight * raw))
def wall_street_should_append(
last_time_iso: Optional[str],
new_time_iso: str,
min_interval_minutes: int = WALL_STREET_MIN_INTERVAL_MINUTES,
) -> bool:
"""
是否应追加一条华尔街趋势点。若与上一条间隔不足 min_interval_minutes 则跳过,
减少因爬虫短时间多篇报道导致的密集锯齿。
"""
if not last_time_iso:
return True
try:
from datetime import datetime
last = datetime.fromisoformat(last_time_iso.replace("Z", "+00:00"))
new = datetime.fromisoformat(new_time_iso.replace("Z", "+00:00"))
delta_min = (new - last).total_seconds() / 60
return delta_min >= min_interval_minutes
except Exception:
return True
def smooth_retaliation(
raw_value: int,
current_value: int,
*,
current_weight: float = RETALIATION_CURRENT_WEIGHT,
max_step: int = RETALIATION_MAX_STEP,
) -> int:
"""
反击情绪指数:先与当前值平滑,再限制单步变化幅度,避免连续多条报道导致快速漂移或抖动。
"""
raw = clamp(raw_value)
cur = clamp(current_value)
smoothed = round(current_weight * cur + (1.0 - current_weight) * raw)
smoothed = clamp(smoothed)
# 单步变化上限
delta = smoothed - cur
if abs(delta) > max_step:
step = max_step if delta > 0 else -max_step
smoothed = clamp(cur + step)
return smoothed

View File

@@ -3,7 +3,7 @@
前端面板完整数据 schema与 DB / situationData / useReplaySituation 对齐
爬虫 + AI 清洗后的数据必须符合此 schema 才能正确更新前端
"""
from typing import Any, Dict, List, Literal, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
# 事件脉络
SITUATION_UPDATE_CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")

View File

@@ -1,7 +1,21 @@
# -*- coding: utf-8 -*-
"""新闻分类与严重度判定"""
import re
from typing import Literal
from typing import List
try:
from typing import Literal # type: ignore
except ImportError:
try:
from typing_extensions import Literal # type: ignore
except ImportError:
from typing import Any
class _LiteralFallback:
def __getitem__(self, item):
return Any
Literal = _LiteralFallback()
Category = Literal["deployment", "alert", "intel", "diplomatic", "other"]
Severity = Literal["low", "medium", "high", "critical"]
@@ -13,7 +27,7 @@ CAT_INTEL = ["satellite", "intel", "image", "surveillance", "卫星", "情报"]
CAT_DIPLOMATIC = ["talk", "negotiation", "diplomat", "sanction", "谈判", "制裁"]
def _match(text: str, words: list[str]) -> bool:
def _match(text: str, words: List[str]) -> bool:
t = (text or "").lower()
for w in words:
if w.lower() in t:

View File

@@ -5,7 +5,19 @@ AI 新闻分类与严重度判定
设置 PARSER_AI_DISABLED=1 可只用规则(更快)
"""
import os
from typing import Literal, Optional, Tuple
from typing import Any, Optional, Tuple
try:
from typing import Literal # type: ignore
except ImportError:
try:
from typing_extensions import Literal # type: ignore
except ImportError:
class _LiteralFallback:
def __getitem__(self, item):
return Any
Literal = _LiteralFallback()
Category = Literal["deployment", "alert", "intel", "diplomatic", "other"]
Severity = Literal["low", "medium", "high", "critical"]

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timezone
from typing import Callable, Optional, Tuple
from config import DB_PATH, API_BASE
from db_writer import touch_situation_updated_at_path
def _notify_api(api_base: str) -> bool:
@@ -66,6 +67,8 @@ def _extract_and_merge(items: list, db_path: str) -> bool:
except Exception:
pass
extracted = extract_from_news(text, timestamp=ts)
if ts:
extracted["_event_time"] = ts
if extracted and merge(extracted, db_path=db_path):
merged_any = True
return merged_any
@@ -172,15 +175,18 @@ def run_full_pipeline(
except Exception as e:
print(f" [warn] 正文抓取: {e}")
# 4. 映射到前端库字段并更新表
n_panel = write_updates(new_items) if new_items else 0
# 4. 映射到前端库字段并更新表(与去重/AI 使用同一 db path
n_panel = write_updates(new_items, db_path=path) if new_items else 0
if new_items:
_extract_and_merge(new_items, path)
# 5. 通知(有新增时才通知;可选:先执行外部逻辑如 GDELT 回填,再通知
# 4.5 每次运行都刷新 situation.updated_at便于前端显示「最后抓取时间」否则只有新增条目时才更新数据会一直停在旧日期
touch_situation_updated_at_path(db_path=path)
# 5. 通知(每次运行都通知,让 API 重载并广播最新 lastUpdated
if on_notify:
on_notify()
if notify and (n_panel > 0 or n_news > 0):
if notify:
_notify_api(base)
return len(items), n_news, n_panel

20
crawler/pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[project]
name = "usa-crawler"
version = "1.0.0"
description = "GDELT + RSS 爬虫与实时冲突服务"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"requests>=2.32.0",
"feedparser>=6.0.10",
"beautifulsoup4>=4.12.0",
"pytest>=8.0.0",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"deep-translator>=1.11.0",
"dashscope>=1.20.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]

View File

@@ -31,7 +31,7 @@ API_BASE = os.environ.get("API_BASE", "http://localhost:3001")
QUERY = os.environ.get("GDELT_QUERY", "United States Iran military")
MAX_RECORDS = int(os.environ.get("GDELT_MAX_RECORDS", "30"))
FETCH_INTERVAL_SEC = int(os.environ.get("FETCH_INTERVAL_SEC", "60"))
RSS_INTERVAL_SEC = int(os.environ.get("RSS_INTERVAL_SEC", "60")) # 每分钟抓取世界主流媒体
# RSS 抓取间隔:优先从 DB crawler_config 读取(修订面板可调),否则用环境变量或默认 60
# 时间范围1h=1小时 1d=1天 1week=1周不设则默认 3 个月(易返回旧文)
GDELT_TIMESPAN = os.environ.get("GDELT_TIMESPAN", "1d")
# 设为 1 则跳过 GDELT仅用 RSS 新闻作为事件脉络GDELT 国外可能无法访问)
@@ -48,6 +48,22 @@ if os.environ.get("CRAWLER_USE_PROXY") != "1":
EVENT_CACHE: List[dict] = []
def _get_rss_interval_sec() -> int:
"""从 DB crawler_config 读取 RSS 抓取间隔(秒),修订面板可调。无配置或异常时返回 60。"""
try:
if os.path.exists(DB_PATH):
conn = sqlite3.connect(DB_PATH, timeout=3)
row = conn.execute("SELECT rss_interval_sec FROM crawler_config WHERE id = 1").fetchone()
conn.close()
if row and row[0] is not None:
val = int(row[0])
if 30 <= val <= 86400:
return val
except Exception:
pass
return int(os.environ.get("RSS_INTERVAL_SEC", "60"))
# ==========================
# 冲突强度评分 (110)
# ==========================
@@ -297,8 +313,43 @@ def _rss_to_gdelt_fallback() -> None:
LAST_FETCH = {"items": 0, "inserted": 0, "error": None}
def _refresh_panel_data() -> int:
"""从近期事件重新提取并合并战损/据点等面板实时数据,不依赖本轮是否有新 RSS。返回合并条数。"""
if not os.path.exists(DB_PATH):
return 0
try:
from db_merge import merge
use_dashscope = bool(os.environ.get("DASHSCOPE_API_KEY", "").strip())
if use_dashscope:
from extractor_dashscope import extract_from_news
elif os.environ.get("CLEANER_AI_DISABLED", "0") == "1":
from extractor_rules import extract_from_news
else:
from extractor_ai import extract_from_news
conn = sqlite3.connect(DB_PATH, timeout=10)
rows = conn.execute(
"SELECT id, timestamp, category, summary FROM situation_update ORDER BY timestamp DESC LIMIT 50"
).fetchall()
conn.close()
merged = 0
for r in rows:
uid, ts, cat, summary = r
text = ((cat or "") + " " + (summary or "")).strip()
if len(text) < 20:
continue
try:
extracted = extract_from_news(text, timestamp=ts)
if extracted and merge(extracted, db_path=DB_PATH):
merged += 1
except Exception:
pass
return merged
except Exception:
return 0
def fetch_news() -> None:
"""执行完整写库流水线GDELT 禁用时用 RSS 回填 gdelt_events,再通知 Node"""
"""执行完整写库流水线;产出看板实时数据(战损、据点、冲突事件)+ 事件脉络。GDELT 禁用时用 RSS 回填 gdelt_events。"""
try:
from pipeline import run_full_pipeline
LAST_FETCH["error"] = None
@@ -314,7 +365,7 @@ def fetch_news() -> None:
_rss_to_gdelt_fallback()
_notify_node()
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] RSS 抓取 {n_fetched} 条,去重新增 {n_news}资讯,写入事件脉络 {n_panel}")
print(f"[{ts}] 抓取 {n_fetched} 条,去重新增 {n_news},写脉络 {n_panel} → 面板实时数据(战损/据点)已由本批提取更新")
if n_fetched == 0:
print(f"[{ts}] 0 条检查网络、RSS 源或 KEYWORDS 过滤)")
except Exception as e:
@@ -322,6 +373,10 @@ def fetch_news() -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻抓取失败: {e}")
# 每 N 轮做一次「从近期事件回填面板实时数据」,保证战损/据点等与最新内容一致
BACKFILL_CYCLES = int(os.environ.get("BACKFILL_CYCLES", "2"))
_cycle_count = 0
# ==========================
# 定时任务asyncio 后台任务,避免 APScheduler executor 关闭竞态)
# ==========================
@@ -329,16 +384,26 @@ _bg_task: Optional[asyncio.Task] = None
async def _periodic_fetch() -> None:
global _cycle_count
loop = asyncio.get_event_loop()
while True:
try:
await loop.run_in_executor(None, fetch_news)
await loop.run_in_executor(None, fetch_gdelt_events)
_cycle_count += 1
if _cycle_count >= BACKFILL_CYCLES:
_cycle_count = 0
merged = _refresh_panel_data()
if merged > 0:
_notify_node()
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] 面板实时数据回填:从近期事件合并 {merged} 条(战损/据点)")
except asyncio.CancelledError:
break
except Exception as e:
print(f" [warn] 定时抓取: {e}")
await asyncio.sleep(min(RSS_INTERVAL_SEC, FETCH_INTERVAL_SEC))
interval = min(_get_rss_interval_sec(), FETCH_INTERVAL_SEC)
await asyncio.sleep(interval)
# ==========================

View File

@@ -1,8 +1,10 @@
requests>=2.31.0
feedparser>=6.0.0
# Python 3.11+ 爬虫依赖(使用当前最新兼容版本)
# 安装: pip install -r crawler/requirements.txt
requests>=2.32.0
feedparser>=6.0.10
beautifulsoup4>=4.12.0
pytest>=7.0.0
fastapi>=0.109.0
uvicorn>=0.27.0
pytest>=8.0.0
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
deep-translator>=1.11.0
dashscope>=1.20.0

9
crawler/run_uvicorn.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# PM2 用:在 crawler 目录下启动 uvicornGDELT/RSS 实时服务 :8000
set -e
cd "$(dirname "$0")"
[ -n "$LANG" ] || export LANG="${LANG:-en_US.UTF-8}"
[ -n "$LC_ALL" ] || export LC_ALL="${LC_ALL:-en_US.UTF-8}"
# 若项目根目录有 .env可在此加载PM2 一般已在 ecosystem 里配 env
if [ -f ../.env ]; then set -a; . ../.env; set +a; fi
exec python3 -m uvicorn realtime_conflict_service:app --host 0.0.0.0 --port 8000

View File

@@ -3,6 +3,7 @@
import re
import socket
from datetime import datetime, timezone
from typing import List, Set, Tuple
import feedparser
@@ -33,7 +34,7 @@ def _matches_keywords(text: str) -> bool:
return False
def _fetch_one_feed(name: str, url: str, timeout: int) -> list[dict]:
def _fetch_one_feed(name: str, url: str, timeout: int) -> List[dict]:
"""抓取单个 RSS 源,超时或异常返回空列表。不负责去重。"""
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
@@ -72,14 +73,14 @@ def _fetch_one_feed(name: str, url: str, timeout: int) -> list[dict]:
return out
def fetch_all() -> list[dict]:
def fetch_all() -> List[dict]:
"""抓取所有配置的 RSS 源,按源超时与隔离错误,全局去重后返回。"""
sources = get_feed_sources()
if not sources:
return []
items: list[dict] = []
seen: set[tuple[str, str]] = set()
items: List[dict] = []
seen: Set[Tuple[str, str]] = set()
for name, url in sources:
batch = _fetch_one_feed(name, url, FEED_TIMEOUT)
@@ -88,7 +89,6 @@ def fetch_all() -> list[dict]:
if key in seen:
continue
seen.add(key)
# 写入 DB 的 schema 不包含 source可后续扩展
items.append({k: v for k, v in item.items() if k != "source"})
items.append(item)
return items

View File

@@ -13,10 +13,16 @@ def _is_mostly_chinese(text: str) -> bool:
def translate_to_chinese(text: str) -> str:
"""将文本翻译成中文,失败或已是中文则返回原文。Google 失败时尝试 MyMemory。"""
"""将文本翻译成中文,失败或已是中文则返回原文。
说明:
- 默认关闭外部翻译deep_translator直接返回原文避免因网络或代理问题阻塞整条流水线。
- 如需开启翻译,可显式设置环境变量 TRANSLATE_DISABLED=0。
"""
if not text or not text.strip():
return text
if os.environ.get("TRANSLATE_DISABLED", "0") == "1":
# 默认禁用翻译TRANSLATE_DISABLED 未设置时视为开启(值为 "1"
if os.environ.get("TRANSLATE_DISABLED", "1") == "1":
return text
s = str(text).strip()
if len(s) > 2000:

View File

@@ -19,6 +19,8 @@ services:
build:
context: .
dockerfile: Dockerfile.crawler
ports:
- "8000:8000"
environment:
- DB_PATH=/data/data.db
- API_BASE=http://api:3001

269
docs/DEBUG_PANELS.md Normal file
View File

@@ -0,0 +1,269 @@
# 看板板块逐项调试指南
本文档按前端每个板块列出:**数据来源表**、**谁写入**、**如何验证**、**常见问题**,便于逐项排查。
---
## 数据流总览
```
前端 Dashboard
→ useReplaySituation() → situation (来自 WebSocket / GET /api/situation)
→ getSituation() 读 server/situationData.js
→ 从 SQLite (server/data.db) 多表 SELECT 后拼成 JSON
```
- **写入方**`server/seed.js`(初始化)、爬虫流水线(`crawler/pipeline.py` + `db_merge.py`、GDELT 服务(`gdelt_events` / `conflict_stats`)。
- **读入方**:仅 `server/situationData.js``getSituation()`,被 `/api/situation` 与 WebSocket 广播使用。
---
## 1. 顶栏 (HeaderPanel)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 最后更新时间 | `situation.lastUpdated` | `situation.updated_at`(表 `situation` id=1 | 爬虫 notify 时更新 |
| 在看/看过 | `stats.viewers` / `stats.cumulative` | `visits` / `visitor_count`,见 `POST /api/visit` | 与爬虫无关 |
| 美/伊战力条 | `usForces.powerIndex.overall` / `iranForces.powerIndex.overall` | `power_index` 表 | **仅 seed** |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.lastUpdated, .usForces.powerIndex.overall, .iranForces.powerIndex.overall'`
- 看板顶栏是否显示时间、双战力数值。
**常见问题**
- `lastUpdated` 不变:爬虫未调 `POST /api/crawler/notify` 或 Node 未执行 `reloadFromFile()`
- 战力条为 0未跑 seed 或 `power_index` 无数据。
---
## 2. 事件脉络 / 时间线 (TimelinePanel → EventTimelinePanel + RecentUpdatesPanel)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 近期更新列表 | `situation.recentUpdates` | `situation_update`ORDER BY timestamp DESC LIMIT 50 | 爬虫 `write_updates(new_items)` + seed 若干条 |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.recentUpdates | length'`
- `curl -s http://localhost:3001/api/situation | jq '.recentUpdates[0]'`
- 或用调试接口:`curl -s -H "x-api-key: $API_ADMIN_KEY" http://localhost:3001/api/db/dashboard | jq '.situation_update | length'`
**常见问题**
- 条数为 0未 seed 且爬虫未写入;或爬虫只跑 main.py入口 A未跑 gdelt入口 B仍会写 `situation_update`,但若 RSS 抓取 0 条则无新数据。
- 不更新:爬虫未启动;或未调 notify或 Node 与爬虫用的不是同一个 `data.db`(路径/环境变量不一致)。
---
## 3. 地图 (WarMap)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 美军据点 | `usForces.keyLocations` | `key_location` WHERE side='us' | seed 全量;爬虫通过 `key_location_updates` 只更新 status/damage_level |
| 伊朗据点 | `iranForces.keyLocations` | `key_location` WHERE side='iran' | 同上 |
| 冲突点(绿/橙/红) | `situation.conflictEvents` | `gdelt_events`ORDER BY event_time DESC LIMIT 30 | GDELT API 写入;或 GDELT 关闭时 RSS 回填 |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.usForces.keyLocations | length, .conflictEvents | length'`
- 地图上是否有基地/舰船点位、是否有冲突点图层。
**常见问题**
- 无冲突点:`gdelt_events` 为空;未跑 gdelt 或 GDELT 被墙且未用 RSS 回填(`_rss_to_gdelt_fallback`)。
- 基地状态不更新:爬虫提取的 `key_location_updates``name_keywords``key_location.name` 无法 LIKE 匹配(名称不一致)。
---
## 4. 美国基地状态 (BaseStatusPanel)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 基地列表 | `usForces.keyLocations``type === 'Base'` | `key_location` side='us' | 同 WarMap |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '[.usForces.keyLocations[] | select(.type == "Base")] | length'`
- 看板左侧「美国基地」是否展示且状态/损伤与预期一致。
**常见问题**
- 与「地图」一致;若 seed 的 key_location 有 type/region而爬虫只更新 status/damage_level名称必须能与 extractor 的 name_keywords 匹配。
---
## 5. 战损 (CombatLossesPanel + CombatLossesOtherPanel)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 美军/伊朗阵亡/受伤/装备等 | `usForces.combatLosses` / `iranForces.combatLosses` | `combat_losses`side=us/iran | seed 初始值;爬虫 AI 提取 `combat_losses_delta` 后 db_merge **增量**叠加 |
| 冲突统计(估计伤亡等) | `situation.conflictStats` | `conflict_stats` 表 id=1 | GDELT 或 RSS 回填时写入 |
| 平民伤亡合计 | `situation.civilianCasualtiesTotal` | 由 combat_losses 双方平民字段 + conflict_stats.estimated_casualties 计算 | 见 situationData.js |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.usForces.combatLosses, .iranForces.combatLosses, .conflictStats'`
- 看板战损数字是否与 API 一致。
**常见问题**
- 战损一直不变:新闻中无明确伤亡/装备数字;或未跑入口 Bgdelt或 AI 提取器未启用/报错Ollama/通义/规则);或 merge 时单次增量被上限截断。
- 数字异常大:提取器误把「累计总数」当成本条增量;已用 `MAX_DELTA_PER_MERGE` 做上限。
---
## 6. 伊朗基地状态 (IranBaseStatusPanel)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 基地/港/核/导弹等 | `iranForces.keyLocations` 中 type 为 Base/Port/Nuclear/Missile | `key_location` side='iran' | 同 WarMap |
**验证与常见问题**
- 同「美国基地」;确保 seed 中伊朗 key_location 的 name 与爬虫 extractor 的 name_keywords 能匹配(如德黑兰、伊斯法罕、布什尔等)。
---
## 7. 战力对比图 (PowerChart)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 美/伊战力指数 | `usForces.powerIndex` / `iranForces.powerIndex` | `power_index` 表 | **仅 seed**,爬虫不写 |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.usForces.powerIndex, .iranForces.powerIndex'`
**常见问题**
- 为 0 或缺失:未执行 seed`power_index` 表空。
---
## 8. 华尔街/投资趋势 (InvestmentTrendChart)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 时间序列 | `usForces.wallStreetInvestmentTrend` | `wall_street_trend`time, value | seed 写入初始曲线;爬虫仅在提取出 `wall_street`**INSERT 新点** |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.usForces.wallStreetInvestmentTrend | length'`
- 看板右侧美国下方趋势图是否有数据。
**常见问题**
- 无曲线:未 seed 或表空。
- 不随新闻更新:提取器未输出 `wall_street` 或新闻中无相关表述。
---
## 9. 美国力量摘要 (ForcePanel side=us)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 摘要数字 | `usForces.summary` | `force_summary` side='us' | **仅 seed** |
| 战力指数 | `usForces.powerIndex` | `power_index` | **仅 seed** |
| 资产列表 | `usForces.assets` | `force_asset` side='us' | **仅 seed** |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.usForces.summary, .usForces.assets | length'`
**常见问题**
- 全为 0 或空:未 seed爬虫不更新这些表。
---
## 10. 报复情绪 (RetaliationGauge)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 当前值 | `iranForces.retaliationSentiment` | `retaliation_current` id=1 | seed 初始;爬虫提取 `retaliation`**替换** 当前值并 **追加** history |
| 历史曲线 | `iranForces.retaliationSentimentHistory` | `retaliation_history` 表 | 同上 |
**验证**
- `curl -s http://localhost:3001/api/situation | jq '.iranForces.retaliationSentiment, .iranForces.retaliationSentimentHistory | length'`
**常见问题**
- 不更新:新闻中无报复相关表述;或提取器未输出 `retaliation`
---
## 11. 伊朗力量摘要 (ForcePanel side=iran)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 同美国侧 | `iranForces.summary` / `powerIndex` / `assets` | `force_summary` / `power_index` / `force_asset` side='iran' | **仅 seed** |
**验证与常见问题**
- 同「美国力量摘要」。
---
## 12. 资讯列表 (GET /api/news若有单独页面消费)
| 项目 | 数据来源 | 写入方 | 说明 |
|------|----------|--------|------|
| 资讯行 | `news_content` 表 | 爬虫 `save_and_dedup` 后写入 | 仅入口 B 流水线;事件脉络来自 situation_update资讯表独立 |
**验证**
- `curl -s -H "x-api-key: $API_ADMIN_KEY" http://localhost:3001/api/news?limit=5 | jq '.items | length'`
- 若未配 ADMIN_KEY部分环境可能不鉴权也可访问视 routes 配置而定。
**常见问题**
- `items` 为 0未跑入口 B或去重后无新增或 RSS 抓取 0 条。
---
## 快速检查命令汇总
```bash
# 1. API 与态势整体
curl -s http://localhost:3001/api/health
curl -s http://localhost:3001/api/situation | jq '{
lastUpdated,
recentUpdates: (.recentUpdates | length),
conflictEvents: (.conflictEvents | length),
usPower: .usForces.powerIndex.overall,
iranPower: .iranForces.powerIndex.overall,
usLosses: .usForces.combatLosses.personnelCasualties,
iranLosses: .iranForces.combatLosses.personnelCasualties,
usBases: (.usForces.keyLocations | length),
iranBases: (.iranForces.keyLocations | length),
wallStreetLen: (.usForces.wallStreetInvestmentTrend | length),
retaliationCur: .iranForces.retaliationSentiment
}'
# 2. 各表行数(需 sqlite3
DB="${DB_PATH:-server/data.db}"
for t in force_summary power_index force_asset key_location combat_losses wall_street_trend retaliation_current retaliation_history situation_update gdelt_events conflict_stats news_content; do
echo -n "$t: "; sqlite3 "$DB" "SELECT COUNT(*) FROM $t" 2>/dev/null || echo "?"
done
# 3. 爬虫状态与通知
curl -s http://localhost:8000/crawler/status | jq .
curl -s -X POST http://localhost:3001/api/crawler/notify
```
---
## 建议调试顺序
1. **先确认 API 与 DB 一致**`npm run api` 已起、`GET /api/situation` 返回 200`lastUpdated``recentUpdates` 等存在。
2. **确认 seed**:若从未 seed先跑 `node server/seed.js`(或项目提供的 seed 命令),再刷新看板,检查战力/摘要/基地/战损等是否有初始值。
3. **事件脉络**:确认爬虫已起(`npm run gdelt`、RSS 能抓到条数、`situation_update` 条数增加、notify 后前端/API 的 `recentUpdates` 增加。
4. **战损/基地/报复/美股**:确认跑的是入口 B、提取器可用Ollama 或 DASHSCOPE_API_KEY 或规则)、新闻内容包含可解析的伤亡/基地/报复表述;必要时用 crawler 的提取单测或 backfill 接口验证。
5. **地图冲突点**:确认 `gdelt_events` 有数据GDELT 或 RSS 回填);冲突统计看 `conflict_stats`
按上述顺序逐板块对照「数据来源 → 写入方 → 验证命令 → 常见问题」,即可定位每个板块不更新或显示异常的原因。
**若只关心战损、基地、地图战区**:见 **docs/DEBUG_战损_基地_地图.md**,并运行 `./scripts/debug-panels-focus.sh` 做专项检查。

View File

@@ -0,0 +1,135 @@
# 战损、基地、地图战区 — 专项调试
只关心这三块时,按下面数据源 + 排查顺序即可。
---
## 一、战损 (combat_losses)
### 数据流
```
RSS 新闻(标题+摘要/正文) → 爬虫流水线 run_full_pipeline
→ extract_from_news(text) → combat_losses_delta { us: { personnel_killed, ... }, iran: { ... } }
→ db_merge.merge() → 按「增量」叠加到 combat_losses 表
→ POST /api/crawler/notify → Node 重载 DB
→ getSituation() 读 combat_losses → 前端 CombatLossesPanel / CombatLossesOtherPanel
```
- **表**`combat_losses`side=us / iran字段含 personnel_killed、personnel_wounded、bases_destroyed、bases_damaged、aircraft、drones、missiles 等。
- **初始值**`node server/seed.js` 会写入美/伊两行。
- **更新条件**:只有新闻里**明确出现可解析的伤亡/装备数字**如「2 名美军死亡」「14 人受伤」「1 架战机受损」)时,提取器才会输出 `combat_losses_delta`merge 才会叠加。
### 提取器选择(三选一)
| 环境变量 | 使用模块 | 说明 |
|----------|----------|------|
| `DASHSCOPE_API_KEY` 已设 | `extractor_dashscope` | 通义抽取,精度较好 |
| 未设通义 且 `CLEANER_AI_DISABLED≠1` | `extractor_ai` | 需本机 Ollama如 llama3.1 |
| 未设通义 且 `CLEANER_AI_DISABLED=1` | `extractor_rules` | 规则正则,无需模型 |
### 验证命令
```bash
# API 返回的战损
curl -s http://localhost:3001/api/situation | jq '{
us: .usForces.combatLosses.personnelCasualties,
iran: .iranForces.combatLosses.personnelCasualties,
conflictStats: .conflictStats
}'
# 表内原始值
sqlite3 server/data.db "SELECT side, personnel_killed, personnel_wounded, bases_destroyed, bases_damaged, aircraft FROM combat_losses"
```
### 常见问题
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 战损数字从不变化 | 1) 只跑了 main.py 未跑 gdelt<br>2) 新闻里没有明确伤亡/装备数字<br>3) 提取器未启用或报错Ollama 未起、通义未配) | 跑 `npm run gdelt`;用带数字的新闻测;看爬虫日志是否有提取/merge 报错 |
| 数字暴增一次 | 提取器把「累计总数」当成本条增量 | 已用 MAX_DELTA_PER_MERGE 做单次上限;可查 db_merge.py |
| 想用已有事件脉络重算战损 | 历史新闻当时未做提取 | `curl -X POST http://localhost:8000/crawler/backfill` 用 situation_update 最近 50 条重新提取并 merge |
---
## 二、基地 (key_location)
### 数据流
```
RSS 新闻 → extract_from_news → key_location_updates: [ { name_keywords, side, status, damage_level } ]
→ db_merge.merge() → UPDATE key_location SET status=?, damage_level=? WHERE side=? AND (name LIKE ? OR ...)
→ getSituation() 读 key_location → 前端 BaseStatusPanel(美) / IranBaseStatusPanel(伊) / WarMap 据点层
```
- **表**`key_location`side=us / iran字段含 name、lat、lng、type、region、**status**、**damage_level**。
- **初始数据**seed 写入大量美/伊据点和基地(含 name**爬虫只更新已有行的 status、damage_level**,不新增行。
- **匹配规则**:提取器的 `name_keywords`(如 `阿萨德|asad`)会按 **LIKE '%关键词%'**`key_location.name` 匹配。例如 name 为「阿萨德空军基地」时,关键词「阿萨德」能匹配。
### 规则提取器支持的基地关键词(与 seed name 对应关系)
- **美军**:阿萨德|阿因|asad → 匹配 seed「阿萨德空军基地」「阿因·阿萨德」巴格达 → 巴格达外交支援中心;乌代德|卡塔尔 → 乌代德空军基地;埃尔比勒 → 埃尔比勒空军基地;因吉尔利克|土耳其 → 因吉尔利克空军基地;苏尔坦|沙特 → 苏尔坦亲王空军基地;坦夫|叙利亚 → 坦夫驻军;达夫拉|阿联酋 → 达夫拉空军基地;内瓦提姆|拉蒙|以色列 → 内瓦提姆/拉蒙等;赛利耶、巴林、科威特 等。
- **伊朗**:阿巴斯港、德黑兰、布什尔、伊斯法罕、纳坦兹、米纳布、霍尔木兹 等seed 中需有对应 name 的伊朗据点)。
若 seed 里没有某据点,或 name 与关键词完全对不上(例如英文报道只写 "Al-Asad" 而 seed 只有「阿萨德空军基地」),规则里已含 asad/阿萨德,一般能匹配;若仍不匹配,可查 `key_location.name` 与 extractor_rules.py / extractor_dashscope 的 name_keywords 是否有一致子串。
### 验证命令
```bash
# 被标为遭袭的据点
curl -s http://localhost:3001/api/situation | jq '[.usForces.keyLocations[], .iranForces.keyLocations[]] | map(select(.status == "attacked")) | length'
# 表内 status / damage_level
sqlite3 server/data.db "SELECT side, name, status, damage_level FROM key_location WHERE status != 'operational' OR damage_level IS NOT NULL LIMIT 20"
```
### 常见问题
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 基地状态从不更新 | 1) 新闻未提及「某基地遭袭」类表述<br>2) 提取的 name_keywords 与 key_location.name 无法 LIKE 匹配 | 确认 seed 的 name 含中文/英文与提取器关键词一致;或扩展 extractor 的 name_keywords |
| 地图/基地面板无据点 | key_location 表空 | 先执行 `node server/seed.js` |
---
## 三、地图战区 / 冲突点 (gdelt_events + conflict_stats)
### 数据流
- **正常模式**`fetch_gdelt_events()` 请求 GDELT API → 解析为事件列表 → `_write_to_db(events)` 写入 `gdelt_events``conflict_stats`(总事件数、高影响事件数、估计伤亡、打击次数等)。
- **GDELT 不可用**:设 `GDELT_DISABLED=1` 时,`fetch_news()` 里在流水线结束后调 `_rss_to_gdelt_fallback()`,用 **situation_update 最近 50 条** 按 summary 推断经纬度(`_infer_coords`)和 impact_score由 severity 映射),写入 `gdelt_events`,这样地图仍有冲突点。
前端 WarMap 根据 `conflictEvents`= gdelt_events的 impact_score 分绿/橙/红三层显示;战损区「冲突统计」来自 `conflict_stats`
### 验证命令
```bash
# 冲突点条数 + 冲突统计
curl -s http://localhost:3001/api/situation | jq '{ conflictEvents: (.conflictEvents | length), conflictStats: .conflictStats }'
# 表内
sqlite3 server/data.db "SELECT COUNT(*) FROM gdelt_events"
sqlite3 server/data.db "SELECT * FROM conflict_stats WHERE id = 1"
```
### 常见问题
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 地图没有冲突点 | 1) gdelt_events 表空<br>2) 未跑 gdelt 或 GDELT 被墙且未开 RSS 回填 | 跑 `npm run gdelt`;国内可设 `GDELT_DISABLED=1`,靠 situation_update 回填 |
| 冲突点不更新 | 爬虫未调 notify或 Node/爬虫用的不是同一个 data.db | 确认 API_BASE、DB_PATH 一致;看 Node 终端是否有 `[crawler/notify] DB 已重载` |
| conflict_stats 全 0 | 从未成功写入过 gdelt_eventsGDELT 与 RSS 回填都未执行) | 先让 gdelt_events 有数据(见上) |
---
## 四、一键检查(仅战损 / 基地 / 地图)
在项目根执行:
```bash
./scripts/debug-panels-focus.sh
```
会检查API 是否通、`combat_losses` / `key_location` / `gdelt_events` / `conflict_stats` 行数及关键字段、并给出简短结论。需已启动 API`npm run api`);可选 `jq``sqlite3` 以输出更全。
详细逐板块说明见 `docs/DEBUG_PANELS.md`

View File

@@ -0,0 +1,69 @@
# 华尔街财团投入指数 & 反击情绪指数:更新逻辑与波动说明
## 一、数据流概览
```
爬虫提取 (extractor_ai / extractor_dashscope)
→ retaliation_sentiment / wall_street_value (0100按单篇报道)
→ db_merge.merge(extracted)
→ SQLite: retaliation_current, retaliation_history, wall_street_trend
→ server/situationData.js getSituation()
→ 前端: iranForces.retaliationSentiment(History), usForces.wallStreetInvestmentTrend
→ 组件: RetaliationGauge, InvestmentTrendChart回放: useReplaySituation 插值
```
## 二、当前写入逻辑crawler/db_merge.py
### 1. 华尔街财团投入指数 (wall_street_trend)
- **表**`wall_street_trend(id, time, value)`,前端用整表做 `wallStreetInvestmentTrend` 折线。
- **写入**:每次爬虫产出 `extracted["wall_street"]` 时:
-`value` 做限幅 `[1, 99]`**不做任何平滑**
- 直接 `INSERT INTO wall_street_trend (time, value) VALUES (?, ?)`
- 表保留最近 `WALL_STREET_TREND_MAX_ROWS`200条。
- **波动原因**
- 每条报道一个点,爬虫频繁时点很密;
- 不同报道提取值差异大(如 30 / 80 / 45曲线会剧烈锯齿
- 无“与上一点平滑”,无时间间隔限制,易受单条 0/100 或异常值影响(虽已夹到 199
### 2. 反击情绪指数 (retaliation_current + retaliation_history)
- **表**`retaliation_current(id=1, value)` 当前值;`retaliation_history(id, time, value)` 历史曲线。
- **写入**:每次爬虫产出 `extracted["retaliation"]` 时:
- 当前值平滑:`new_val = 0.6 * current + 0.4 * raw`,再夹到 [1, 99]
- `retaliation_current` 更新为该 `new_val`
- `retaliation_history` 追加一条 `(time, new_val)`
- 历史表保留最近 `RETALIATION_HISTORY_MAX_ROWS`300条。
- **波动原因**
- 多条新闻短时间连续写入时,每次都用新的 raw 更新 current连续多步 0.6/0.4 仍会快速漂移;
- history 每写一次就一个点,点过密且 raw 差异大时折线仍会明显抖动;
- 单步无“最大变化幅度”限制,极端 raw 仍会导致单次跳动较大。
## 三、爬虫侧产出形态
- **extractor_ai / extractor_dashscope**:仅当报道涉及对应维度时才输出:
- `retaliation_sentiment`0100
- `wall_street_value`0100。
- 每条报道独立一个值,多篇报道会多次调用 `merge()`,因此**波动确实主要由爬虫数据更新频率和单条取值差异导致**。
## 四、稳定化思路(计算模块)
1. **华尔街**
- 与**上一点**做平滑再写入:例如 `value = alpha * last_value + (1-alpha) * raw`,再限幅;
- 可选:仅当距上一条时间超过一定间隔(如 1530 分钟)才 INSERT减少密集点带来的锯齿。
2. **反击情绪**
- 加强平滑例如提高当前值权重0.8 * current + 0.2 * raw
- 单步变化上限:例如 `new_val` 相对 `current` 最多 ±N如 5
- 历史记录:可对 history 做同样限幅或间隔写入,避免曲线过密抖动。
实现位置:**crawler/indicator_smooth.py**,在 `db_merge.merge()` 中调用,仍写回现有表,前端与 API 契约不变。
### 已实现参数(可调)
| 指标 | 参数 | 默认 | 说明 |
|------|------|------|------|
| 华尔街 | `WALL_STREET_NEW_WEIGHT` | 0.35 | 新 raw 权重,越小曲线越平滑 |
| 华尔街 | `WALL_STREET_MIN_INTERVAL_MINUTES` | 20 | 两笔趋势最小间隔(分钟),不足则本条不写入 |
| 反击情绪 | `RETALIATION_CURRENT_WEIGHT` | 0.8 | 当前值权重,越大越平滑 |
| 反击情绪 | `RETALIATION_MAX_STEP` | 5 | 单次相对当前值最大变化幅度(点) |

68
docs/PRODUCTION.md Normal file
View File

@@ -0,0 +1,68 @@
# 生产部署与数据对齐
## 1. 当前项目是否能在 Docker 中单独运行
- **能**。爬虫镜像 `Dockerfile.crawler` 自包含 Python 3.11 + `crawler/requirements.txt`(含 dashscope无宿主机 Python 版本依赖。
- **两种常见用法**
- **docker-compose 一起跑**API + 爬虫都在容器内,共用一个命名卷 `app-data`,天然对齐。
- **爬虫单独 Docker、API 在宿主机**:爬虫容器通过挂载宿主机上的 **同一个** `server/data.db`,并设置 `API_BASE` 指向宿主机 API即可单独运行且数据一致。
## 2. 数据对齐(必须满足)
| 角色 | 使用的 DB 路径(示例) | 说明 |
|--------|-------------------------------|------|
| Node API | `process.env.DB_PATH``server/data.db` | 见 `server/db.js``docker-entrypoint.sh` |
| 爬虫Docker 内) | `DB_PATH=/data/data.db`,且 `/data/data.db` 由宿主机同一文件挂载 | 见 `Dockerfile.crawler``crawler/config.py` |
**原则**API 和爬虫必须读写 **同一个 SQLite 文件**。否则会出现「爬虫写了库、API 读不到」或反之。
- **docker-compose 全容器**:两边都用卷 `app-data`,路径均为 `/data/data.db`,自动对齐。
- **API 宿主机 + 爬虫 Docker**:宿主机 API 的 `DB_PATH` 指向例如 `$PROJECT/server/data.db`;爬虫启动时用 `-v $PROJECT/server/data.db:/data/data.db``-e DB_PATH=/data/data.db`,即对齐。
## 3. 生产脚本与用法
### 3.1 爬虫单独 DockerAPI 在宿主机,如 PM2
```bash
# 首次:构建镜像并启动爬虫容器(会读 .env 中的 DASHSCOPE_API_KEY
./scripts/production-start.sh
# 或分步:
docker build -t usa-dashboard-crawler:latest -f Dockerfile.crawler .
./scripts/run-crawler-docker-standalone.sh
```
可调环境变量(在运行脚本前 export 或写在 .env
- `PROJECT_ROOT`:项目根目录,默认当前目录;用于解析 `server/data.db`
- `DB_FILE`:宿主机 DB 绝对路径,默认 `$PROJECT_ROOT/server/data.db`
- `API_BASE`:爬虫通知 API 的地址,默认 `http://host.docker.internal:3001`Linux 下脚本会自动加 `--add-host=host.docker.internal:host-gateway`)。
- `DASHSCOPE_API_KEY`:阿里云 DashScope启用 AI 清洗(可选)。
### 3.2 docker-compose 全栈API + 爬虫都在容器)
```bash
# 启动
docker compose up -d
# 或传入 DASHSCOPE_API_KEY
DASHSCOPE_API_KEY=sk-xxx docker compose up -d
# 停止
docker compose down
```
此时 API 与爬虫共用卷 `app-data`DB 路径均为 `/data/data.db`,无需额外对齐。
### 3.3 宿主机 APIPM2使用的 DB 路径
确保 PM2 启动 API 时使用的 DB 与爬虫挂载的是同一文件,例如:
- 在 ecosystem 或启动命令里设置:`DB_PATH=/www/wwwroot/www.airtep.com2/usa/server/data.db`
- 或项目根目录即部署目录时,不设则默认为 `server/data.db`(相对路径以进程 cwd 为准)。
## 4. 检查清单
- [ ] API 与爬虫使用**同一 DB 文件**(见上表)。
- [ ] 爬虫能访问到 API`API_BASE` 在「爬虫单独 Docker」场景下指向宿主机`http://host.docker.internal:3001`),在 compose 场景下为 `http://api:3001`
- [ ] 若需 AI 清洗:在爬虫侧设置 `DASHSCOPE_API_KEY`compose 或 standalone 脚本的 .env/环境变量)。
- [ ] 首次部署或无 DB 时:先创建并初始化 DB例如 `DB_PATH=server/data.db node server/seed.js`),再启动爬虫容器。

43
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,43 @@
/**
* PM2 进程配置API + 爬虫GDELT/RSS uvicorn 服务)
* 用法:
* pm2 start ecosystem.config.cjs # 启动全部
* pm2 restart ecosystem.config.cjs # 重启全部
* pm2 stop ecosystem.config.cjs # 停止全部
* pm2 logs nsa_api / pm2 logs nsa_crawler
* 需 .env 时可在启动前 source .env或在应用内用 dotenv 加载。
*/
module.exports = {
apps: [
{
name: 'nsa_api',
script: 'server/index.js',
cwd: __dirname,
interpreter: 'node',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
API_PORT: 3001,
},
},
{
name: 'nsa_crawler',
script: 'crawler/run_uvicorn.sh',
cwd: __dirname,
interpreter: 'bash',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '300M',
env: {
CLEANER_AI_DISABLED: '1',
PARSER_AI_DISABLED: '0',
GDELT_DISABLED: '1',
RSS_INTERVAL_SEC: '60',
},
},
],
};

View File

@@ -2,9 +2,15 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/usa_logo.png" />
<link rel="icon" type="image/png" href="/usa_logo.png" />
<link rel="apple-touch-icon" href="/usa_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>美伊军事态势显示</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="美伊军事态势显示" />
<meta property="og:image" content="/usa_logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:image" content="/usa_logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">

6
map.md
View File

@@ -282,3 +282,9 @@ const IRAN_SOURCE = [51.3890, 35.6892] // Tehran
所有动画走 WebGL 图层
禁止 DOM 动画
git代码更新:git fetch origin && git reset --hard origin/master
前端发版npm run build
后端发版pm2 restart 3

BIN
public/usa_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

6
run.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
cd /www/wwwroot/www.airtep.com2/usa
git fetch origin && git reset --hard origin/master && npm run build && pm2 restart 4
# 同步更新爬虫 Docker 容器(使用最新代码重新构建镜像并重启)
docker build -t usa-dashboard-crawler:latest -f Dockerfile.crawler . && bash scripts/run-crawler-docker-standalone.sh

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# ç”± scripts/gen-align-schema-from-local.sh æ ¹æ<C2B9>®æœ¬åœ° server/data.db 表结构生æˆ<C3A6>,ä¾ç”Ÿäº§æ‰§è¡Œã€
# 用法:在生产目录执行 DB_PATH=server/data.db ./scripts/align-production-schema.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
run() { sqlite3 "$DB_PATH" "$1" 2>/dev/null || true; }
echo "=== 对é½<C3A9>生产库表结构(与本地 data.db 一致):$DB_PATH ==="
run "ALTER TABLE combat_losses ADD COLUMN bases_destroyed INTEGER NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE combat_losses ADD COLUMN bases_damaged INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN personnel_killed INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN personnel_wounded INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN aircraft INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN warships INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN armor INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN vehicles INTEGER NOT NULL;"
run "ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN drones INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN missiles INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN helicopters INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN submarines INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN tanks INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN carriers INTEGER NOT NULL DEFAULT 0;"
run "UPDATE combat_losses SET carriers = COALESCE(tanks, 0) WHERE carriers = 0;"
run "ALTER TABLE combat_losses ADD COLUMN civilian_ships INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE combat_losses ADD COLUMN airport_port INTEGER NOT NULL DEFAULT 0;"
echo " combat_losses done"
run "ALTER TABLE conflict_stats ADD COLUMN total_events INTEGER NOT NULL DEFAULT 0;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE conflict_stats ADD COLUMN high_impact_events INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE conflict_stats ADD COLUMN estimated_casualties INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE conflict_stats ADD COLUMN estimated_strike_count INTEGER NOT NULL DEFAULT 0;"
run "ALTER TABLE conflict_stats ADD COLUMN updated_at TEXT NOT NULL;"
echo " conflict_stats done"
run "ALTER TABLE display_stats ADD COLUMN viewers INTEGER;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE display_stats ADD COLUMN share_count INTEGER;"
run "ALTER TABLE display_stats ADD COLUMN like_count INTEGER;"
run "ALTER TABLE display_stats ADD COLUMN override_enabled INTEGER NOT NULL DEFAULT 0;"
echo " display_stats done"
run "ALTER TABLE feedback ADD COLUMN content TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE feedback ADD COLUMN ip TEXT;"
run "ALTER TABLE feedback ADD COLUMN created_at TEXT NOT NULL;"
echo " feedback done"
run "ALTER TABLE force_asset ADD COLUMN side TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE force_asset ADD COLUMN name TEXT NOT NULL;"
run "ALTER TABLE force_asset ADD COLUMN type TEXT NOT NULL;"
run "ALTER TABLE force_asset ADD COLUMN count INTEGER NOT NULL;"
run "ALTER TABLE force_asset ADD COLUMN status TEXT NOT NULL;"
run "ALTER TABLE force_asset ADD COLUMN lat REAL;"
run "ALTER TABLE force_asset ADD COLUMN lng REAL;"
echo " force_asset done"
run "ALTER TABLE force_summary ADD COLUMN total_assets INTEGER NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE force_summary ADD COLUMN personnel INTEGER NOT NULL;"
run "ALTER TABLE force_summary ADD COLUMN naval_ships INTEGER NOT NULL;"
run "ALTER TABLE force_summary ADD COLUMN aircraft INTEGER NOT NULL;"
run "ALTER TABLE force_summary ADD COLUMN ground_units INTEGER NOT NULL;"
run "ALTER TABLE force_summary ADD COLUMN uav INTEGER NOT NULL;"
run "ALTER TABLE force_summary ADD COLUMN missile_consumed INTEGER NOT NULL;"
run "ALTER TABLE force_summary ADD COLUMN missile_stock INTEGER NOT NULL;"
echo " force_summary done"
run "ALTER TABLE gdelt_events ADD COLUMN event_time TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE gdelt_events ADD COLUMN title TEXT NOT NULL;"
run "ALTER TABLE gdelt_events ADD COLUMN lat REAL NOT NULL;"
run "ALTER TABLE gdelt_events ADD COLUMN lng REAL NOT NULL;"
run "ALTER TABLE gdelt_events ADD COLUMN impact_score INTEGER NOT NULL;"
run "ALTER TABLE gdelt_events ADD COLUMN url TEXT;"
run "ALTER TABLE gdelt_events ADD COLUMN created_at TEXT;"
echo " gdelt_events done"
run "ALTER TABLE key_location ADD COLUMN side TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE key_location ADD COLUMN name TEXT NOT NULL;"
run "ALTER TABLE key_location ADD COLUMN lat REAL NOT NULL;"
run "ALTER TABLE key_location ADD COLUMN lng REAL NOT NULL;"
run "ALTER TABLE key_location ADD COLUMN type TEXT;"
run "ALTER TABLE key_location ADD COLUMN region TEXT;"
run 'ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT '\''operational'\'';'
run "ALTER TABLE key_location ADD COLUMN damage_level INTEGER;"
run "ALTER TABLE key_location ADD COLUMN attacked_at TEXT;"
echo " key_location done"
run "ALTER TABLE like_count ADD COLUMN total INTEGER NOT NULL DEFAULT 0;"
# ¼ˆæœ¬åœ°åˆ—)
echo " like_count done"
run "ALTER TABLE map_strike_line ADD COLUMN target_lng REAL NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE map_strike_line ADD COLUMN target_lat REAL NOT NULL;"
run "ALTER TABLE map_strike_line ADD COLUMN target_name TEXT;"
run "ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT;"
echo " map_strike_line done"
run "ALTER TABLE map_strike_source ADD COLUMN name TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE map_strike_source ADD COLUMN lng REAL NOT NULL;"
run "ALTER TABLE map_strike_source ADD COLUMN lat REAL NOT NULL;"
echo " map_strike_source done"
run "ALTER TABLE news_content ADD COLUMN content_hash TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE news_content ADD COLUMN title TEXT NOT NULL;"
run "ALTER TABLE news_content ADD COLUMN summary TEXT NOT NULL;"
run 'ALTER TABLE news_content ADD COLUMN url TEXT NOT NULL DEFAULT '\'''\'';'
run 'ALTER TABLE news_content ADD COLUMN source TEXT NOT NULL DEFAULT '\'''\'';'
run "ALTER TABLE news_content ADD COLUMN published_at TEXT NOT NULL;"
run 'ALTER TABLE news_content ADD COLUMN category TEXT NOT NULL DEFAULT '\''other'\'';'
run 'ALTER TABLE news_content ADD COLUMN severity TEXT NOT NULL DEFAULT '\''medium'\'';'
run "ALTER TABLE news_content ADD COLUMN created_at TEXT NOT NULL;"
echo " news_content done"
run "ALTER TABLE power_index ADD COLUMN overall INTEGER NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE power_index ADD COLUMN military_strength INTEGER NOT NULL;"
run "ALTER TABLE power_index ADD COLUMN economic_power INTEGER NOT NULL;"
run "ALTER TABLE power_index ADD COLUMN geopolitical_influence INTEGER NOT NULL;"
echo " power_index done"
run "ALTER TABLE retaliation_current ADD COLUMN value INTEGER NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
echo " retaliation_current done"
run "ALTER TABLE retaliation_history ADD COLUMN time TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE retaliation_history ADD COLUMN value INTEGER NOT NULL;"
echo " retaliation_history done"
run "ALTER TABLE share_count ADD COLUMN total INTEGER NOT NULL DEFAULT 0;"
# ¼ˆæœ¬åœ°åˆ—)
echo " share_count done"
run "ALTER TABLE situation ADD COLUMN data TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE situation ADD COLUMN updated_at TEXT NOT NULL;"
echo " situation done"
run "ALTER TABLE situation_update ADD COLUMN timestamp TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE situation_update ADD COLUMN category TEXT NOT NULL;"
run "ALTER TABLE situation_update ADD COLUMN summary TEXT NOT NULL;"
run "ALTER TABLE situation_update ADD COLUMN severity TEXT NOT NULL;"
echo " situation_update done"
run "ALTER TABLE visitor_count ADD COLUMN total INTEGER NOT NULL DEFAULT 0;"
# ¼ˆæœ¬åœ°åˆ—)
echo " visitor_count done"
run "ALTER TABLE visits ADD COLUMN last_seen TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
echo " visits done"
run "ALTER TABLE wall_street_trend ADD COLUMN time TEXT NOT NULL;"
# ¼ˆæœ¬åœ°åˆ—)
run "ALTER TABLE wall_street_trend ADD COLUMN value INTEGER NOT NULL;"
echo " wall_street_trend done"
echo "=== 完æˆ<C3A6>ã€æ ¸å¯¹ç¤ºä¾ï¼š ==="
echo " sqlite3 $DB_PATH \"PRAGMA table_info(key_location);\""
echo " sqlite3 $DB_PATH \"PRAGMA table_info(combat_losses);\""

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# 检查攻击地点/打击线是否完整(与 seed.js 一致)
# 用法: DB_PATH=server/data.db ./scripts/check-attack-locations.sh
# 裸机: cd /root/usa && ./scripts/check-attack-locations.sh
set -e
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 "错误: 数据库文件不存在"
exit 1
fi
if ! command -v sqlite3 &>/dev/null; then
echo "需要 sqlite3。安装: yum install sqlite 或 apt install sqlite3"
exit 1
fi
# 期望数量(与 server/seed.js 一致)
EXPECT_US=62 # getUsLocations: naval + attacked + newBases
EXPECT_IRAN=18 # iranLocs 条数
EXPECT_ISRAEL=4
EXPECT_LINCOLN=5
EXPECT_FORD=7
n_us=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM key_location WHERE side='us';" 2>/dev/null || echo "0")
n_iran=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM key_location WHERE side='iran';" 2>/dev/null || echo "0")
n_israel=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM map_strike_line WHERE source_id='israel';" 2>/dev/null || echo "0")
n_lincoln=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM map_strike_line WHERE source_id='lincoln';" 2>/dev/null || echo "0")
n_ford=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM map_strike_line WHERE source_id='ford';" 2>/dev/null || echo "0")
echo ""
echo "key_location:"
echo " us (美军基地等): 当前 $n_us 条,期望 $EXPECT_US"
echo " iran (伊朗被袭点): 当前 $n_iran 条,期望 $EXPECT_IRAN"
echo ""
echo "map_strike_line (盟军打击伊朗):"
echo " israel: 当前 $n_israel 条,期望 $EXPECT_ISRAEL"
echo " lincoln: 当前 $n_lincoln 条,期望 $EXPECT_LINCOLN"
echo " ford: 当前 $n_ford 条,期望 $EXPECT_FORD"
echo "=========================================="
ok=0
[[ "$n_us" -ge "$EXPECT_US" ]] && [[ "$n_iran" -ge "$EXPECT_IRAN" ]] && \
[[ "$n_israel" -ge "$EXPECT_ISRAEL" ]] && [[ "$n_lincoln" -ge "$EXPECT_LINCOLN" ]] && \
[[ "$n_ford" -ge "$EXPECT_FORD" ]] && ok=1
if [[ $ok -eq 1 ]]; then
echo "结论: 攻击地点/打击线数量正常"
exit 0
fi
echo "结论: 数量不足,请在生产执行 seed 以与当前代码一致:"
echo " cd $PROJECT_ROOT"
echo " cp server/data.db server/data.db.bak-\$(date +%Y%m%d-%H%M%S)"
echo " DB_PATH=server/data.db node server/seed.js"
echo " 重启 API 后刷新页面"
exit 1

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* 检查爬虫写入的数据:条数 + 最近内容situation_update、news_content、gdelt_events
* 用法(项目根目录): node scripts/check-crawler-data.cjs
* 可选:先启动爬虫 npm run gdelt再启动 API 或直接运行本脚本读 DB
*/
const path = require('path')
const http = require('http')
const projectRoot = path.resolve(__dirname, '..')
process.chdir(projectRoot)
const db = require('../server/db')
const CRAWLER_URL = process.env.CRAWLER_URL || 'http://localhost:8000'
const SHOW_ROWS = 10
function fetchCrawlerStatus() {
return new Promise((resolve) => {
const url = new URL(`${CRAWLER_URL}/crawler/status`)
const req = http.request(
{ hostname: url.hostname, port: url.port || 80, path: url.pathname, method: 'GET', timeout: 3000 },
(res) => {
let body = ''
res.on('data', (c) => (body += c))
res.on('end', () => {
try {
resolve(JSON.parse(body))
} catch {
resolve(null)
}
})
}
)
req.on('error', () => resolve(null))
req.end()
})
}
async function run() {
console.log('========================================')
console.log('爬虫数据检查(条数 + 最近内容)')
console.log('========================================\n')
// ---------- 爬虫服务状态(可选)----------
const status = await fetchCrawlerStatus()
if (status) {
console.log('--- 爬虫服务状态 GET /crawler/status ---')
console.log(' db_path:', status.db_path)
console.log(' db_exists:', status.db_exists)
console.log(' situation_update_count:', status.situation_update_count)
console.log(' last_fetch_items:', status.last_fetch_items, '(本轮抓取条数)')
console.log(' last_fetch_inserted:', status.last_fetch_inserted, '(去重后新增)')
if (status.last_fetch_error) console.log(' last_fetch_error:', status.last_fetch_error)
console.log('')
} else {
console.log('--- 爬虫服务 ---')
console.log(' 未启动或不可达:', CRAWLER_URL)
console.log('')
}
// ---------- situation_update事件脉络看板「近期更新」----------
let situationUpdateRows = []
let situationUpdateCount = 0
try {
situationUpdateCount = db.prepare('SELECT COUNT(*) as c FROM situation_update').get().c
situationUpdateRows = db
.prepare(
'SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT ?'
)
.all(SHOW_ROWS)
} catch (e) {
console.log('situation_update 表读取失败:', e.message)
}
console.log('--- situation_update事件脉络---')
console.log(' 总条数:', situationUpdateCount)
if (situationUpdateRows.length > 0) {
console.log(' 最近', situationUpdateRows.length, '条:')
situationUpdateRows.forEach((r, i) => {
const summary = (r.summary || '').slice(0, 50)
console.log(` ${i + 1}. [${r.timestamp}] ${r.category}/${r.severity} ${summary}${summary.length >= 50 ? '…' : ''}`)
})
}
console.log('')
// ---------- news_content资讯表爬虫去重后写入----------
let newsCount = 0
let newsRows = []
try {
newsCount = db.prepare('SELECT COUNT(*) as c FROM news_content').get().c
newsRows = db
.prepare(
'SELECT title, summary, source, published_at, category, severity FROM news_content ORDER BY published_at DESC LIMIT ?'
)
.all(SHOW_ROWS)
} catch (e) {
console.log('news_content 表读取失败:', e.message)
}
console.log('--- news_content资讯表---')
console.log(' 总条数:', newsCount)
if (newsRows.length > 0) {
console.log(' 最近', newsRows.length, '条:')
newsRows.forEach((r, i) => {
const title = (r.title || '').slice(0, 45)
console.log(` ${i + 1}. [${r.published_at || ''}] ${r.source || ''} ${title}${title.length >= 45 ? '…' : ''}`)
if (r.summary) console.log(` summary: ${(r.summary || '').slice(0, 60)}`)
})
}
console.log('')
// ---------- gdelt_events地图冲突点----------
let gdeltCount = 0
let gdeltRows = []
try {
gdeltCount = db.prepare('SELECT COUNT(*) as c FROM gdelt_events').get().c
gdeltRows = db
.prepare('SELECT event_id, event_time, title, impact_score FROM gdelt_events ORDER BY event_time DESC LIMIT 5')
.all()
} catch (e) {
console.log('gdelt_events 表读取失败:', e.message)
}
console.log('--- gdelt_events地图冲突点---')
console.log(' 总条数:', gdeltCount)
if (gdeltRows.length > 0) {
console.log(' 最近 5 条:')
gdeltRows.forEach((r, i) => {
const title = (r.title || '').slice(0, 50)
console.log(` ${i + 1}. [${r.event_time}] impact=${r.impact_score} ${title}${title.length >= 50 ? '…' : ''}`)
})
}
console.log('========================================')
}
db.initDb().then(() => run()).catch((err) => {
console.error('失败:', err.message)
process.exit(1)
})

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 "=========================================="

78
scripts/debug-panels-focus.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# 仅检查:战损、基地、地图战区 三块数据
# 用法: ./scripts/debug-panels-focus.sh
set -e
API_URL="${API_URL:-http://localhost:3001}"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
echo "=========================================="
echo "战损 / 基地 / 地图战区 — 数据检查"
echo "API: $API_URL | DB: $DB_PATH"
echo "=========================================="
echo ""
# ---------- API 连通 ----------
if ! curl -sf "$API_URL/api/health" >/dev/null 2>&1; then
echo "✗ API 无响应,请先运行: npm run api"
exit 1
fi
echo "✓ API 正常"
echo ""
SIT=$(curl -sf "$API_URL/api/situation" 2>/dev/null || echo "{}")
# ---------- 1. 战损 ----------
echo "[1] 战损 (combat_losses)"
if command -v jq &>/dev/null; then
us_k=$(echo "$SIT" | jq -r '.usForces.combatLosses.personnelCasualties.killed // "?"')
us_w=$(echo "$SIT" | jq -r '.usForces.combatLosses.personnelCasualties.wounded // "?"')
ir_k=$(echo "$SIT" | jq -r '.iranForces.combatLosses.personnelCasualties.killed // "?"')
ir_w=$(echo "$SIT" | jq -r '.iranForces.combatLosses.personnelCasualties.wounded // "?"')
echo " 美军 阵亡=$us_k 受伤=$us_w | 伊朗 阵亡=$ir_k 受伤=$ir_w"
echo " conflictStats: $(echo "$SIT" | jq -c '.conflictStats')"
else
echo " (安装 jq 可显示详细数字)"
fi
if [[ -f "$DB_PATH" ]] && command -v sqlite3 &>/dev/null; then
echo " 表 combat_losses:"
sqlite3 "$DB_PATH" "SELECT side, personnel_killed, personnel_wounded, bases_destroyed, bases_damaged FROM combat_losses" 2>/dev/null | while read -r line; do echo " $line"; done
fi
echo " 数据来源: seed 初始;爬虫从新闻提取 combat_losses_delta 后 db_merge 增量叠加。不更新→检查是否跑 gdelt、提取器是否输出、新闻是否含伤亡数字。"
echo ""
# ---------- 2. 基地 ----------
echo "[2] 基地 (key_location)"
if command -v jq &>/dev/null; then
us_loc=$(echo "$SIT" | jq -r '.usForces.keyLocations | length')
ir_loc=$(echo "$SIT" | jq -r '.iranForces.keyLocations | length')
us_attacked=$(echo "$SIT" | jq -r '[.usForces.keyLocations[] | select(.status == "attacked")] | length')
ir_attacked=$(echo "$SIT" | jq -r '[.iranForces.keyLocations[] | select(.status == "attacked")] | length')
echo " 美军 据点=$us_loc 遭袭=$us_attacked | 伊朗 据点=$ir_loc 遭袭=$ir_attacked"
fi
if [[ -f "$DB_PATH" ]] && command -v sqlite3 &>/dev/null; then
echo " 表 key_location 遭袭/有损伤的:"
sqlite3 "$DB_PATH" "SELECT side, name, status, damage_level FROM key_location WHERE status != 'operational' OR damage_level IS NOT NULL LIMIT 10" 2>/dev/null | while read -r line; do echo " $line"; done
fi
echo " 数据来源: seed 写入全部据点;爬虫只更新 status/damage_level需 name_keywords 与 name LIKE 匹配。不更新→检查新闻是否提基地遭袭、关键词与 seed name 是否一致。"
echo ""
# ---------- 3. 地图战区 ----------
echo "[3] 地图战区 (gdelt_events + conflict_stats)"
if command -v jq &>/dev/null; then
ev_cnt=$(echo "$SIT" | jq -r '.conflictEvents | length')
echo " conflictEvents 条数: $ev_cnt"
echo " conflictStats: $(echo "$SIT" | jq -c '.conflictStats')"
fi
if [[ -f "$DB_PATH" ]] && command -v sqlite3 &>/dev/null; then
n_ev=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM gdelt_events" 2>/dev/null || echo "0")
echo " 表 gdelt_events 行数: $n_ev"
sqlite3 "$DB_PATH" "SELECT total_events, high_impact_events, estimated_casualties, estimated_strike_count FROM conflict_stats WHERE id = 1" 2>/dev/null | while read -r line; do echo " conflict_stats: $line"; done
fi
echo " 数据来源: GDELT API 写入;或 GDELT_DISABLED=1 时由 situation_update 回填。无点→跑 gdelt 或开启 RSS 回填。"
echo ""
echo "=========================================="
echo "详细说明与排查顺序见: docs/DEBUG_战损_基地_地图.md"
echo "=========================================="

83
scripts/debug-panels.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# 看板板块数据快速检查:各表/API 与板块对应关系,便于逐项 debug
# 用法: ./scripts/debug-panels.sh
# 依赖: curl可选 jq、sqlite3 以输出更清晰
set -e
API_URL="${API_URL:-http://localhost:3001}"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
echo "=========================================="
echo "看板板块数据检查 (DEBUG_PANELS)"
echo "API: $API_URL | DB: $DB_PATH"
echo "=========================================="
echo ""
# ---------- 1. API 健康与态势摘要 ----------
echo "[1] API 与态势摘要"
if ! curl -sf "$API_URL/api/health" >/dev/null 2>&1; then
echo " ✗ API 无响应,请先运行: npm run api"
echo " 后续表检查将跳过(依赖 API 或直接读 DB"
else
echo " ✓ API 正常"
SIT=$(curl -sf "$API_URL/api/situation" 2>/dev/null || echo "{}")
if command -v jq &>/dev/null; then
echo " lastUpdated: $(echo "$SIT" | jq -r '.lastUpdated // "?"')"
echo " recentUpdates: $(echo "$SIT" | jq -r '.recentUpdates | length') 条 → 事件脉络"
echo " conflictEvents: $(echo "$SIT" | jq -r '.conflictEvents | length') 条 → 地图冲突点"
echo " us powerIndex: $(echo "$SIT" | jq -r '.usForces.powerIndex.overall') → 顶栏/战力图"
echo " iran powerIndex: $(echo "$SIT" | jq -r '.iranForces.powerIndex.overall')"
echo " us keyLocations: $(echo "$SIT" | jq -r '.usForces.keyLocations | length') 条 → 美国基地/地图"
echo " iran keyLocations: $(echo "$SIT" | jq -r '.iranForces.keyLocations | length') 条 → 伊朗基地/地图"
echo " us combatLosses: killed=$(echo "$SIT" | jq -r '.usForces.combatLosses.personnelCasualties.killed') wounded=$(echo "$SIT" | jq -r '.usForces.combatLosses.personnelCasualties.wounded')"
echo " wallStreet points: $(echo "$SIT" | jq -r '.usForces.wallStreetInvestmentTrend | length') → 华尔街图"
echo " retaliation: $(echo "$SIT" | jq -r '.iranForces.retaliationSentiment') (history: $(echo "$SIT" | jq -r '.iranForces.retaliationSentimentHistory | length') 条)"
else
echo " (安装 jq 可显示详细字段) 态势已拉取,长度: ${#SIT}"
fi
fi
echo ""
# ---------- 2. 各表行数(直接读 DB----------
echo "[2] 数据库表行数(与板块对应)"
if ! [[ -f "$DB_PATH" ]]; then
echo " ✗ 数据库文件不存在: $DB_PATH"
echo " 请先 seed: node server/seed.js 或 启动 API 后由 initDb 创建"
elif ! command -v sqlite3 &>/dev/null; then
echo " (未安装 sqlite3跳过表统计。可安装后重试)"
else
TABLES="force_summary power_index force_asset key_location combat_losses wall_street_trend retaliation_current retaliation_history situation_update situation gdelt_events conflict_stats news_content"
for t in $TABLES; do
n=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM $t" 2>/dev/null || echo "?")
case "$t" in
force_summary) desc="力量摘要(美/伊)" ;;
power_index) desc="战力指数 → 顶栏/战力图" ;;
force_asset) desc="资产列表 → 左右侧摘要" ;;
key_location) desc="据点 → 地图/美伊基地面板" ;;
combat_losses) desc="战损 → 战损面板" ;;
wall_street_trend) desc="华尔街趋势图" ;;
retaliation_current) desc="报复当前值" ;;
retaliation_history) desc="报复历史 → 仪表盘" ;;
situation_update) desc="事件脉络 → 时间线" ;;
situation) desc="updated_at → 顶栏时间" ;;
gdelt_events) desc="冲突点 → 地图图层" ;;
conflict_stats) desc="冲突统计 → 战损区" ;;
news_content) desc="资讯表 → /api/news" ;;
*) desc="" ;;
esac
printf " %-22s %6s %s\n" "$t" "$n" "$desc"
done
fi
echo ""
# ---------- 3. 板块健康简要判断 ----------
echo "[3] 板块数据来源与可能问题"
echo " • 仅 seed、爬虫不写: force_summary, power_index, force_asset"
echo " • 爬虫可更新: situation_update(事件脉络), key_location(基地状态), combat_losses(战损), retaliation_*, wall_street_trend, gdelt_events"
echo " • 事件脉络不更新 → 检查爬虫是否启动、是否调用 POST /api/crawler/notify"
echo " • 战损/基地不更新 → 检查是否跑 npm run gdelt、提取器是否输出、新闻是否含相关表述"
echo " • 地图无冲突点 → 检查 gdelt_events 是否有数据、GDELT 或 RSS 回填是否执行"
echo ""
echo "详细逐板块说明见: docs/DEBUG_PANELS.md"
echo "=========================================="

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# 在本地执行:读取 server/data.db 各表 PRAGMA table_info生成供生产执行的 align-production-schema.sh
# 用法:在项目根目录执行 ./scripts/gen-align-schema-from-local.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
OUT_PATH="$PROJECT_ROOT/scripts/align-production-schema.sh"
if [[ ! -f "$DB_PATH" ]]; then
echo "本地库不存在: $DB_PATH"
exit 1
fi
tables=$(sqlite3 "$DB_PATH" "SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('sqlite_sequence') ORDER BY name;")
cat > "$OUT_PATH" << 'HEAD'
#!/usr/bin/env bash
# 由 scripts/gen-align-schema-from-local.sh 根据本地 server/data.db 表结构生成,供生产执行。
# 用法:在生产目录执行 DB_PATH=server/data.db ./scripts/align-production-schema.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_PATH="${DB_PATH:-$PROJECT_ROOT/server/data.db}"
run() { sqlite3 "$DB_PATH" "$1" 2>/dev/null || true; }
echo "=== 对齐生产库表结构(与本地 data.db 一致):$DB_PATH ==="
HEAD
while IFS= read -r table; do
[[ -z "$table" ]] && continue
# 跳过 cid=0首列通常建表时已有
first=1
while IFS='|' read -r cid name type notnull dflt pk; do
[[ -z "$cid" || "$cid" -eq 0 ]] && continue
# 非常量默认值(如 datetime('now'))不写 DEFAULT避免生产 SQLite 报错
def="$type"
[[ "$notnull" == "1" ]] && def="$def NOT NULL"
if [[ -n "$dflt" && "$dflt" != *"("* ]]; then
# SQL 字面量:已知字符串默认值写死,避免 shell 转义问题
case "$dflt" in
'"operational"') def="${def} DEFAULT 'operational'" ;;
'"other"') def="${def} DEFAULT 'other'" ;;
'"medium"') def="${def} DEFAULT 'medium'" ;;
"''") def="${def} DEFAULT ''" ;;
*) dflt_sql="${dflt//\"/\'}"; def="$def DEFAULT $dflt_sql" ;;
esac
fi
if [[ "$def" == *\'* ]]; then
# def 含单引号:用 run '...'\''...'\'' 形式写入
safe_def=$(echo "$def" | sed "s/'/'\\\\''/g")
printf "run 'ALTER TABLE %s ADD COLUMN %s %s;'\n" "$table" "$name" "$safe_def" >> "$OUT_PATH"
else
printf 'run "ALTER TABLE %s ADD COLUMN %s %s;"\n' "$table" "$name" "$def" >> "$OUT_PATH"
fi
if [[ "$first" -eq 1 ]]; then
echo "# $table(本地列)" >> "$OUT_PATH"
first=0
fi
if [[ "$table" == "combat_losses" && "$name" == "carriers" ]]; then
echo 'run "UPDATE combat_losses SET carriers = COALESCE(tanks, 0) WHERE carriers = 0;"' >> "$OUT_PATH"
fi
done < <(sqlite3 -separator '|' "$DB_PATH" "PRAGMA table_info($table);")
if [[ "$first" -eq 0 ]]; then
echo "echo \" $table done\"" >> "$OUT_PATH"
fi
done <<< "$tables"
echo "" >> "$OUT_PATH"
echo "echo \"=== 完成。核对示例: ===\"" >> "$OUT_PATH"
echo "echo \" sqlite3 \$DB_PATH \\\"PRAGMA table_info(key_location);\\\"\"" >> "$OUT_PATH"
echo "echo \" sqlite3 \$DB_PATH \\\"PRAGMA table_info(combat_losses);\\\"\"" >> "$OUT_PATH"
chmod +x "$OUT_PATH"
echo "已生成: $OUT_PATH"
echo "请将该文件推到生产后执行DB_PATH=server/data.db ./scripts/align-production-schema.sh"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# 生产环境一键:构建爬虫镜像 + 以「仅爬虫 Docker、API 在宿主机」方式启动,并输出数据对齐说明。
# 使用前API 已用 PM2 等方式在宿主机 3001 端口运行,且 server/data.db 已存在(或先执行 npm run api:seed
set -e
cd "$(dirname "$0")/.."
PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"
REGISTRY="${REGISTRY:-}"
echo "==> Building crawler image..."
docker build -t usa-dashboard-crawler:latest \
${REGISTRY:+--build-arg REGISTRY="$REGISTRY"} \
-f Dockerfile.crawler .
echo ""
./scripts/run-crawler-docker-standalone.sh
echo ""
echo "==> Data alignment (生产数据对齐)"
echo " API (host) DB_PATH = $PROJECT_ROOT/server/data.db (或 env DB_PATH)"
echo " Crawler /data/data.db = 挂载自上述同一文件"
echo " 二者必须指向同一 SQLite 文件,前端/API 与爬虫才能数据一致。"

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# 生产:仅用 Docker 跑爬虫API 在宿主机(如 PM2时使用。
# 保证爬虫与 API 使用同一 SQLite 文件(数据对齐)。
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
DB_FILE="${DB_FILE:-$PROJECT_ROOT/server/data.db}"
API_BASE="${API_BASE:-http://host.docker.internal:3001}"
CRAWLER_IMAGE="${CRAWLER_IMAGE:-usa-dashboard-crawler:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-usa-crawler}"
# 可选:从 .env 加载 DASHSCOPE_API_KEY 等
if [ -f "$PROJECT_ROOT/.env" ]; then
set -a
# shellcheck source=../.env
. "$PROJECT_ROOT/.env"
set +a
fi
# 宿主机 DB 必须存在API 已初始化或先 seed
if [ ! -f "$DB_FILE" ]; then
echo "ERROR: DB file not found: $DB_FILE"
echo " Create it first: DB_PATH=$DB_FILE node server/seed.js"
exit 1
fi
# Linux 下 Docker 默认无 host.docker.internal需显式添加
DOCKER_EXTRA=()
if [ "$(uname -s)" = "Linux" ]; then
DOCKER_EXTRA+=(--add-host=host.docker.internal:host-gateway)
fi
# 若已存在同名容器则先删
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
echo "==> Starting crawler container (standalone)"
echo " DB: $DB_FILE -> /data/data.db"
echo " API_BASE: $API_BASE"
echo " Image: $CRAWLER_IMAGE"
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 8000:8000 \
-v "$DB_FILE:/data/data.db" \
-e DB_PATH=/data/data.db \
-e API_BASE="$API_BASE" \
-e GDELT_DISABLED=1 \
-e RSS_INTERVAL_SEC=60 \
${DASHSCOPE_API_KEY:+ -e DASHSCOPE_API_KEY="$DASHSCOPE_API_KEY"} \
"${DOCKER_EXTRA[@]}" \
"$CRAWLER_IMAGE"
echo " Container: $CONTAINER_NAME"
echo " Logs: docker logs -f $CONTAINER_NAME"
echo " Status: curl -s http://localhost:8000/crawler/status | jq ."

17
scripts/run-crawler-range.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# 按时间范围跑一轮爬虫RSS仅保留指定起始时间之后的条目
# 用法:
# ./scripts/run-crawler-range.sh # 默认从 2026-02-28 0:00 到现在
# ./scripts/run-crawler-range.sh 2026-02-25T00:00:00
#
# GDELT 时间范围需在启动 gdelt 服务时设置,例如:
# GDELT_TIMESPAN=3d npm run gdelt
set -e
START="${1:-2026-02-28T00:00:00}"
cd "$(dirname "$0")/.."
echo "RSS 抓取时间范围: 仅保留 ${START} 之后"
echo "运行: cd crawler && CRAWL_START_DATE=${START} python run_once.py"
echo ""
export CRAWL_START_DATE="$START"
(cd crawler && python3 run_once.py)

126
scripts/test.sh Normal file
View File

@@ -0,0 +1,126 @@
sqlite3 server/data.db "
UPDATE combat_losses
SET civilian_killed = 380, civilian_wounded = 1520
WHERE side = 'us';
UPDATE combat_losses
SET civilian_killed = 4120, civilian_wounded = 12030
WHERE side = 'iran';
"
cd /root/usa
sqlite3 server/data.db "
UPDATE combat_losses
SET bases_destroyed = 15,
bases_damaged = 57,
personnel_killed = 327,
personnel_wounded = 984,
civilian_killed = 380,
civilian_wounded = 1520,
aircraft = 24,
warships = 1,
armor = 18,
vehicles = 42,
drones = 28,
missiles = 756,
helicopters = 8,
submarines = 2,
tanks = 0,
carriers = 0,
civilian_ships = 100,
airport_port = 5
WHERE side = 'us';
UPDATE combat_losses
SET bases_destroyed = 2100,
bases_damaged = 8400,
personnel_killed = 2847,
personnel_wounded = 5620,
civilian_killed = 4120,
civilian_wounded = 12030,
aircraft = 240,
warships = 120,
armor = 18,
vehicles = 420,
drones = 28,
missiles = 4560,
helicopters = 20,
submarines = 2,
tanks = 50,
carriers = 0,
civilian_ships = 50,
airport_port = 42
WHERE side = 'iran';
"
sqlite3 server/data.db "
UPDATE combat_losses
SET bases_destroyed = 15,
bases_damaged = 57,
personnel_killed = 327,
personnel_wounded = 984,
aircraft = 4,
warships = 0,
armor = 3,
vehicles = 76,
civilian_killed = 380,
civilian_wounded = 1520
WHERE side = 'us';
UPDATE combat_losses
SET bases_destroyed = 2100,
bases_damaged = 8400,
personnel_killed = 2847,
personnel_wounded = 5620,
aircraft = 70,
warships = 120,
armor = 18,
vehicles = 420,
civilian_killed = 4120,
civilian_wounded = 12030
WHERE side = 'iran';
"
cd /root/usa
sqlite3 server/data.db "
UPDATE combat_losses
SET bases_destroyed = 15,
bases_damaged = 57,
personnel_killed = 327,
personnel_wounded = 984,
civilian_killed = 380,
civilian_wounded = 1520,
aircraft = 4,
warships = 1,
armor = 18,
vehicles = 42,
drones = 68,
missiles = 1756,
helicopters = 8,
submarines = 0,
tanks = 0,
carriers = 0,
civilian_ships = 172,
airport_port = 7
WHERE side = 'us';
UPDATE combat_losses
SET bases_destroyed = 2100,
bases_damaged = 8400,
personnel_killed = 2847,
personnel_wounded = 5620,
civilian_killed = 4120,
civilian_wounded = 12030,
aircraft = 106,
warships = 107,
armor = 72,
vehicles = 506,
drones = 1428,
missiles = 6620,
helicopters = 20,
submarines = 4,
tanks = 50,
carriers = 1,
civilian_ships = 42,
airport_port = 31
WHERE side = 'iran';
"

81
scripts/verify-panels.cjs Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* 代码层执行看板验证:直接调用 getSituation() 与 DB输出战损 / 基地 / 地图战区 结果。
* 用法(项目根目录): node scripts/verify-panels.cjs
*/
const path = require('path')
const projectRoot = path.resolve(__dirname, '..')
process.chdir(projectRoot)
const db = require('../server/db')
const { getSituation } = require('../server/situationData')
function run() {
const s = getSituation()
console.log('========================================')
console.log('看板数据验证(与 API getSituation 一致)')
console.log('========================================\n')
console.log('lastUpdated:', s.lastUpdated)
console.log('')
// ---------- 1. 战损 ----------
console.log('--- [1] 战损 combat_losses ---')
const us = s.usForces.combatLosses
const ir = s.iranForces.combatLosses
console.log('美军 阵亡:', us.personnelCasualties.killed, '受伤:', us.personnelCasualties.wounded)
console.log('美军 基地毁/损:', us.bases.destroyed, '/', us.bases.damaged)
console.log('美军 战机/舰艇/装甲/车辆:', us.aircraft, us.warships, us.armor, us.vehicles)
console.log('伊朗 阵亡:', ir.personnelCasualties.killed, '受伤:', ir.personnelCasualties.wounded)
console.log('伊朗 基地毁/损:', ir.bases.destroyed, '/', ir.bases.damaged)
console.log('平民合计 killed/wounded:', s.civilianCasualtiesTotal.killed, s.civilianCasualtiesTotal.wounded)
console.log('conflictStats:', JSON.stringify(s.conflictStats))
console.log('')
// ---------- 2. 基地(与看板口径一致:美军仅 type===Base伊朗为 Base/Port/Nuclear/Missile----------
console.log('--- [2] 基地 key_location ---')
const usLoc = s.usForces.keyLocations || []
const irLoc = s.iranForces.keyLocations || []
const usBases = usLoc.filter((l) => l.type === 'Base')
const irBases = irLoc.filter((l) => ['Base', 'Port', 'Nuclear', 'Missile'].includes(l.type))
const usAttacked = usBases.filter((l) => l.status === 'attacked')
const irAttacked = irBases.filter((l) => l.status === 'attacked')
console.log('美军 总基地数(仅Base):', usBases.length, '| 遭袭:', usAttacked.length, '(与看板「美军基地态势」一致)')
console.log('伊朗 总基地数(Base/Port/Nuclear/Missile):', irBases.length, '| 遭袭:', irAttacked.length, '(与看板「伊朗基地态势」一致)')
if (usAttacked.length > 0) {
console.log('美军遭袭示例:', usAttacked.slice(0, 3).map((l) => `${l.name}(${l.status},damage=${l.damage_level})`).join(', '))
}
if (irAttacked.length > 0) {
console.log('伊朗遭袭示例:', irAttacked.slice(0, 3).map((l) => `${l.name}(${l.status},damage=${l.damage_level})`).join(', '))
}
console.log('')
// ---------- 3. 地图战区 ----------
console.log('--- [3] 地图战区 gdelt_events + conflict_stats ---')
const events = s.conflictEvents || []
console.log('conflictEvents 条数:', events.length)
console.log('conflictStats:', JSON.stringify(s.conflictStats))
if (events.length > 0) {
console.log('最近 3 条:', events.slice(0, 3).map((e) => `${e.event_time} ${(e.title || '').slice(0, 40)} impact=${e.impact_score}`))
}
console.log('')
// ---------- 附加:事件脉络 ----------
const updates = s.recentUpdates || []
console.log('--- [附] 事件脉络 situation_update ---')
console.log('recentUpdates 条数:', updates.length)
if (updates.length > 0) {
console.log('最新 1 条:', updates[0].timestamp, (updates[0].summary || '').slice(0, 50))
}
console.log('========================================')
}
db
.initDb()
.then(() => run())
.catch((err) => {
console.error('验证失败:', err.message)
process.exit(1)
})

View File

@@ -145,8 +145,9 @@ npm run api # 启动 server/index.js默认端口 3001
- **路径**`/ws`(与 HTTP 同端口)。
- **连接时**:服务端发送一条 `{ type: 'situation', data, stats }`
- **定时广播**`setInterval(broadcastSituation, 3000)` 每 3 秒向所有已连接客户端推送最新 `getSituation()` + `getStats()`
- **爬虫通知**POST `/api/crawler/notify` 会立即执行一次 `broadcastSituation()`,不必等 3 秒。
- **定时广播**`BROADCAST_INTERVAL_MS`(默认 30 秒)轮询;**仅当数据有变化**(以 `situation.updated_at` + `situation_update` 条数为版本)时才执行 `getSituation()` + `getStats()` 并推送,避免无变更时重复查库和推送、降低负载
- **即时广播**:以下情况会立即推送一次(不等待定时间隔):爬虫 POST `/api/crawler/notify`、修订页保存PUT/PATCH/POST/DELETE `/api/edit/*`)。
- **环境变量**`BROADCAST_INTERVAL_MS=0` 可关闭定时轮询,仅依赖即时广播;设为 `3000` 可恢复为每 3 秒检查一次(仍仅在数据变化时推送)。
---

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('status')) exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
if (!names.includes('damage_level')) exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
if (!names.includes('attacked_at')) exec('ALTER TABLE key_location ADD COLUMN attacked_at TEXT')
} catch (_) {}
try {
const lossCols = prepare('PRAGMA table_info(combat_losses)').all()
@@ -226,6 +227,11 @@ function runMigrations(db) {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
const fbCols = prepare('PRAGMA table_info(feedback)').all()
const fbNames = (fbCols || []).map((c) => c.name)
if (!fbNames.includes('created_at')) {
exec('ALTER TABLE feedback ADD COLUMN created_at TEXT NOT NULL DEFAULT (datetime(\'now\'))')
}
} catch (_) {}
try {
exec(`
@@ -236,6 +242,127 @@ function runMigrations(db) {
INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0);
`)
} catch (_) {}
try {
exec(`
CREATE TABLE IF NOT EXISTS like_count (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO like_count (id, total) VALUES (1, 0);
`)
} catch (_) {}
try {
exec(`
CREATE TABLE IF NOT EXISTS display_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
viewers INTEGER NULL,
cumulative INTEGER NULL,
share_count INTEGER NULL,
like_count INTEGER NULL,
feedback_count INTEGER NULL
);
INSERT OR IGNORE INTO display_stats (id) VALUES (1);
`)
} catch (_) {}
try {
const dsCols = prepare('PRAGMA table_info(display_stats)').all()
const dsNames = dsCols.map((c) => c.name)
if (!dsNames.includes('override_enabled')) {
exec('ALTER TABLE display_stats ADD COLUMN override_enabled INTEGER NOT NULL DEFAULT 0')
}
} catch (_) {}
try {
exec(`
CREATE TABLE IF NOT EXISTS map_strike_source (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lng REAL NOT NULL,
lat REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS map_strike_line (
source_id TEXT NOT NULL,
target_lng REAL NOT NULL,
target_lat REAL NOT NULL,
target_name TEXT,
struck_at TEXT,
FOREIGN KEY (source_id) REFERENCES map_strike_source(id)
);
CREATE INDEX IF NOT EXISTS idx_map_strike_line_source ON map_strike_line(source_id);
`)
} catch (_) {}
try {
const lineCols = prepare('PRAGMA table_info(map_strike_line)').all()
if (!lineCols.some((c) => c.name === 'struck_at')) {
exec('ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT')
}
} catch (_) {}
try {
exec(`
CREATE TABLE IF NOT EXISTS animation_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
strike_cutoff_days INTEGER NOT NULL DEFAULT 5,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO animation_config (id, strike_cutoff_days) VALUES (1, 5);
`)
} catch (_) {}
try {
exec(`
CREATE TABLE IF NOT EXISTS war_map_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
config TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS crawler_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
rss_interval_sec INTEGER NOT NULL DEFAULT 60
);
INSERT OR IGNORE INTO crawler_config (id, rss_interval_sec) VALUES (1, 60);
`)
} catch (_) {}
// 生产环境可能未跑 seed 或使用旧 seed补齐 war_map_config 与以色列→黎巴嫩打击线,保证攻击动画显示
try {
let row = db.prepare('SELECT 1 FROM war_map_config WHERE id = 1').get()
if (!row) {
const warMapConfig = {
pincerAxes: [
{ start: [43.6, 37.2], end: [46.27, 38.08], name: 'North Pincer (Tabriz)' },
{ start: [45.0, 35.4], end: [46.99, 35.31], name: 'Central Pincer (Sanandaj)' },
{ start: [45.6, 35.2], end: [47.07, 34.31], name: 'South Pincer (Kermanshah)' },
],
israelLebanonAxis: { start: [35.25, 32.95], end: [35.55, 33.45], name: 'Israel → Lebanon' },
defenseLinePath: [[46.27, 38.08], [46.99, 35.31], [47.07, 34.31]],
}
db.prepare(
'INSERT INTO war_map_config (id, config, updated_at) VALUES (1, ?, datetime(\'now\'))'
).run(JSON.stringify(warMapConfig))
}
// 确保 map_strike_source 有 israel否则打击线无法关联
db.prepare(
'INSERT OR IGNORE INTO map_strike_source (id, name, lng, lat) VALUES (\'israel\', \'以色列\', 34.78, 32.08)'
).run()
const israelLebanonTargets = [
[35.5, 33.86, '贝鲁特南郊指挥所', '2026-02-28T14:00:00.000Z'],
[35.32, 33.34, '利塔尼弹药库', '2026-02-28T14:10:00.000Z'],
[36.2, 34.01, '巴勒贝克后勤枢纽', '2026-02-28T14:20:00.000Z'],
[35.19, 33.27, '提尔海岸阵地', '2026-02-28T14:30:00.000Z'],
[36.38, 34.39, '赫尔梅勒无人机阵地', '2026-02-28T14:40:00.000Z'],
]
const insertStrikeLine = db.prepare(
'INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)'
)
const hasStrikeLine = db.prepare(
'SELECT 1 FROM map_strike_line WHERE source_id = ? AND target_lng = ? AND target_lat = ?'
)
for (const [lng, lat, name, struckAt] of israelLebanonTargets) {
if (!hasStrikeLine.get('israel', lng, lat)) {
insertStrikeLine.run('israel', lng, lat, name, struckAt)
}
}
} catch (_) {}
}
async function initDb() {

View File

@@ -32,15 +32,18 @@ app.post('/api/crawler/notify', (req, res) => {
res.json({ ok: true })
})
// 生产环境:提供前端静态文件
// 生产环境:提供前端静态文件(含修订页 /edit依赖 SPA fallback
const distPath = path.join(__dirname, '..', 'dist')
if (fs.existsSync(distPath)) {
app.use(express.static(distPath))
// 非 API/WS 的请求一律返回 index.html由前端路由处理 /、/edit、/db 等
app.get('*', (req, res, next) => {
if (!req.path.startsWith('/api') && req.path !== '/ws') {
res.sendFile(path.join(distPath, 'index.html'))
} else next()
})
} else {
console.warn('[server] dist 目录不存在,前端页面(含 /edit 修订页)不可用。请在项目根目录执行 npm run build 后再启动。')
}
const server = http.createServer(app)
@@ -52,26 +55,56 @@ wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'situation', data: getSituation(), stats: getStats() }))
})
function broadcastSituation() {
// 版本含 situation、situation_update、key_location 基地态势,任一变化都会触发广播,保证爬虫 AI 更新基地后前端实时刷新
function getBroadcastVersion() {
try {
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
const row = db.prepare('SELECT COUNT(*) as c FROM situation_update').get()
const usAtt = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='us' AND status='attacked'").get()
const irAtt = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='iran' AND status='attacked'").get()
return `${meta?.updated_at || ''}_${row?.c ?? 0}_b${usAtt?.c ?? 0}_${irAtt?.c ?? 0}`
} catch (_) {
return ''
}
}
let lastBroadcastVersion = null
function broadcastSituation(force = false) {
if (!force && wss.clients.size === 0) return
const version = getBroadcastVersion()
if (!force && version === lastBroadcastVersion) return
try {
const data = JSON.stringify({ type: 'situation', data: getSituation(), stats: getStats() })
wss.clients.forEach((c) => {
if (c.readyState === 1) c.send(data)
})
lastBroadcastVersion = version
} catch (_) {}
}
app.set('broadcastSituation', broadcastSituation)
setInterval(broadcastSituation, 3000)
// 供爬虫调用:先从磁盘重载 DB纳入爬虫写入再更新 updated_at 并立即广播
app.set('broadcastSituation', () => broadcastSituation(true))
if (typeof routes.setBroadcastSituation === 'function') {
routes.setBroadcastSituation(() => broadcastSituation(true))
}
const BROADCAST_INTERVAL_MS = Math.max(0, parseInt(process.env.BROADCAST_INTERVAL_MS, 10) || 30000)
if (BROADCAST_INTERVAL_MS > 0) {
setInterval(() => broadcastSituation(false), BROADCAST_INTERVAL_MS)
}
// 供爬虫调用:先从磁盘重载 DB纳入爬虫写入再更新 situation.updated_at 并立即广播;前端据此实时更新基地态势等
function notifyCrawlerUpdate() {
try {
const db = require('./db')
db.reloadFromFile()
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
broadcastSituation()
broadcastSituation(true) // 强制推送,确保基地数/被袭数等随 AI 清洗实时更新
const n = db.prepare('SELECT COUNT(*) as c FROM situation_update').get().c
console.log('[crawler/notify] DB 已重载并广播situation_update 条数:', n)
const usB = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='us'").get().c
const irB = db.prepare("SELECT COUNT(*) as c FROM key_location WHERE side='iran'").get().c
console.log('[crawler/notify] DB 已重载并广播situation_update:', n, '基地 us/iran:', usB, irB)
} catch (e) {
console.error('[crawler/notify]', e?.message || e)
}

View File

@@ -5,6 +5,13 @@ const db = require('./db')
const router = express.Router()
/** 由 index.js 注入,确保修订后能触发 WebSocket 广播(不依赖 req.app */
let _broadcastSituation = null
function setBroadcastSituation(fn) {
_broadcastSituation = typeof fn === 'function' ? fn : null
}
router.setBroadcastSituation = setBroadcastSituation
// 简单鉴权:通过环境变量配置的 API_ADMIN_KEY 保护敏感接口(不返回真实密钥)
const ADMIN_API_KEY = process.env.API_ADMIN_KEY || ''
@@ -105,12 +112,12 @@ function getClientIp(req) {
return req.ip || req.socket?.remoteAddress || 'unknown'
}
// 优先用前端传来的 viewer-id 去重(每设备一个),否则用 IP这样多设备同 WiFi 也能正确统计「在看」
function getVisitKey(req) {
const vid = req.headers['x-viewer-id']
const ip = getClientIp(req)
const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
if (typeof vid === 'string' && vid.trim().length > 0 && (process.env.NODE_ENV === 'development' || isLocal)) {
return 'vid:' + vid.trim()
if (typeof vid === 'string' && vid.trim().length > 0) {
return 'vid:' + vid.trim().slice(0, 64)
}
return ip
}
@@ -129,7 +136,7 @@ router.post('/visit', (req, res) => {
res.json(getStats())
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0 })
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 0 })
}
})
@@ -163,12 +170,59 @@ router.post('/share', (req, res) => {
}
})
router.post('/like', (req, res) => {
try {
db.prepare(
'INSERT INTO like_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1'
).run()
res.json(getStats())
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 0 })
}
})
router.get('/stats', (req, res) => {
try {
res.json(getStats())
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0 })
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 0 })
}
})
// 战区地图配置:钳形轴线、以黎轴线、防御线路径,供前端 useWarMapData 拉取;新增/修改数据落库
router.get('/war-map-config', (req, res) => {
try {
const row = db.prepare('SELECT config FROM war_map_config WHERE id = 1').get()
if (!row || !row.config) {
return res.json({})
}
const data = JSON.parse(row.config)
res.json(data)
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
router.put('/war-map-config', requireAdmin, (req, res) => {
try {
const body = req.body || {}
const pincerAxes = Array.isArray(body.pincerAxes) ? body.pincerAxes : null
const israelLebanonAxis = body.israelLebanonAxis && typeof body.israelLebanonAxis === 'object' ? body.israelLebanonAxis : null
const defenseLinePath = Array.isArray(body.defenseLinePath) ? body.defenseLinePath : null
if (!pincerAxes?.length || !israelLebanonAxis || !defenseLinePath?.length) {
return res.status(400).json({ error: 'pincerAxes (array), israelLebanonAxis (object), defenseLinePath (array) required' })
}
const config = JSON.stringify({ pincerAxes, israelLebanonAxis, defenseLinePath })
db.prepare(
'INSERT INTO war_map_config (id, config, updated_at) VALUES (1, ?, datetime(\'now\')) ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = datetime(\'now\')'
).run(config)
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
@@ -190,7 +244,7 @@ router.get('/events', (req, res) => {
// ---------- 手动修正看板数据(编辑页用) ----------
function broadcastAfterEdit(req) {
try {
const broadcast = req.app?.get?.('broadcastSituation')
const broadcast = _broadcastSituation || req.app?.get?.('broadcastSituation')
if (typeof broadcast === 'function') broadcast()
} catch (_) {}
}
@@ -200,16 +254,52 @@ router.get('/edit/raw', (req, res) => {
try {
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran')
const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us')
const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran')
const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('us')
const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('iran')
const updates = db.prepare('SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 80').all()
const summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
let displayStats = null
let animationConfig = null
try {
displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
} catch (_) {}
try {
animationConfig = db.prepare('SELECT strike_cutoff_days FROM animation_config WHERE id = 1').get()
} catch (_) {}
let crawlerConfig = null
try {
crawlerConfig = db.prepare('SELECT rss_interval_sec FROM crawler_config WHERE id = 1').get()
} catch (_) {}
const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
const liveViewers = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get()?.n ?? 0
const realFeedback = db.prepare('SELECT COUNT(*) as n FROM feedback').get()?.n ?? 0
let realLikeCount = 0
try {
realLikeCount = db.prepare('SELECT total FROM like_count WHERE id = 1').get()?.total ?? 0
} catch (_) {}
res.json({
combatLosses: { us: lossesUs || null, iran: lossesIr || null },
keyLocations: { us: locUs || [], iran: locIr || [] },
situationUpdates: updates || [],
forceSummary: { us: summaryUs || null, iran: summaryIr || null },
displayStats: {
overrideEnabled: displayStats?.override_enabled === 1,
viewers: displayStats?.viewers ?? liveViewers,
cumulative: displayStats?.cumulative ?? realCumulative,
shareCount: displayStats?.share_count ?? realShare,
likeCount: displayStats?.like_count ?? realLikeCount,
feedbackCount: displayStats?.feedback_count ?? realFeedback,
},
animationConfig: {
strikeCutoffDays: animationConfig?.strike_cutoff_days ?? 5,
},
crawlerConfig: {
rssIntervalSec: crawlerConfig?.rss_interval_sec ?? 60,
},
})
} catch (err) {
console.error(err)
@@ -217,6 +307,33 @@ router.get('/edit/raw', (req, res) => {
}
})
/** GET 留言列表(修订页查看) */
router.get('/edit/feedback', (req, res) => {
try {
const list = db.prepare('SELECT id, content, ip, created_at FROM feedback ORDER BY id DESC LIMIT 500').all()
res.json({ list })
} catch (err) {
console.error('[edit/feedback]', err?.message || err)
// 表不存在或列缺失时返回空列表,避免修订页报错
res.json({ list: [] })
}
})
/** PUT 爬虫配置RSS 抓取间隔等,爬虫从同一 DB 读取) */
router.put('/edit/crawler-config', (req, res) => {
try {
const n = req.body?.rssIntervalSec
if (n === undefined) return res.status(400).json({ error: 'rssIntervalSec required' })
const val = parseInt(String(n), 10)
if (!Number.isFinite(val) || val < 30 || val > 86400) return res.status(400).json({ error: 'rssIntervalSec must be 3086400' })
db.prepare('INSERT OR REPLACE INTO crawler_config (id, rss_interval_sec) VALUES (1, ?)').run(val)
res.json({ ok: true, rssIntervalSec: val })
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message })
}
})
/** PUT 更新战损(美/伊) */
router.put('/edit/combat-losses', (req, res) => {
try {
@@ -256,7 +373,7 @@ router.patch('/edit/key-location/:id', (req, res) => {
if (!Number.isFinite(id)) return res.status(400).json({ error: 'invalid id' })
const row = db.prepare('SELECT id FROM key_location WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'key_location not found' })
const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level']
const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level', 'attacked_at']
const updates = []
const values = []
for (const k of allowed) {
@@ -343,4 +460,74 @@ router.put('/edit/force-summary', (req, res) => {
}
})
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除该字段clearOverride=true 或全部为 null 时关闭覆盖、恢复实时统计 */
router.put('/edit/display-stats', (req, res) => {
try {
db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run()
const body = req.body || {}
const clearOverride = body.clearOverride === true || body.clearOverride === 'true'
const viewers = body.viewers
const cumulative = body.cumulative
const shareCount = body.shareCount
const likeCount = body.likeCount
const feedbackCount = body.feedbackCount
const allNull = viewers === null && cumulative === null && shareCount === null && likeCount === null && feedbackCount === null
if (clearOverride || allNull) {
db.prepare(
'UPDATE display_stats SET override_enabled = 0, viewers = NULL, cumulative = NULL, share_count = NULL, like_count = NULL, feedback_count = NULL WHERE id = 1'
).run()
broadcastAfterEdit(req)
return res.json({ ok: true })
}
const updates = []
const values = []
const setField = (key, bodyKey) => {
const v = body[bodyKey ?? key]
if (v === undefined) return
if (v === null) {
updates.push(`${key} = ?`)
values.push(null)
return
}
const n = Math.max(0, parseInt(v, 10))
if (!Number.isFinite(n)) throw new Error(`${bodyKey ?? key} must be number`)
updates.push(`${key} = ?`)
values.push(n)
}
setField('viewers')
setField('cumulative')
setField('share_count', 'shareCount')
setField('like_count', 'likeCount')
setField('feedback_count', 'feedbackCount')
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
updates.push('override_enabled = ?')
values.push(1)
values.push(1)
db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values)
broadcastAfterEdit(req)
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(400).json({ error: err.message })
}
})
router.put('/edit/animation-config', (req, res) => {
try {
const body = req.body || {}
const v = body.strikeCutoffDays
const n = Math.max(1, parseInt(v, 10) || 0)
if (!Number.isFinite(n)) return res.status(400).json({ error: 'strikeCutoffDays must be number' })
db.prepare('INSERT OR IGNORE INTO animation_config (id, strike_cutoff_days) VALUES (1, 5)').run()
db.prepare('UPDATE animation_config SET strike_cutoff_days = ?, updated_at = datetime(\'now\') WHERE id = 1').run(n)
broadcastAfterEdit(req)
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(400).json({ error: err.message })
}
})
module.exports = router

View File

@@ -3,84 +3,84 @@ const db = require('./db')
// 与 src/data/mapLocations.ts 同步62 基地27 被袭 (严重6 中度12 轻度9)
function getUsLocations() {
const naval = [
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null },
{ name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null },
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null },
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null },
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null },
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null },
{ name: '林肯号航母 (CVN-72)', lat: 24.1568, lng: 58.4215, type: 'Aircraft Carrier', region: '北阿拉伯海', status: 'operational', damage_level: null, attacked_at: null },
{ name: '福特号航母 (CVN-78)', lat: 35.7397, lng: 24.1002, type: 'Aircraft Carrier', region: '东地中海', status: 'operational', damage_level: null, attacked_at: null },
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational', damage_level: null, attacked_at: null },
]
const attacked = [
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3 },
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3 },
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3 },
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3 },
{ name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 },
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2 },
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2 },
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 },
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2 },
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2 },
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2 },
{ name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2 },
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2 },
{ name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1 },
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1 },
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1 },
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1 },
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1 },
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 },
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1 },
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1 },
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1 },
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:00:00.000Z' },
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:15:00.000Z' },
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:30:00.000Z' },
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T06:45:00.000Z' },
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:00:00.000Z' },
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, type: 'Base', region: '沙特', status: 'attacked', damage_level: 3, attacked_at: '2026-02-28T07:15:00.000Z' },
{ name: '塔吉军营', lat: 33.556, lng: 44.256, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:30:00.000Z' },
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, type: 'Base', region: '伊拉克', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T07:45:00.000Z' },
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:00:00.000Z' },
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:15:00.000Z' },
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:30:00.000Z' },
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, type: 'Base', region: '叙利亚', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T08:45:00.000Z' },
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:00:00.000Z' },
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, type: 'Base', region: '科威特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:15:00.000Z' },
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, type: 'Base', region: '巴林', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:30:00.000Z' },
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, type: 'Base', region: '阿联酋', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T09:45:00.000Z' },
{ name: '埃斯康村', lat: 24.774, lng: 46.738, type: 'Base', region: '沙特', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:00:00.000Z' },
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, type: 'Base', region: '以色列', status: 'attacked', damage_level: 2, attacked_at: '2026-02-28T10:15:00.000Z' },
{ name: '布林军营', lat: 29.603, lng: 47.456, type: 'Base', region: '科威特', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:30:00.000Z' },
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, type: 'Base', region: '卡塔尔', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T10:45:00.000Z' },
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, type: 'Base', region: '以色列', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:00:00.000Z' },
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, type: 'Base', region: '约旦', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:15:00.000Z' },
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, type: 'Base', region: '土耳其', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:30:00.000Z' },
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T11:45:00.000Z' },
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, type: 'Base', region: '阿曼', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:00:00.000Z' },
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, type: 'Base', region: '埃及', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:15:00.000Z' },
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, type: 'Base', region: '吉布提', status: 'attacked', damage_level: 1, attacked_at: '2026-02-28T12:30:00.000Z' },
]
const newBases = [
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null },
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null },
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null },
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null },
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null },
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null },
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null },
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null },
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null },
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null },
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null },
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null },
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null },
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null },
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null },
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null },
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null },
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null },
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null },
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null },
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林', status: 'operational', damage_level: null, attacked_at: null },
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null },
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特', status: 'operational', damage_level: null, attacked_at: null },
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null },
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null },
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null },
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null },
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔', status: 'operational', damage_level: null, attacked_at: null },
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特', status: 'operational', damage_level: null, attacked_at: null },
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚', status: 'operational', damage_level: null, attacked_at: null },
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦', status: 'operational', damage_level: null, attacked_at: null },
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其', status: 'operational', damage_level: null, attacked_at: null },
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列', status: 'operational', damage_level: null, attacked_at: null },
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼', status: 'operational', damage_level: null, attacked_at: null },
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及', status: 'operational', damage_level: null, attacked_at: null },
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提', status: 'operational', damage_level: null, attacked_at: null },
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋', status: 'operational', damage_level: null, attacked_at: null },
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克', status: 'operational', damage_level: null, attacked_at: null },
]
return [...naval, ...attacked, ...newBases]
}
@@ -126,27 +126,92 @@ function seed() {
;[...usAssets, ...iranAssets].forEach((row) => insertAsset.run(...row))
const insertLoc = db.prepare(`
INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level, attacked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
db.exec('DELETE FROM key_location')
for (const loc of getUsLocations()) {
insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level)
insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level, loc.attacked_at ?? null)
}
// 盟军先打击伊朗2月28日 02:0004:00回放时先出现
const iranLocs = [
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3],
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3],
['iran', '布什尔核电站', 28.9681, 50.838, 'Nuclear', '伊朗', 'attacked', 2],
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2],
['iran', '纳坦兹铀浓缩', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2],
['iran', '米纳布岸防', 27.13, 57.08, 'Base', '伊朗', 'damaged', 2],
['iran', '卡拉季无人机厂', 35.808, 51.002, 'Base', '伊朗', 'attacked', 2],
['iran', '克尔曼沙赫导弹阵地', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2],
['iran', '大不里士空军基地', 38.08, 46.29, 'Base', '伊朗', 'damaged', 1],
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Base', '伊朗', 'operational', null],
['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', 'operational', null, null],
['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Naval', '伊朗', 'attacked', 3, '2026-02-28T02:15:00.000Z'],
['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3, '2026-02-28T02:00:00.000Z'],
['iran', '哈梅内伊官邸', 35.69, 51.42, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:30:00.000Z'],
['iran', '总统府/情报部', 35.72, 51.41, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:32:00.000Z'],
['iran', '梅赫拉巴德机场', 35.69, 51.15, 'Leadership', '伊朗', 'attacked', 2, '2026-02-28T02:34:00.000Z'],
['iran', '库姆', 34.64, 50.88, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:00:00.000Z'],
['iran', '伊朗专家会议秘书处', 34.625448, 50.876409, 'Leadership', '库姆', 'attacked', 2, '2026-02-28T02:02:00.000Z'],
['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
['iran', '布什尔雷达站', 28.968, 50.838, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:20:00.000Z'],
['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:22:00.000Z'],
['iran', '纳坦兹', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2, '2026-02-28T02:04:00.000Z'],
['iran', '卡拉季无人机厂', 35.808, 51.002, 'UAV', '伊朗', 'attacked', 2, '2026-02-28T02:06:00.000Z'],
['iran', '克尔曼沙赫导弹掩体', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:36:00.000Z'],
['iran', '大不里士空军基地', 38.08, 46.29, 'Missile', '伊朗', 'damaged', 1, '2026-02-28T02:38:00.000Z'],
['iran', '伊拉姆导弹阵地', 33.64, 46.42, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:40:00.000Z'],
['iran', '霍拉马巴德储备库', 33.48, 48.35, 'Missile', '伊朗', 'attacked', 2, '2026-02-28T02:42:00.000Z'],
['iran', '米纳布', 27.13, 57.08, 'Naval', '伊朗', 'damaged', 2, '2026-02-28T02:18:00.000Z'],
['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Naval', '伊朗', 'operational', null, null],
]
iranLocs.forEach((r) => insertLoc.run(...r))
const insertStrikeSource = db.prepare('INSERT OR REPLACE INTO map_strike_source (id, name, lng, lat) VALUES (?, ?, ?, ?)')
const strikeSources = [
['israel', '以色列', 34.78, 32.08],
['lincoln', '林肯号航母', 58.4215, 24.1568],
['ford', '福特号航母', 24.1002, 35.7397],
['virginia_srilanka', '洛杉矶级攻击核潜艇(斯里兰卡外海)', 78.5, 5.2],
['tomahawk_arabian', '阿拉伯海潜艇(战斧)', 59.2, 23.0],
['arabian_sea_sub_torpedo', '阿拉伯海潜艇(鱼雷)', 59.2, 23.0],
]
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'],
]
// 以色列攻击黎巴嫩(真主党目标),时间在伊朗反击之后 14:0014:40
const israelLebanonTargets = [
[35.5, 33.86, '贝鲁特南郊指挥所', '2026-02-28T14:00:00.000Z'],
[35.32, 33.34, '利塔尼弹药库', '2026-02-28T14:10:00.000Z'],
[36.2, 34.01, '巴勒贝克后勤枢纽', '2026-02-28T14:20:00.000Z'],
[35.19, 33.27, '提尔海岸阵地', '2026-02-28T14:30:00.000Z'],
[36.38, 34.39, '赫尔梅勒无人机阵地', '2026-02-28T14:40:00.000Z'],
]
israelTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('israel', lng, lat, name, struckAt))
israelLebanonTargets.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))
const virginiaSrilankaTargets = [[79.8, 6.5, '德纳号轻型护卫舰', '2026-02-28T08:00:00.000Z']]
virginiaSrilankaTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('virginia_srilanka', lng, lat, name, struckAt))
const tomahawkArabianTargets = [[56.26, 27.18, '沙希德·鲁德基号/无人机航母', '2026-02-28T03:00:00.000Z']]
tomahawkArabianTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('tomahawk_arabian', lng, lat, name, struckAt))
const arabianSubTorpedoTargets = [[56.26, 27.18, '沙希德·鲁德基号/无人机航母', '2026-02-28T03:00:00.000Z']]
arabianSubTorpedoTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('arabian_sea_sub_torpedo', lng, lat, name, struckAt))
try {
db.exec(`
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles, drones, missiles, helicopters, submarines, tanks, carriers, civilian_ships, airport_port) VALUES
@@ -182,6 +247,28 @@ function seed() {
const insertUpdate = db.prepare('INSERT INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)')
updateRows.forEach((row) => insertUpdate.run(...row))
try {
db.prepare(
'INSERT OR REPLACE INTO animation_config (id, strike_cutoff_days, updated_at) VALUES (1, ?, datetime(\'now\'))'
).run(5)
} catch (_) {}
// 战区地图配置:钳形轴线、以黎轴线、防御线(与 src/data/extendedWarData 一致,新增数据落库)
const warMapConfig = {
pincerAxes: [
{ start: [43.6, 37.2], end: [46.27, 38.08], name: 'North Pincer (Tabriz)' },
{ start: [45.0, 35.4], end: [46.99, 35.31], name: 'Central Pincer (Sanandaj)' },
{ start: [45.6, 35.2], end: [47.07, 34.31], name: 'South Pincer (Kermanshah)' },
],
israelLebanonAxis: { start: [35.25, 32.95], end: [35.55, 33.45], name: 'Israel → Lebanon' },
defenseLinePath: [[46.27, 38.08], [46.99, 35.31], [47.07, 34.31]],
}
try {
db.prepare(
'INSERT INTO war_map_config (id, config, updated_at) VALUES (1, ?, datetime(\'now\')) ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = datetime(\'now\')'
).run(JSON.stringify(warMapConfig))
} catch (_) {}
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run('2026-03-01T11:45:00.000Z')
console.log('Seed completed.')
}

View File

@@ -54,15 +54,40 @@ function getSituation() {
const powerIr = db.prepare('SELECT * FROM power_index WHERE side = ?').get('iran')
const assetsUs = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('us')
const assetsIr = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('iran')
const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us')
const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran')
const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('us')
const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level, attacked_at FROM key_location WHERE side = ?').all('iran')
let mapStrikeSources = []
let mapStrikeLines = []
try {
mapStrikeSources = db.prepare('SELECT id, name, lng, lat FROM map_strike_source').all()
const lines = db.prepare('SELECT source_id, target_lng, target_lat, target_name, struck_at FROM map_strike_line ORDER BY source_id, struck_at').all()
const bySource = {}
for (const row of lines) {
if (!bySource[row.source_id]) bySource[row.source_id] = []
bySource[row.source_id].push({
lng: row.target_lng,
lat: row.target_lat,
name: row.target_name || '',
struck_at: row.struck_at || null,
})
}
mapStrikeLines = Object.entries(bySource).map(([sourceId, targets]) => ({ sourceId, targets }))
} catch (_) {}
const attackedTargets = (locUs || []).filter((l) => l.status === 'attacked').map((l) => [l.lng, l.lat])
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran')
const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get()
const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all()
// 反击情绪无记录时给默认 50避免爬虫未写入时前端显示 0
const retaliationValue = retaliationCur?.value ?? 50
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
// 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
let animationConfigRow = null
try {
animationConfigRow = db.prepare('SELECT strike_cutoff_days FROM animation_config WHERE id = 1').get()
} catch (_) {}
let conflictEvents = []
let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }
@@ -134,7 +159,7 @@ function getSituation() {
assets: (assetsIr || []).map(toAsset),
keyLocations: locIr || [],
combatLosses: irLosses,
retaliationSentiment: retaliationCur?.value ?? 0,
retaliationSentiment: retaliationValue,
retaliationSentimentHistory: retaliationHist || [],
},
recentUpdates: (updates || []).map((u) => ({
@@ -155,8 +180,15 @@ function getSituation() {
})),
conflictStats,
civilianCasualtiesTotal,
// 顶层聚合,便于 sit.combatLosses.us / sit.combatLosses.iran 与 usForces/iranForces 内保持一致
combatLosses: { us: usLosses, iran: irLosses },
mapData: {
attackedTargets,
strikeSources: mapStrikeSources,
strikeLines: mapStrikeLines,
},
animationConfig: {
strikeCutoffDays: animationConfigRow?.strike_cutoff_days ?? 5,
},
}
}

View File

@@ -1,13 +1,41 @@
const db = require('./db')
function toNum(v) {
if (v == null || v === '') return 0
const n = Number(v)
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0
}
function getStats() {
const viewers = db.prepare(
const viewersRow = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get().n
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
const feedbackCount = db.prepare('SELECT COUNT(*) as n FROM feedback').get().n ?? 0
const shareCount = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
return { viewers, cumulative, feedbackCount, shareCount }
).get()
const cumulativeRow = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()
const feedbackRow = db.prepare('SELECT COUNT(*) as n FROM feedback').get()
const shareRow = db.prepare('SELECT total FROM share_count WHERE id = 1').get()
let realLikeCount = 0
try {
realLikeCount = toNum(db.prepare('SELECT total FROM like_count WHERE id = 1').get()?.total)
} catch (_) {}
let viewers = toNum(viewersRow?.n)
let cumulative = toNum(cumulativeRow?.total)
let feedbackCount = toNum(feedbackRow?.n)
let shareCount = toNum(shareRow?.total)
let likeCount = realLikeCount
let display = null
try {
display = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
} catch (_) {}
const useOverride = display && display.override_enabled === 1
if (useOverride && display) {
if (display.viewers != null) viewers = toNum(display.viewers)
if (display.cumulative != null) cumulative = toNum(display.cumulative)
if (display.share_count != null) shareCount = toNum(display.share_count)
if (display.like_count != null) likeCount = toNum(display.like_count)
else likeCount = realLikeCount
if (display.feedback_count != null) feedbackCount = toNum(display.feedback_count)
}
return { viewers, cumulative, feedbackCount, shareCount, likeCount }
}
module.exports = { getStats }

204
src/api/edit.ts Normal file
View File

@@ -0,0 +1,204 @@
/** 手动修正看板数据 API */
export interface CombatLossesRow {
side: string
bases_destroyed: number
bases_damaged: number
personnel_killed: number
personnel_wounded: number
civilian_killed?: number
civilian_wounded?: number
aircraft: number
warships: number
armor: number
vehicles: number
drones?: number
missiles?: number
helicopters?: number
submarines?: number
tanks?: number
carriers?: number
civilian_ships?: number
airport_port?: number
}
export interface KeyLocationRow {
id: number
side: string
name: string
lat: number
lng: number
type?: string | null
region?: string | null
status?: string | null
damage_level?: number | null
}
export interface SituationUpdateRow {
id: string
timestamp: string
category: string
summary: string
severity: string
}
export interface ForceSummaryRow {
side: string
total_assets: number
personnel: number
naval_ships: number
aircraft: number
ground_units: number
uav: number
missile_consumed: number
missile_stock: number
}
export interface DisplayStatsRow {
overrideEnabled?: boolean
viewers: number
cumulative: number
shareCount: number
likeCount: number
feedbackCount: number
}
export interface AnimationConfigRow {
strikeCutoffDays: number
}
export interface CrawlerConfigRow {
rssIntervalSec: number
}
export interface FeedbackRow {
id: number
content: string
ip: string | null
created_at: string
}
export interface EditRawData {
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
situationUpdates: SituationUpdateRow[]
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
displayStats?: DisplayStatsRow
animationConfig?: AnimationConfigRow
crawlerConfig?: CrawlerConfigRow
}
export async function fetchEditRaw(): Promise<EditRawData> {
const res = await fetch('/api/edit/raw', { cache: 'no-store' })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function putCombatLosses(side: 'us' | 'iran', body: Partial<CombatLossesRow>): Promise<void> {
const res = await fetch('/api/edit/combat-losses', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side, ...body }),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function patchKeyLocation(id: number, body: Partial<KeyLocationRow>): Promise<void> {
const res = await fetch(`/api/edit/key-location/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function postSituationUpdate(body: {
id?: string
timestamp?: string
category: string
summary: string
severity?: string
}): Promise<{ id: string }> {
const res = await fetch('/api/edit/situation-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
return res.json()
}
export async function deleteSituationUpdate(id: string): Promise<void> {
const res = await fetch(`/api/edit/situation-update/${encodeURIComponent(id)}`, { method: 'DELETE' })
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSummaryRow>): Promise<void> {
const res = await fetch('/api/edit/force-summary', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side, ...body }),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
/** 传 null 的字段会清除覆盖,改回实时统计 */
/** 传 clearOverride: true 可关闭覆盖、恢复实时统计 */
export async function putDisplayStats(
body: Partial<{ [K in keyof DisplayStatsRow]: number | null }> & { clearOverride?: boolean }
): Promise<void> {
const res = await fetch('/api/edit/display-stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function putAnimationConfig(body: Partial<AnimationConfigRow>): Promise<void> {
const res = await fetch('/api/edit/animation-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function putCrawlerConfig(body: { rssIntervalSec: number }): Promise<void> {
const res = await fetch('/api/edit/crawler-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const e = await res.json().catch(() => ({}))
throw new Error((e as { error?: string }).error || res.statusText)
}
}
export async function fetchFeedbackList(): Promise<{ list: FeedbackRow[] }> {
const res = await fetch('/api/edit/feedback', { cache: 'no-store' })
if (!res.ok) throw new Error('获取留言失败')
return res.json()
}

View File

@@ -1,28 +1,26 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore'
import { fetchAndSetSituation } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore'
import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare, RefreshCw } from 'lucide-react'
import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare } from 'lucide-react'
const STORAGE_LIKES = 'us-iran-dashboard-likes'
// 开发环境下每标签一个 viewer-id便于本地验证「在看」开/关标签变化
const STORAGE_VIEWER_ID = 'us-iran-viewer-id'
// 用 sessionStorage每个标签/窗口一个 viewer-id无痕多窗口也会各自独立便于「在看」按窗口去重
const getViewerId = (): string | undefined => {
if (typeof import.meta !== 'undefined' && import.meta.env?.DEV && typeof crypto?.randomUUID === 'function') {
if (typeof crypto?.randomUUID !== 'function') return undefined
try {
let id = sessionStorage.getItem('us-iran-viewer-id')
let id = sessionStorage.getItem(STORAGE_VIEWER_ID)
if (!id) {
id = crypto.randomUUID()
sessionStorage.setItem('us-iran-viewer-id', id)
sessionStorage.setItem(STORAGE_VIEWER_ID, id)
}
return id
} catch {
return undefined
}
}
return undefined
}
function getStoredLikes(): number {
@@ -41,17 +39,20 @@ export function HeaderPanel() {
const [now, setNow] = useState(() => new Date())
const [likes, setLikes] = useState(getStoredLikes)
const [liked, setLiked] = useState(false)
const [likeBurst, setLikeBurst] = useState(0)
const pendingLikesRef = useRef(0)
const stats = useStatsStore((s) => s.stats)
const setStats = useStatsStore((s) => s.setStats)
const viewers = stats.viewers ?? 0
const cumulative = stats.cumulative ?? 0
const feedbackCount = stats.feedbackCount ?? 0
const shareCount = stats.shareCount ?? 0
const serverLikeCount = stats.likeCount
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [feedbackText, setFeedbackText] = useState('')
const [feedbackSending, setFeedbackSending] = useState(false)
const [feedbackDone, setFeedbackDone] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [likeSending, setLikeSending] = useState(false)
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
@@ -70,6 +71,7 @@ export function HeaderPanel() {
cumulative: data.cumulative,
feedbackCount: data.feedbackCount,
shareCount: data.shareCount,
likeCount: data.likeCount,
})
} catch {
setStats({ viewers: 0, cumulative: 0 })
@@ -140,15 +142,46 @@ export function HeaderPanel() {
}
}
const handleLike = () => {
if (liked) return
setLiked(true)
const next = likes + 1
setLikes(next)
const sendOneLike = async () => {
try {
localStorage.setItem(STORAGE_LIKES, String(next))
const res = await fetch('/api/like', { method: 'POST' })
const data = await res.json()
if (res.ok && data.likeCount != null) {
setStats({
viewers: data.viewers,
cumulative: data.cumulative,
feedbackCount: data.feedbackCount,
shareCount: data.shareCount,
likeCount: data.likeCount,
})
setLikes(data.likeCount)
try {
localStorage.setItem(STORAGE_LIKES, String(data.likeCount))
} catch {}
}
} catch {
setLikes((prev) => Math.max(0, prev - 1))
} finally {
if (pendingLikesRef.current > 0) {
pendingLikesRef.current -= 1
sendOneLike()
} else {
setLikeSending(false)
}
}
}
const handleLike = () => {
setLiked(true)
setLikes((prev) => (serverLikeCount ?? prev) + 1)
setLikeBurst((n) => n + 1)
if (!likeSending) {
setLikeSending(true)
sendOneLike()
} else {
pendingLikesRef.current += 1
}
}
const formatDateTime = (d: Date) =>
d.toLocaleString('zh-CN', {
@@ -186,9 +219,14 @@ export function HeaderPanel() {
{formatDateTime(now)}
</span>
</div>
{(isConnected || isReplayMode) && (
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
{/* 非回放时显示数据更新时间,与后端 situation.updated_at 一致(爬虫 notify / 编辑保存时后端更新并广播) */}
{isReplayMode ? (
<span className="text-[10px] text-military-accent">
{formatDataTime(situation.lastUpdated)} ()
</span>
) : (
<span className="text-[10px] text-green-500/90">
{formatDataTime(situation.lastUpdated)} ()
</span>
)}
</div>
@@ -219,27 +257,26 @@ export function HeaderPanel() {
<button
type="button"
onClick={handleLike}
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] ${
className={`relative flex shrink-0 select-none items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors active:scale-95 sm:px-2 sm:py-1 sm:text-[10px] ${
liked
? 'border-red-500/50 bg-red-500/20 text-red-400'
: 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400'
}`}
>
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 ${liked ? 'fill-current' : ''}`} />
{likes > 0 && <span className="tabular-nums">{likes}</span>}
</button>
<button
type="button"
onClick={async () => {
setRefreshing(true)
await fetchAndSetSituation().finally(() => setRefreshing(false))
}}
disabled={refreshing}
className="flex shrink-0 items-center gap-1 rounded border border-military-border px-1.5 py-0.5 text-[9px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400 disabled:opacity-50 sm:px-2 sm:py-1 sm:text-[10px]"
title="从服务器拉取最新态势数据"
<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)}
>
<RefreshCw className={`h-3 w-3 sm:h-3.5 sm:w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
+1
</span>
)}
</span>
{(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? likes}</span>}
</button>
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>
{isConnected ? <Wifi className="h-3.5 w-3.5" /> : <WifiOff className="h-3.5 w-3.5" />}

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
import { usePlaybackStore, getTicks, REPLAY_START, REPLAY_END, type ReplayScale } from '@/store/playbackStore'
import { useSituationStore } from '@/store/situationStore'
import { NewsTicker } from './NewsTicker'
import { config } from '@/config'
@@ -33,16 +33,20 @@ export function TimelinePanel() {
const {
isReplayMode,
playbackTime,
replayScale,
isPlaying,
speedSecPerTick,
setReplayMode,
setPlaybackTime,
setReplayScale,
setIsPlaying,
stepForward,
stepBack,
setSpeed,
} = usePlaybackStore()
const replayTicks = getTicks(replayScale)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
@@ -62,27 +66,30 @@ export function TimelinePanel() {
return
}
timerRef.current = setInterval(() => {
const current = usePlaybackStore.getState().playbackTime
const i = REPLAY_TICKS.indexOf(current)
if (i >= REPLAY_TICKS.length - 1) {
const { playbackTime: current, replayScale: scale } = usePlaybackStore.getState()
const ticks = getTicks(scale)
const i = ticks.indexOf(current)
if (i >= ticks.length - 1) {
setIsPlaying(false)
return
}
setPlaybackTime(REPLAY_TICKS[i + 1])
setPlaybackTime(ticks[i + 1])
}, speedSecPerTick * 1000)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
const index = REPLAY_TICKS.indexOf(playbackTime)
const value = index >= 0 ? index : REPLAY_TICKS.length - 1
const index = replayTicks.indexOf(playbackTime)
const value = index >= 0 ? index : replayTicks.length - 1
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const i = parseInt(e.target.value, 10)
setPlaybackTime(REPLAY_TICKS[i])
setPlaybackTime(replayTicks[i])
}
const scaleLabels: Record<ReplayScale, string> = { '30m': '30分钟', '1h': '1小时', '1d': '1天' }
return (
<div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
{!isReplayMode && (
@@ -142,7 +149,7 @@ export function TimelinePanel() {
<button
type="button"
onClick={stepForward}
disabled={index >= REPLAY_TICKS.length - 1}
disabled={index >= replayTicks.length - 1}
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
title="下一步"
>
@@ -150,21 +157,45 @@ export function TimelinePanel() {
</button>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
<div className="flex items-center gap-1 text-[11px] text-military-text-secondary">
<span></span>
<select
value={replayScale}
onChange={(e) => setReplayScale(e.target.value as ReplayScale)}
className="rounded border border-military-border bg-military-dark/80 px-2 py-1 text-[11px] text-military-text-secondary focus:border-military-accent focus:outline-none"
title="回放刻度"
>
{(['30m', '1h', '1d'] as const).map((s) => (
<option key={s} value={s}>{scaleLabels[s]}</option>
))}
</select>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:min-w-[320px]">
<input
type="range"
min={0}
max={REPLAY_TICKS.length - 1}
max={replayTicks.length - 1}
value={value}
onChange={handleSliderChange}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
/>
{/* 按刻度划分的时间轴:均匀取约 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 className="flex items-center gap-2 text-[11px] tabular-nums text-military-text-secondary">
<span>{formatTick(REPLAY_START)}</span>
<span className="font-medium text-military-accent">{formatTick(playbackTime)}</span>
<span>{formatTick(REPLAY_END)}</span>
</div>
<select
@@ -180,6 +211,9 @@ export function TimelinePanel() {
</select>
</>
)}
<span className="ml-auto text-[10px] text-military-text-secondary shrink-0">
</span>
</div>
</div>
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,12 @@
// 仅用于前端展示,不参与任何真实评估
export const EXTENDED_WAR_ZONES = {
// 1. 霍尔木兹海峡交战区 (Strait of Hormuz) — 多边形,包络海峡水道及两侧水域 [lng, lat]
// 1. 霍尔木兹海峡交战区 — 伊朗国境线沿岸(波斯湾→海峡→阿曼湾)+ 阿曼穆桑代姆 + 波斯湾出口 [lng, lat]
hormuzCombatZone: {
type: 'Feature' as const,
properties: {
name: '霍尔木兹海峡交战区',
name_en: 'Strait of Hormuz Area',
status: 'BLOCKED / ENGAGED',
style: {
fillColor: '#FFD700',
@@ -18,24 +19,33 @@ export const EXTENDED_WAR_ZONES = {
type: 'Polygon' as const,
coordinates: [
[
[55.0, 25.0],
[55.5, 25.4],
[56.2, 26.0],
[56.8, 26.6],
[57.2, 27.0],
[57.0, 27.4],
[56.4, 27.2],
[55.8, 26.6],
[55.2, 25.9],
[54.8, 25.4],
[55.0, 25.0],
[55.92, 27.02], // 波斯湾入口(伊朗西侧,近阿联酋水道)
[56.12, 27.08], // 伊朗沿岸向东
[56.27, 27.18], // 阿巴斯港一带(伊朗国境线)
[56.35, 27.05], // 格什姆岛西北侧伊朗主陆
[56.28, 26.92], // 格什姆岛北缘(伊朗海岸)
[56.45, 26.88], // 格什姆东侧水道(伊朗岸)
[56.62, 26.78], // 伊朗沿岸向东
[56.88, 26.58], // 米纳布方向(伊朗海岸)
[57.08, 26.42], // 锡里克Sirik附近伊朗国境线
[57.38, 25.88], // 库角Ras al Kuh
[57.52, 25.72], // 库角Ras al Kuh
[57.77, 25.64], // 贾斯克Jask— 阿曼湾开口伊朗侧
[56.26, 25.61], // 迪巴Dibba— 阿曼湾开口阿曼侧
[56.34, 25.92], // 穆桑代姆东海岸
[56.38, 26.18], // 穆桑代姆东海岸
[56.4, 26.35], // 拉斯·穆桑代姆最北端
[56.24, 26.22], // 穆桑代姆西侧狭窄水道
[56.08, 26.0], // 穆桑代姆西海岸
[55.96, 26.05], // 波斯湾出口(阿曼/阿联酋侧)
[55.92, 27.02], // 闭合:回到起点
],
],
},
},
// 霍尔木兹区域标注点(多边形中心附近,用于显示文字)
hormuzLabelCenter: [56.0, 26.2] as [number, number],
hormuzLabelCenter: [56.5, 26.35] as [number, number],
// 2. 真主党势力范围 (Hezbollah) — 黎巴嫩南部 + 贝卡谷地,多边形 [lng, lat]
hezbollahZone: {
@@ -81,6 +91,24 @@ export const EXTENDED_WAR_ZONES = {
// 真主党区域标注点(用于显示文字)
hezbollahLabelCenter: [35.7, 33.7] as [number, number],
/** 苏丹武装标注点(地图上「苏丹武装」文字位置;国境由 countries 源 filter Sudan 绘制) */
sudanLabelCenter: [30.5, 15.25] as [number, number],
// 库尔德武装势力区域标注点(叙/土/伊三区紫色带中心附近)
kurdishLabelCenter: [43.5, 36.3] as [number, number],
// 真主党打击源(黎巴嫩境内多处,随机选取作为攻击起点)[lng, lat]
hezbollahStrikeSource: [35.32, 33.28] as [number, number],
/** 黎巴嫩境内攻击源(提尔、西顿、巴勒贝克、贝鲁特南郊、纳巴提耶等),用于黎巴嫩→以色列动画 */
lebanonStrikeSources: [
[35.19, 33.27], // 提尔 Tyre
[35.37, 33.56], // 西顿 Sidon
[36.2, 34.01], // 巴勒贝克 Baalbek
[35.5, 33.86], // 贝鲁特南郊 Dahieh
[35.38, 33.38], // 纳巴提耶 Nabatiyeh
[35.85, 33.85], // 贝卡谷地
] as [number, number][],
// 3. 真主党当前攻击目标 (North Israel Targets)
activeAttacks: [
{
@@ -104,6 +132,139 @@ export const EXTENDED_WAR_ZONES = {
],
} as const
/** 跨国库尔德势力范围与钳形攻势 — 土(Bakur)/叙(Rojava)/伊(Bashur) 三区 + 北/南钳形箭头 */
const SYRIA_ROJAVA: [number, number][] = [
[36.0, 36.5],
[42.3, 37.1],
[42.0, 35.0],
[39.0, 34.5],
[38.0, 35.5],
[36.0, 36.5],
]
const TURKEY_BAKUR: [number, number][] = [
[39.5, 38.5],
[44.5, 38.2],
[44.8, 37.0],
[42.5, 37.0],
[40.0, 37.5],
[39.5, 38.5],
]
const IRAQ_BASHUR: [number, number][] = [
[42.5, 37.2],
[45.0, 37.3],
[46.2, 35.8],
[45.5, 34.5],
[43.5, 34.8],
[42.5, 36.0],
[42.5, 37.2],
]
/** 北/中/南三线进攻轴线起止点 [lng, lat],供生长动画与固定几何使用 */
export const PINCER_AXES = [
{ start: [43.6, 37.2] as [number, number], end: [46.27, 38.08] as [number, number], name: 'North Pincer (Tabriz)' },
{ start: [45.0, 35.4] as [number, number], end: [46.99, 35.31] as [number, number], name: 'Central Pincer (Sanandaj)' },
{ start: [45.6, 35.2] as [number, number], end: [47.07, 34.31] as [number, number], name: 'South Pincer (Kermanshah)' },
] as const
/** 以色列进攻黎巴嫩轴线 [lng, lat]:以色列北部 → 黎巴嫩南部/真主党势力方向 */
export const ISRAEL_LEBANON_AXIS = {
start: [35.25, 32.95] as [number, number],
end: [35.55, 33.45] as [number, number],
name: 'Israel → Lebanon',
} as const
/** 伊朗被库尔德武装进攻点防御线:大不里士→萨南达季→克尔曼沙赫,黄色虚线 */
export const KURDISH_ATTACK_DEFENSE_LINE: GeoJSON.Feature<GeoJSON.LineString> = {
type: 'Feature',
properties: { name: '伊朗西部防御线' },
geometry: {
type: 'LineString',
coordinates: [
[46.27, 38.08],
[46.99, 35.31],
[47.07, 34.31],
],
},
}
/** 三叉戟 18 点一笔画固定几何(不回头路径、槽位 t 小于主尖),与 PINCER_AXES 顺序一致:北 / 中 / 南 */
export const PINCER_TRIDENT_RINGS: [number, number][][] = [
[ // 北线 (Tabriz) — 彻底修复版
[43.45, 37.55], [43.75, 36.95], [43.85, 37.05], [43.55, 37.65],
[44.40, 37.35],
[45.10, 37.15], [46.15, 37.60], [45.40, 37.55],
[44.80, 37.70],
[45.50, 37.85], [46.27, 38.08], [45.40, 38.00],
[44.75, 37.95],
[45.05, 38.25], [46.00, 38.45], [45.20, 38.15],
[44.35, 37.60],
[43.45, 37.55],
],
[
[44.95, 35.70], [45.05, 35.10], [45.15, 35.15], [45.05, 35.75],
[45.60, 35.25], [46.20, 35.10], [46.85, 35.15], [46.20, 35.25],
[45.85, 35.31], [46.40, 35.25], [46.99, 35.31], [46.40, 35.38],
[45.85, 35.45], [46.25, 35.55], [46.80, 35.65], [46.25, 35.45],
[45.65, 35.55], [44.95, 35.70],
],
[
[45.85, 35.45], [45.35, 34.95], [45.45, 35.05], [45.95, 35.55],
[46.05, 34.85], [46.45, 34.50], [46.95, 34.15], [46.50, 34.35],
[46.25, 34.45], [46.65, 34.35], [47.07, 34.31], [46.75, 34.55],
[46.35, 34.75], [46.65, 34.85], [47.15, 34.65], [46.55, 34.95],
[46.30, 35.15], [45.85, 35.45],
],
]
export const KURDISH_FRONT_GEOJSON: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
name: 'Kurdish Tactical Range (Bakur / Rojava / Bashur)',
region_type: 'InfluenceZone',
description: '叙利亚东北部(SDF)、土耳其东南部、伊拉克 KRG 自治区,紫色弧形地带向 Rojhelat 钳形攻势',
},
geometry: {
type: 'MultiPolygon',
coordinates: [[SYRIA_ROJAVA], [TURKEY_BAKUR], [IRAQ_BASHUR]],
},
},
{
type: 'Feature',
properties: {
name: '大不里士 (Tabriz)',
region_type: 'Target',
status: 'Obj: CRITICAL',
showMarker: false,
},
geometry: { type: 'Point', coordinates: [46.27, 38.08] },
},
{
type: 'Feature',
properties: {
name: '萨南达季 (Sanandaj)',
region_type: 'Target',
status: 'Obj: HIGH',
showMarker: true,
},
geometry: { type: 'Point', coordinates: [46.99, 35.31] },
},
{
type: 'Feature',
properties: {
name: '克尔曼沙赫 (Kermanshah)',
region_type: 'Target',
status: 'Obj: STRATEGIC',
showMarker: true,
},
geometry: { type: 'Point', coordinates: [47.07, 34.31] },
},
],
}
// 战损评估点位(以色列打击黎巴嫩 & 联军打击伊朗本土)
export const STRIKE_DAMAGE_ASSESSMENT = {
lebanonFront: [

View File

@@ -1,188 +1,15 @@
/** 航母标记 - 全部中文 */
export const CARRIER_MARKERS = [
{
id: 'CVN-72',
name: '林肯号航母',
coordinates: [58.4215, 24.1568] as [number, number],
type: 'Aircraft Carrier',
status: 'Active - Combat Readiness',
details: '林肯号航母打击群 (CSG-3) 部署于北阿拉伯海。',
},
{
id: 'CVN-78',
name: '福特号航母',
coordinates: [24.1002, 35.7397] as [number, number],
type: 'Aircraft Carrier',
status: 'Active - Forward Deployed',
details: '距克里特苏达湾约 15 公里。',
},
]
/**
* 地图点位类型定义。实际数据来自 APIgetSituation 的 usForces.keyLocations / iranForces.keyLocations 与 mapData
*/
export type KeyLocItem = {
name: string
lat: number
lng: number
type?: string
region?: string
id?: number
status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number
/** 遭袭时间 ISO 字符串,用于状态管理与数据回放 */
attacked_at?: string | null
}
/** 美军基地总数 62被袭击 27 个。损毁程度:严重 6 / 中度 12 / 轻度 9 */
const ATTACKED_BASES = [
// 严重损毁 (6): 高价值目标,近伊朗
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, region: '伊拉克', damage_level: 3 },
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, region: '伊拉克', damage_level: 3 },
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, region: '卡塔尔', damage_level: 3 },
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, region: '伊拉克', damage_level: 3 },
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, region: '土耳其', damage_level: 3 },
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, region: '沙特', damage_level: 3 },
// 中度损毁 (12)
{ name: '塔吉军营', lat: 33.556, lng: 44.256, region: '伊拉克', damage_level: 2 },
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, region: '伊拉克', damage_level: 2 },
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, region: '叙利亚', damage_level: 2 },
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, region: '叙利亚', damage_level: 2 },
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, region: '叙利亚', damage_level: 2 },
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, region: '叙利亚', damage_level: 2 },
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, region: '科威特', damage_level: 2 },
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, region: '科威特', damage_level: 2 },
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, region: '巴林', damage_level: 2 },
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, region: '阿联酋', damage_level: 2 },
{ name: '埃斯康村', lat: 24.774, lng: 46.738, region: '沙特', damage_level: 2 },
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, region: '以色列', damage_level: 2 },
// 轻度损毁 (9)
{ name: '布林军营', lat: 29.603, lng: 47.456, region: '科威特', damage_level: 1 },
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, region: '卡塔尔', damage_level: 1 },
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, region: '以色列', damage_level: 1 },
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, region: '约旦', damage_level: 1 },
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, region: '土耳其', damage_level: 1 },
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, region: '阿曼', damage_level: 1 },
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, region: '阿曼', damage_level: 1 },
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, region: '埃及', damage_level: 1 },
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, region: '吉布提', damage_level: 1 },
]
/** 35 个新增 operational 基地 */
const NEW_BASES: KeyLocItem[] = [
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔' },
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林' },
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特' },
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特' },
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋' },
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋' },
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特' },
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特' },
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特' },
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克' },
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克' },
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克' },
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克' },
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚' },
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚' },
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦' },
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其' },
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列' },
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼' },
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼' },
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及' },
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及' },
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提' },
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔' },
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特' },
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克' },
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚' },
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦' },
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其' },
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列' },
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼' },
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及' },
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提' },
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋' },
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克' },
]
/** 美军全部地图点位2 航母 + 9 海军 + 62 基地 */
export const US_KEY_LOCATIONS: KeyLocItem[] = [
...CARRIER_MARKERS.map((c) => ({
name: c.name + ` (${c.id})`,
lat: c.coordinates[1],
lng: c.coordinates[0],
type: 'Aircraft Carrier' as const,
region: c.id === 'CVN-72' ? '北阿拉伯海' : '东地中海',
status: 'operational' as const,
damage_level: undefined as number | undefined,
})),
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational' },
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational' },
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational' },
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational' },
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational' },
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
...ATTACKED_BASES.map((b) => ({
...b,
type: 'Base' as const,
status: 'attacked' as const,
})),
...NEW_BASES,
]
/** 被袭击的 27 个基地坐标 [lng, lat],用于绘制攻击曲线 */
export const ATTACKED_TARGETS: [number, number][] = ATTACKED_BASES.map((b) => [b.lng, b.lat])
/** 美以联军打击伊朗目标 (2026.03.01) - 中文标注coords [lng, lat] */
export const ALLIED_STRIKE_LOCATIONS = [
// 1. 核心指挥与政治中枢
{ name: '哈梅内伊官邸', coords: [51.42, 35.69] as [number, number], type: 'Leadership' },
{ name: '总统府/情报部', coords: [51.41, 35.72] as [number, number], type: 'Leadership' },
{ name: '梅赫拉巴德机场', coords: [51.15, 35.69] as [number, number], type: 'Leadership' },
{ name: '库姆', coords: [50.88, 34.64] as [number, number], type: 'Leadership' },
// 2. 核设施与战略研究点
{ name: '伊斯法罕核设施', coords: [51.667, 32.654] as [number, number], type: 'Nuclear' },
{ name: '纳坦兹', coords: [51.916, 33.666] as [number, number], type: 'Nuclear' },
{ name: '布什尔雷达站', coords: [50.838, 28.968] as [number, number], type: 'Nuclear' },
// 3. 导弹与无人机基地
{ name: '卡拉季无人机厂', coords: [51.002, 35.808] as [number, number], type: 'UAV' },
{ name: '克尔曼沙赫导弹掩体', coords: [47.076, 34.314] as [number, number], type: 'Missile' },
{ name: '大不里士空军基地', coords: [46.29, 38.08] as [number, number], type: 'Missile' },
{ name: '伊拉姆导弹阵地', coords: [46.42, 33.64] as [number, number], type: 'Missile' },
{ name: '霍拉马巴德储备库', coords: [48.35, 33.48] as [number, number], type: 'Missile' },
// 4. 海军与南部封锁节点
{ name: '阿巴斯港海军司令部', coords: [56.27, 27.18] as [number, number], type: 'Naval' },
{ name: '米纳布', coords: [57.08, 27.13] as [number, number], type: 'Naval' },
{ name: '霍尔木兹岸防阵地', coords: [56.5, 27.0] as [number, number], type: 'Naval' },
]
/** 盟军打击目标坐标 [lng, lat] */
export const ALLIED_STRIKE_TARGETS: [number, number][] = ALLIED_STRIKE_LOCATIONS.map((s) => s.coords)
/** 林肯号航母位置 [lng, lat] - 北阿拉伯海 */
export const LINCOLN_COORDS: [number, number] = [58.4215, 24.1568]
/** 福特号航母位置 [lng, lat] - 东地中海 */
export const FORD_COORDS: [number, number] = [24.1002, 35.7397]
/** 以色列打击源 [lng, lat] - 特拉维夫附近 */
export const ISRAEL_STRIKE_SOURCE: [number, number] = [34.78, 32.08]
/** 林肯号打击目标:南部海军/核设施 */
export const LINCOLN_STRIKE_TARGETS: [number, number][] = [
[56.27, 27.18], [57.08, 27.13], [56.5, 27.0], // 阿巴斯港、米纳布、霍尔木兹
[50.838, 28.968], [51.667, 32.654], // 布什尔、伊斯法罕
]
/** 福特号打击目标:北部/西部 */
export const FORD_STRIKE_TARGETS: [number, number][] = [
[51.42, 35.69], [51.41, 35.72], [51.15, 35.69], // 德黑兰核心
[46.29, 38.08], [47.076, 34.314], [46.42, 33.64], [48.35, 33.48], // 大不里士、克尔曼沙赫、伊拉姆、霍拉马巴德
]
/** 以色列打击目标:中部核设施/指挥 */
export const ISRAEL_STRIKE_TARGETS: [number, number][] = [
[50.88, 34.64], [51.916, 33.666], [51.002, 35.808], // 库姆、纳坦兹、卡拉季
]
export const IRAN_KEY_LOCATIONS: KeyLocItem[] = [
{ name: '阿巴斯港', lat: 27.1832, lng: 56.2666, type: 'Port', region: '伊朗' },
{ name: '德黑兰', lat: 35.6892, lng: 51.389, type: 'Capital', region: '伊朗' },
{ name: '布什尔', lat: 28.9681, lng: 50.838, type: 'Base', region: '伊朗' },
]

View File

@@ -1,5 +1,4 @@
// TypeScript interfaces for military situation data
import { US_KEY_LOCATIONS, IRAN_KEY_LOCATIONS } from './mapLocations'
export interface ForceAsset {
id: string
@@ -90,6 +89,8 @@ export interface MilitarySituation {
id?: number
status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number
/** 遭袭时间 ISO 字符串,用于状态管理与回放 */
attacked_at?: string | null
}[]
combatLosses: CombatLosses
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
@@ -108,6 +109,7 @@ export interface MilitarySituation {
id?: number
status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number
attacked_at?: string | null
}[]
combatLosses: CombatLosses
/** 反击情绪指标 0-100 */
@@ -122,6 +124,19 @@ export interface MilitarySituation {
conflictStats?: ConflictStats
/** 平民伤亡合计(不区分阵营) */
civilianCasualtiesTotal?: { killed: number; wounded: number }
/** 地图打击数据(来自 DB被袭美军基地、打击源、源→目标连线含攻击时间便于回放 */
mapData?: {
attackedTargets: [number, number][]
strikeSources: { id: string; name: string; lng: number; lat: number }[]
strikeLines: {
sourceId: string
targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[]
}[]
}
/** 动画配置:攻击脉冲衰减窗口(天),可在编辑面板调整 */
animationConfig?: {
strikeCutoffDays: number
}
}
export const INITIAL_MOCK_DATA: MilitarySituation = {
@@ -154,7 +169,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
{ id: 'us-8', name: 'MQ-9 死神', type: '无人机', count: 28, status: 'active' },
{ id: 'us-9', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' },
],
keyLocations: US_KEY_LOCATIONS,
keyLocations: [],
combatLosses: {
bases: { destroyed: 0, damaged: 2 },
personnelCasualties: { killed: 127, wounded: 384 },
@@ -211,7 +226,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
{ id: 'ir-8', name: '法塔赫 (Fattah)', type: '导弹', count: 12, status: 'alert' },
{ id: 'ir-9', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' },
],
keyLocations: IRAN_KEY_LOCATIONS,
keyLocations: [],
combatLosses: {
bases: { destroyed: 3, damaged: 8 },
personnelCasualties: { killed: 2847, wounded: 5620 },
@@ -274,4 +289,79 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
conflictEvents: [],
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
civilianCasualtiesTotal: { killed: 430, wounded: 1255 },
// 与 server/seed.js 一致,首屏与未连 API 时也有打击数据,前端可正常绘制攻击线与动画
mapData: {
attackedTargets: [
[42.441, 33.785],
[44.366, 33.315],
[51.314, 25.117],
[43.963, 36.237],
[35.425, 37.002],
[47.58, 24.062],
[44.256, 33.556],
[42.45, 33.8],
[38.618, 33.49],
[40.73, 36.058],
[40.295, 35.336],
[41.885, 37.015],
[47.799, 28.832],
[47.52, 29.346],
[50.608, 26.236],
[54.547, 24.248],
[46.738, 24.774],
[35.012, 31.208],
[47.456, 29.603],
[51.52, 25.275],
[34.666, 30.776],
[36.259, 32.356],
[37.794, 38.354],
[54.024, 17.666],
[58.89, 20.675],
[30.298, 30.915],
[43.159, 11.547],
] as [number, number][],
strikeSources: [
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
{ id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 },
{ id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 },
],
strikeLines: [
{
sourceId: 'israel',
targets: [
{ lng: 50.88, lat: 34.64, name: '库姆' },
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
{ lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' },
{ lng: 35.5, lat: 33.86, name: '贝鲁特南郊指挥所' },
{ lng: 35.32, lat: 33.34, name: '利塔尼弹药库' },
{ lng: 36.2, lat: 34.01, name: '巴勒贝克后勤枢纽' },
{ lng: 35.19, lat: 33.27, name: '提尔海岸阵地' },
{ lng: 36.38, lat: 34.39, 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 type { MilitarySituation } from '@/data/mockData'
import { useSituationStore } from '@/store/situationStore'
import { usePlaybackStore } from '@/store/playbackStore'
import { usePlaybackStore, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
/** 盟军打击伊朗结束时刻:伊朗战损在此阶段增长 */
const ALLIED_STRIKE_END = '2026-02-28T04:00:00.000Z'
/** 伊朗反击开始时刻:美军战损在此阶段增长 */
const IRAN_RETALIATION_START = '2026-02-28T06:00:00.000Z'
/** 将系列时间映射到回放日以便按当天时刻插值 */
function toReplayDay(iso: string, baseDay: string): string {
const d = new Date(iso)
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number)
return new Date(y, (m || 1) - 1, day || 1, d.getUTCHours(), d.getUTCMinutes(), 0, 0).toISOString()
}
/** 判断数据库时间戳是否已到回放时刻(用于按日期刻度自动追加) */
function atOrBefore(ts: string | null | undefined, playbackTime: string): boolean {
if (!ts) return true
return new Date(ts).getTime() <= new Date(playbackTime).getTime()
}
function interpolateAt(
series: { time: string; value: number }[],
at: string,
@@ -43,7 +54,7 @@ function linearProgress(start: string, end: string, at: string): number {
return (ta - ts) / (te - ts)
}
/** 根据回放时刻派生态势数据 */
/** 根据回放时刻派生态势数据:按日期刻度过滤,只显示 attacked_at / struck_at / timestamp 不晚于 playbackTime 的数据库数据 */
export function useReplaySituation(): MilitarySituation {
const situation = useSituationStore((s) => s.situation)
const { isReplayMode, playbackTime } = usePlaybackStore()
@@ -51,86 +62,110 @@ export function useReplaySituation(): MilitarySituation {
return useMemo(() => {
if (!isReplayMode) return situation
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
// 战损阶段:伊朗(盟军打击 02: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 retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
// 华尔街趋势、反击情绪:按回放日当天的时刻插值,保留两位小数避免穿模
const replayDay = playbackTime.slice(0, 10)
const wsValue = Math.round(interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime, replayDay) * 100) / 100
const retValue = Math.round(interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime, replayDay) * 100) / 100
// 战斗损失:从 0 线性增长到当前值
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
const usLoss = situation.usForces.combatLosses
const irLoss = situation.iranForces.combatLosses
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
const usLossesAt = {
bases: {
destroyed: lerp(0, usLoss.bases.destroyed),
damaged: lerp(0, usLoss.bases.damaged),
destroyed: lerpUs(0, usLoss.bases.destroyed),
damaged: lerpUs(0, usLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, usLoss.personnelCasualties.killed),
wounded: lerp(0, usLoss.personnelCasualties.wounded),
killed: lerpUs(0, usLoss.personnelCasualties.killed),
wounded: lerpUs(0, usLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, usLoss.aircraft),
warships: lerp(0, usLoss.warships),
armor: lerp(0, usLoss.armor),
vehicles: lerp(0, usLoss.vehicles),
drones: lerp(0, usLoss.drones ?? 0),
missiles: lerp(0, usLoss.missiles ?? 0),
helicopters: lerp(0, usLoss.helicopters ?? 0),
submarines: lerp(0, usLoss.submarines ?? 0),
carriers: lerp(0, usLoss.carriers ?? 0),
civilianShips: lerp(0, usLoss.civilianShips ?? 0),
airportPort: lerp(0, usLoss.airportPort ?? 0),
aircraft: lerpUs(0, usLoss.aircraft),
warships: lerpUs(0, usLoss.warships),
armor: lerpUs(0, usLoss.armor),
vehicles: lerpUs(0, usLoss.vehicles),
drones: lerpUs(0, usLoss.drones ?? 0),
missiles: lerpUs(0, usLoss.missiles ?? 0),
helicopters: lerpUs(0, usLoss.helicopters ?? 0),
submarines: lerpUs(0, usLoss.submarines ?? 0),
carriers: lerpUs(0, usLoss.carriers ?? 0),
civilianShips: lerpUs(0, usLoss.civilianShips ?? 0),
airportPort: lerpUs(0, usLoss.airportPort ?? 0),
}
const irLossesAt = {
bases: {
destroyed: lerp(0, irLoss.bases.destroyed),
damaged: lerp(0, irLoss.bases.damaged),
destroyed: lerpIran(0, irLoss.bases.destroyed),
damaged: lerpIran(0, irLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, irLoss.personnelCasualties.killed),
wounded: lerp(0, irLoss.personnelCasualties.wounded),
killed: lerpIran(0, irLoss.personnelCasualties.killed),
wounded: lerpIran(0, irLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, irLoss.aircraft),
warships: lerp(0, irLoss.warships),
armor: lerp(0, irLoss.armor),
vehicles: lerp(0, irLoss.vehicles),
drones: lerp(0, irLoss.drones ?? 0),
missiles: lerp(0, irLoss.missiles ?? 0),
helicopters: lerp(0, irLoss.helicopters ?? 0),
submarines: lerp(0, irLoss.submarines ?? 0),
carriers: lerp(0, irLoss.carriers ?? 0),
civilianShips: lerp(0, irLoss.civilianShips ?? 0),
airportPort: lerp(0, irLoss.airportPort ?? 0),
aircraft: lerpIran(0, irLoss.aircraft),
warships: lerpIran(0, irLoss.warships),
armor: lerpIran(0, irLoss.armor),
vehicles: lerpIran(0, irLoss.vehicles),
drones: lerpIran(0, irLoss.drones ?? 0),
missiles: lerpIran(0, irLoss.missiles ?? 0),
helicopters: lerpIran(0, irLoss.helicopters ?? 0),
submarines: lerpIran(0, irLoss.submarines ?? 0),
carriers: lerpIran(0, irLoss.carriers ?? 0),
civilianShips: lerpIran(0, irLoss.civilianShips ?? 0),
airportPort: lerpIran(0, irLoss.airportPort ?? 0),
}
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
// 被袭基地:按 DB attacked_at 过滤,只显示已到回放时刻的遭袭点
const usLocs = situation.usForces.keyLocations || []
const attackedBases = usLocs
.filter((loc) => loc.status === 'attacked')
.sort((a, b) => (b.damage_level ?? 0) - (a.damage_level ?? 0))
const totalAttacked = attackedBases.length
const shownAttackedCount = Math.round(progress * totalAttacked)
const attackedNames = new Set(
attackedBases.slice(0, shownAttackedCount).map((l) => l.name)
)
const usLocsAt = usLocs.map((loc) => {
if (loc.status === 'attacked' && !attackedNames.has(loc.name)) {
if (loc.status === 'attacked' && !atOrBefore(loc.attacked_at, playbackTime)) {
return { ...loc, status: 'operational' as const }
}
return { ...loc }
})
const irLocs = situation.iranForces.keyLocations || []
const irLocsAt = irLocs.map((loc) => {
if ((loc.status === 'attacked' || loc.status === 'damaged') && !atOrBefore(loc.attacked_at, playbackTime)) {
return { ...loc, status: 'operational' as const }
}
return { ...loc }
})
// mapData按 struck_at / attacked_at 过滤;打击线固定顺序 以色列 → 林肯 → 福特,保证回放时以色列动画先出现
const mapData = situation.mapData
const mapDataAt = mapData
? (() => {
const filtered = mapData.strikeLines.map((line) => ({
sourceId: line.sourceId,
targets: line.targets.filter((t) => atOrBefore(t.struck_at, playbackTime)),
}))
const order = ['israel', 'lincoln', 'ford']
const ordered = order.map((id) => filtered.find((l) => l.sourceId === id)).filter(Boolean) as typeof filtered
const rest = filtered.filter((l) => !order.includes(l.sourceId))
const strikeLines = [...ordered, ...rest]
return {
attackedTargets: (usLocsAt.filter((l) => l.status === 'attacked') as { lng: number; lat: number }[]).map(
(l) => [l.lng, l.lat] as [number, number]
),
strikeSources: mapData.strikeSources,
strikeLines,
}
})()
: undefined
return {
...situation,
lastUpdated: playbackTime,
civilianCasualtiesTotal: {
killed: lerp(0, civTotal.killed),
wounded: lerp(0, civTotal.wounded),
killed: lerpIran(0, civTotal.killed),
wounded: lerpIran(0, civTotal.wounded),
},
usForces: {
...situation.usForces,
@@ -143,6 +178,7 @@ export function useReplaySituation(): MilitarySituation {
},
iranForces: {
...situation.iranForces,
keyLocations: irLocsAt,
combatLosses: irLossesAt,
retaliationSentiment: retValue,
retaliationSentimentHistory: [
@@ -153,8 +189,11 @@ export function useReplaySituation(): MilitarySituation {
recentUpdates: (situation.recentUpdates || []).filter(
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
),
conflictEvents: situation.conflictEvents || [],
conflictEvents: (situation.conflictEvents || []).filter(
(e) => new Date(e.event_time).getTime() <= new Date(playbackTime).getTime()
),
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
mapData: mapDataAt,
}
}, [situation, isReplayMode, playbackTime])
}

109
src/hooks/useWarMapData.ts Normal file
View File

@@ -0,0 +1,109 @@
import { useMemo, useState, useEffect } from 'react'
import {
PINCER_AXES,
ISRAEL_LEBANON_AXIS,
KURDISH_ATTACK_DEFENSE_LINE,
} from '@/data/extendedWarData'
import { createTacticalPincerAtProgress } from '@/utils/tacticalPincerArrow'
import { createCurvedDefensePath, createDefenseLine } from '@/utils/defenseLine'
export type PincerAxis = { start: [number, number]; end: [number, number]; name: string }
export type IsraelLebanonAxis = { start: [number, number]; end: [number, number]; name: string }
export type WarMapConfig = {
pincerAxes: PincerAxis[]
israelLebanonAxis: IsraelLebanonAxis
defenseLinePath: [number, number][]
}
const DEFAULT_CONFIG: WarMapConfig = {
pincerAxes: [...PINCER_AXES],
israelLebanonAxis: { ...ISRAEL_LEBANON_AXIS },
defenseLinePath: KURDISH_ATTACK_DEFENSE_LINE.geometry.coordinates as [number, number][],
}
export type WarMapData = WarMapConfig & {
kurdishPincerGrowthInitial: GeoJSON.FeatureCollection
israelLebanonArrowInitial: GeoJSON.FeatureCollection
kurdishDefenseLineGeoJson: GeoJSON.FeatureCollection
}
function buildGeoJsonFromConfig(config: WarMapConfig): Omit<WarMapData, keyof WarMapConfig> {
const kurdishPincerGrowthInitial: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: config.pincerAxes.map((axis) => ({
type: 'Feature',
properties: { name: axis.name },
geometry: {
type: 'Polygon',
coordinates: [createTacticalPincerAtProgress(axis.start, axis.end, 0)],
},
})),
}
const israelLebanonArrowInitial: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { name: config.israelLebanonAxis.name },
geometry: {
type: 'Polygon',
coordinates: [createTacticalPincerAtProgress(
config.israelLebanonAxis.start,
config.israelLebanonAxis.end,
0
)],
},
}],
}
const curvedPath = createCurvedDefensePath(config.defenseLinePath, 0.35, 24)
const lineCoords = createDefenseLine(curvedPath, 0.18, 0.08)
// 整体防御线方向:用锯齿线起点与终点的连线方向
const startPoint = lineCoords[0]
const endPoint = lineCoords[lineCoords.length - 1]
const dx = endPoint[0] - startPoint[0]
const dy = endPoint[1] - startPoint[1]
const tangentAngle = Math.atan2(dy, dx)
const normalAngle = tangentAngle + Math.PI / 2
let angleDeg = (normalAngle * 180) / Math.PI
angleDeg = ((angleDeg + 540) % 360) - 180 // 归一到 [-180, 180]
// 标注点放在整条防御线(锯齿线)中点
const labelIndex = Math.floor(lineCoords.length / 2)
const labelPoint = lineCoords[labelIndex]
const name = '伊朗西部防御线'
const kurdishDefenseLineGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name }, geometry: { type: 'LineString', coordinates: lineCoords } },
{ type: 'Feature', properties: { name, angle: angleDeg }, geometry: { type: 'Point', coordinates: labelPoint } },
],
}
return { kurdishPincerGrowthInitial, israelLebanonArrowInitial, kurdishDefenseLineGeoJson }
}
/** 封装地图轴线、防御线及派生 GeoJSON可从 API 加载后落库,方便后续直接调用 */
export function useWarMapData(): WarMapData {
const [config, setConfig] = useState<WarMapConfig>(DEFAULT_CONFIG)
useEffect(() => {
let cancelled = false
fetch('/api/war-map-config', { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((data: WarMapConfig | null) => {
if (cancelled || !data) return
if (Array.isArray(data.pincerAxes) && data.pincerAxes.length > 0 && data.israelLebanonAxis && Array.isArray(data.defenseLinePath)) {
setConfig({
pincerAxes: data.pincerAxes,
israelLebanonAxis: data.israelLebanonAxis,
defenseLinePath: data.defenseLinePath,
})
}
})
.catch(() => {})
return () => { cancelled = true }
}, [])
return useMemo(() => {
const built = buildGeoJsonFromConfig(config)
return { ...config, ...built }
}, [config])
}

View File

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

View File

@@ -1,28 +1,884 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, Settings } from 'lucide-react'
import { ArrowLeft, RefreshCw, Save, Trash2, Plus, ChevronDown, ChevronRight, MessageSquare, X } from 'lucide-react'
import {
fetchEditRaw,
putCombatLosses,
patchKeyLocation,
postSituationUpdate,
deleteSituationUpdate,
putForceSummary,
putDisplayStats,
putAnimationConfig,
putCrawlerConfig,
fetchFeedbackList,
type EditRawData,
type CombatLossesRow,
type KeyLocationRow,
type ForceSummaryRow,
type DisplayStatsRow,
type AnimationConfigRow,
type CrawlerConfigRow,
type FeedbackRow,
} from '@/api/edit'
import { fetchAndSetSituation } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore'
const LOSS_FIELDS: { key: keyof CombatLossesRow; label: string }[] = [
{ key: 'bases_destroyed', label: '基地摧毁' },
{ key: 'bases_damaged', label: '基地受损' },
{ key: 'personnel_killed', label: '人员阵亡' },
{ key: 'personnel_wounded', label: '人员受伤' },
{ key: 'civilian_killed', label: '平民死亡' },
{ key: 'civilian_wounded', label: '平民受伤' },
{ key: 'aircraft', label: '飞机' },
{ key: 'warships', label: '军舰' },
{ key: 'armor', label: '装甲' },
{ key: 'vehicles', label: '车辆' },
{ key: 'drones', label: '无人机' },
{ key: 'missiles', label: '导弹' },
{ key: 'helicopters', label: '直升机' },
{ key: 'submarines', label: '潜艇' },
{ key: 'carriers', label: '航母' },
{ key: 'civilian_ships', label: '民船' },
{ key: 'airport_port', label: '机场/港口' },
]
const SUMMARY_FIELDS: { key: keyof ForceSummaryRow; label: string }[] = [
{ key: 'total_assets', label: '总资产' },
{ key: 'personnel', label: '人员' },
{ key: 'naval_ships', label: '舰艇' },
{ key: 'aircraft', label: '飞机' },
{ key: 'ground_units', label: '地面单位' },
{ key: 'uav', label: '无人机' },
{ key: 'missile_consumed', label: '导弹消耗' },
{ key: 'missile_stock', label: '导弹库存' },
]
const CATEGORIES = ['deployment', 'alert', 'intel', 'diplomatic', 'other'] as const
const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
const STATUS_OPTIONS = ['operational', 'damaged', 'attacked'] as const
function num(v: unknown): number {
if (v === null || v === undefined) return 0
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
/** 编辑面板占位页,/edit 路由 */
export function EditDashboard() {
const [data, setData] = useState<EditRawData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['displayStats', 'losses', 'updates']))
const [newUpdate, setNewUpdate] = useState({ category: 'other', summary: '', severity: 'medium' })
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [feedbackList, setFeedbackList] = useState<FeedbackRow[]>([])
const [feedbackLoading, setFeedbackLoading] = useState(false)
const setStats = useStatsStore((s) => s.setStats)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const raw = await fetchEditRaw()
setData(raw)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const toggleSection = (id: string) => {
setOpenSections((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const afterSave = async () => {
await load()
await fetchAndSetSituation()
}
const handleSaveLosses = async (side: 'us' | 'iran', row: CombatLossesRow | null) => {
if (!row) return
setSaving(`losses-${side}`)
try {
await putCombatLosses(side, row)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleSaveKeyLocation = async (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => {
setSaving(`loc-${loc.id}`)
try {
await patchKeyLocation(loc.id, patch)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleAddUpdate = async () => {
if (!newUpdate.summary.trim()) return
setSaving('add-update')
try {
await postSituationUpdate({
timestamp: new Date().toISOString(),
category: newUpdate.category,
summary: newUpdate.summary.trim(),
severity: newUpdate.severity,
})
setNewUpdate({ category: 'other', summary: '', severity: 'medium' })
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '添加失败')
} finally {
setSaving(null)
}
}
const handleDeleteUpdate = async (id: string) => {
if (!confirm('确定删除这条事件?')) return
setSaving(`del-${id}`)
try {
await deleteSituationUpdate(id)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '删除失败')
} finally {
setSaving(null)
}
}
const handleSaveForceSummary = async (side: 'us' | 'iran', row: ForceSummaryRow | null) => {
if (!row) return
setSaving(`summary-${side}`)
try {
await putForceSummary(side, row)
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleSaveDisplayStats = async (row: DisplayStatsRow) => {
setSaving('displayStats')
try {
await putDisplayStats(row)
await load()
const res = await fetch('/api/stats', { cache: 'no-store' })
if (res.ok) {
const stats = await res.json()
setStats(stats)
}
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleSaveAnimationConfig = async (row: AnimationConfigRow) => {
setSaving('animationConfig')
try {
await putAnimationConfig({ strikeCutoffDays: row.strikeCutoffDays })
await afterSave()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleSaveCrawlerConfig = async (row: CrawlerConfigRow) => {
setSaving('crawlerConfig')
try {
await putCrawlerConfig({ rssIntervalSec: row.rssIntervalSec })
await load()
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
const handleOpenFeedback = async () => {
setFeedbackOpen(true)
setFeedbackLoading(true)
setFeedbackList([])
try {
const { list } = await fetchFeedbackList()
setFeedbackList(list ?? [])
} catch {
setFeedbackList([])
} finally {
setFeedbackLoading(false)
}
}
const handleClearDisplayStatsOverrides = async () => {
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数看过=累计访问等)。')) return
setSaving('displayStats')
try {
await putDisplayStats({ clearOverride: true })
await load()
const res = await fetch('/api/stats', { cache: 'no-store' })
if (res.ok) {
const stats = await res.json()
setStats(stats)
}
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(null)
}
}
if (loading && !data) {
return (
<div className="min-h-screen bg-military-dark font-db text-military-text-primary">
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-military-border bg-military-panel/95 px-4 py-3">
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
<RefreshCw className="h-6 w-6 animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-military-dark font-orbitron text-military-text-primary">
<header className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-military-border bg-military-panel/95 px-4 py-3">
<div className="flex items-center gap-4">
<Link
to="/"
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 hover:text-military-text-primary"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="flex items-center gap-2 text-lg">
<Settings className="h-5 w-5 text-cyan-400" />
<span className="flex items-center gap-2 text-lg text-cyan-400">
</span>
</div>
<button
onClick={load}
disabled={loading}
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
<main className="p-4 text-military-text-secondary">
<p></p>
{error && (
<div className="mx-4 mt-4 rounded border border-amber-600/50 bg-amber-950/30 px-4 py-2 text-amber-400">
{error}
<button type="button" className="ml-2 underline" onClick={() => setError(null)}></button>
</div>
)}
<main className="max-w-4xl space-y-2 p-4">
{/* 动画衰减参数 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('animation')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('animation') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('animation') && data && (
<div className="border-t border-military-border p-4 space-y-3 text-sm">
<p className="text-military-text-secondary text-xs">
</p>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault()
const form = e.currentTarget
const fd = new FormData(form)
const v = Number(fd.get('strikeCutoffDays') || 0)
const current: AnimationConfigRow = {
strikeCutoffDays: Number.isFinite(v) && v > 0 ? v : data.animationConfig?.strikeCutoffDays ?? 5,
}
handleSaveAnimationConfig(current)
}}
>
<label className="flex items-center gap-2 text-xs text-military-text-secondary">
<span></span>
<input
type="number"
name="strikeCutoffDays"
min={1}
max={30}
defaultValue={data.animationConfig?.strikeCutoffDays ?? 5}
className="w-20 rounded border border-military-border bg-black/40 px-2 py-1 text-right text-xs text-military-text-primary"
/>
</label>
<button
type="submit"
disabled={saving === 'animationConfig'}
className="inline-flex items-center gap-1 rounded border border-military-border px-3 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<Save className="h-3 w-3" />
</button>
</form>
</div>
)}
</section>
{/* 爬虫配置RSS 抓取间隔,爬虫从 DB 读取便于修订面板调整 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('crawlerConfig')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"></span>
{openSections.has('crawlerConfig') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('crawlerConfig') && data && (
<div className="border-t border-military-border p-4 space-y-3 text-sm">
<p className="text-military-text-secondary text-xs">
RSS 30864001 24
</p>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault()
const v = Number((e.currentTarget.querySelector('input[name="rssIntervalSec"]') as HTMLInputElement)?.value ?? 60)
const sec = Number.isFinite(v) && v >= 30 && v <= 86400 ? v : data.crawlerConfig?.rssIntervalSec ?? 60
handleSaveCrawlerConfig({ rssIntervalSec: sec })
}}
>
<label className="flex items-center gap-2 text-xs text-military-text-secondary">
<span></span>
<input
type="number"
name="rssIntervalSec"
min={30}
max={86400}
defaultValue={data.crawlerConfig?.rssIntervalSec ?? 60}
className="w-24 rounded border border-military-border bg-black/40 px-2 py-1 text-right text-xs text-military-text-primary"
/>
</label>
<button
type="submit"
disabled={saving === 'crawlerConfig'}
className="inline-flex items-center gap-1 rounded border border-military-border px-3 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
<Save className="h-3 w-3" />
</button>
</form>
</div>
)}
</section>
{/* 看过、在看、分享、点赞、留言 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<div className="flex w-full items-center gap-2">
<button
onClick={() => toggleSection('displayStats')}
className="flex flex-1 items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / / / / </span>
{openSections.has('displayStats') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<button
type="button"
onClick={handleOpenFeedback}
className="flex items-center gap-1.5 rounded border border-military-border px-2 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 shrink-0"
>
<MessageSquare className="h-3.5 w-3.5" />
</button>
</div>
{openSections.has('displayStats') && data && (
<div className="border-t border-military-border p-4 space-y-3">
<p className="text-military-text-secondary text-xs">
{data.displayStats?.overrideEnabled
? '当前看板显示为覆盖值。'
: '当前看板显示为实时统计。'}
= 2 访 = 访
</p>
<DisplayStatsForm
row={data.displayStats ?? { overrideEnabled: false, viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
onSave={handleSaveDisplayStats}
onClearOverrides={handleClearDisplayStatsOverrides}
saving={saving === 'displayStats'}
/>
</div>
)}
</section>
{/* 战损 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('losses')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('losses') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('losses') && data && (
<div className="border-t border-military-border p-4 space-y-6">
{(['us', 'iran'] as const).map((side) => {
const row = data.combatLosses[side]
if (!row) return <p key={side} className="text-military-text-secondary text-sm"></p>
return (
<LossForm
key={side}
side={side}
row={row}
onSave={(updated) => handleSaveLosses(side, updated)}
saving={saving === `losses-${side}`}
/>
)
})}
</div>
)}
</section>
{/* 美军据点 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('loc-us')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-military-us"></span>
{openSections.has('loc-us') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('loc-us') && data && (
<div className="border-t border-military-border p-4">
<KeyLocationList
list={data.keyLocations.us}
onSave={handleSaveKeyLocation}
savingId={saving}
/>
</div>
)}
</section>
{/* 伊朗据点 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('loc-iran')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-military-iran"></span>
{openSections.has('loc-iran') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('loc-iran') && data && (
<div className="border-t border-military-border p-4">
<KeyLocationList
list={data.keyLocations.iran}
onSave={handleSaveKeyLocation}
savingId={saving}
/>
</div>
)}
</section>
{/* 事件脉络 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('updates')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"></span>
{openSections.has('updates') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('updates') && data && (
<div className="border-t border-military-border p-4 space-y-4">
<div className="flex flex-wrap gap-2 items-end rounded border border-military-border/50 bg-military-dark/50 p-3">
<select
value={newUpdate.category}
onChange={(e) => setNewUpdate((u) => ({ ...u, category: e.target.value }))}
className="rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
value={newUpdate.severity}
onChange={(e) => setNewUpdate((u) => ({ ...u, severity: e.target.value }))}
className="rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
>
{SEVERITIES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input
type="text"
placeholder="摘要"
value={newUpdate.summary}
onChange={(e) => setNewUpdate((u) => ({ ...u, summary: e.target.value }))}
className="min-w-[200px] flex-1 rounded border border-military-border bg-military-panel px-2 py-1.5 text-sm"
/>
<button
type="button"
onClick={handleAddUpdate}
disabled={saving === 'add-update' || !newUpdate.summary.trim()}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
<ul className="space-y-2 max-h-64 overflow-y-auto">
{data.situationUpdates.map((u) => (
<li
key={u.id}
className="flex items-start gap-2 rounded border border-military-border/50 bg-military-dark/30 px-3 py-2 text-sm"
>
<span className="shrink-0 text-military-text-secondary text-xs">
{u.timestamp.slice(0, 19).replace('T', ' ')}
</span>
<span className="shrink-0 rounded bg-military-border/50 px-1.5 py-0.5 text-xs">{u.category}</span>
<span className="shrink-0 rounded bg-amber-900/40 px-1.5 py-0.5 text-xs">{u.severity}</span>
<span className="min-w-0 flex-1 truncate" title={u.summary}>{u.summary}</span>
<button
type="button"
onClick={() => handleDeleteUpdate(u.id)}
disabled={String(saving).startsWith('del-')}
className="shrink-0 rounded p-1 text-red-400 hover:bg-red-950/50 disabled:opacity-50"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
</div>
)}
</section>
{/* 军力概要 */}
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
<button
onClick={() => toggleSection('summary')}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
>
<span className="font-medium text-cyan-400"> / </span>
{openSections.has('summary') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{openSections.has('summary') && data && (
<div className="border-t border-military-border p-4 space-y-6">
{(['us', 'iran'] as const).map((side) => {
const row = data.forceSummary[side]
if (!row) return <p key={side} className="text-military-text-secondary text-sm"></p>
return (
<ForceSummaryForm
key={side}
side={side}
row={row}
onSave={(updated) => handleSaveForceSummary(side, updated)}
saving={saving === `summary-${side}`}
/>
)
})}
</div>
)}
</section>
</main>
{/* 留言数据弹窗 */}
{feedbackOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={() => setFeedbackOpen(false)}
aria-modal="true"
role="dialog"
>
<div
className="max-h-[85vh] w-full max-w-2xl rounded border border-military-border bg-military-panel shadow-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-military-border px-4 py-3">
<h3 className="font-medium text-cyan-400"></h3>
<button
type="button"
onClick={() => setFeedbackOpen(false)}
className="rounded p-1 text-military-text-secondary hover:bg-military-border/50 hover:text-military-text-primary"
aria-label="关闭"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{feedbackLoading ? (
<p className="text-military-text-secondary text-sm"></p>
) : feedbackList.length === 0 ? (
<p className="text-military-text-secondary text-sm"></p>
) : (
<ul className="space-y-3">
{feedbackList.map((f) => (
<li
key={f.id}
className="rounded border border-military-border/50 bg-military-dark/30 px-3 py-2 text-sm"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-military-text-secondary mb-1">
<span>#{f.id}</span>
<span>{f.created_at?.slice(0, 19).replace('T', ' ') ?? ''}</span>
{f.ip && <span>{f.ip}</span>}
</div>
<p className="text-military-text-primary whitespace-pre-wrap break-words">{f.content}</p>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}
function LossForm({
side,
row,
onSave,
saving,
}: {
side: 'us' | 'iran'
row: CombatLossesRow
onSave: (row: CombatLossesRow) => void
saving: boolean
}) {
const [edit, setEdit] = useState<CombatLossesRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
const sideLabel = side === 'us' ? '美军' : '伊朗'
return (
<div className={`rounded border ${side === 'us' ? 'border-military-us/40' : 'border-military-iran/40'} bg-military-dark/50 p-4`}>
<div className="mb-3 font-medium">{sideLabel}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3">
{LOSS_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key] ?? 0}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="mt-3 flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
</div>
)
}
function KeyLocationList({
list,
onSave,
savingId,
}: {
list: KeyLocationRow[]
onSave: (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => void
savingId: string | null
}) {
if (list.length === 0) return <p className="text-military-text-secondary text-sm"></p>
return (
<ul className="space-y-3">
{list.map((loc) => (
<KeyLocationRowEdit
key={loc.id}
loc={loc}
onSave={onSave}
saving={savingId === `loc-${loc.id}`}
/>
))}
</ul>
)
}
function KeyLocationRowEdit({
loc,
onSave,
saving,
}: {
loc: KeyLocationRow
onSave: (loc: KeyLocationRow, patch: Partial<KeyLocationRow>) => void
saving: boolean
}) {
const [status, setStatus] = useState(loc.status ?? 'operational')
const [damageLevel, setDamageLevel] = useState(num(loc.damage_level))
useEffect(() => {
setStatus(loc.status ?? 'operational')
setDamageLevel(num(loc.damage_level))
}, [loc.id, loc.status, loc.damage_level])
const hasChange = status !== (loc.status ?? 'operational') || damageLevel !== num(loc.damage_level)
return (
<li className="flex flex-wrap items-center gap-2 rounded border border-military-border/50 bg-military-dark/30 px-3 py-2">
<span className="font-medium min-w-0 truncate">{loc.name}</span>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="rounded border border-military-border bg-military-panel px-2 py-1 text-sm"
>
{STATUS_OPTIONS.map((s) => {
return <option key={s} value={s}>{s}</option>
})}
</select>
<label className="flex items-center gap-1 text-sm">
<span className="text-military-text-secondary"></span>
<input
type="number"
min={0}
max={3}
value={damageLevel}
onChange={(e) => setDamageLevel(num(e.target.value))}
className="w-14 rounded border border-military-border bg-military-panel px-2 py-1 text-right"
/>
</label>
<button
type="button"
onClick={() => onSave(loc, { status, damage_level: damageLevel })}
disabled={saving || !hasChange}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-2 py-1 text-xs text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-3 w-3" />
</button>
{saving && <span className="text-cyan-400 text-xs"></span>}
</li>
)
}
function ForceSummaryForm({
side,
row,
onSave,
saving,
}: {
side: 'us' | 'iran'
row: ForceSummaryRow
onSave: (row: ForceSummaryRow) => void
saving: boolean
}) {
const [edit, setEdit] = useState<ForceSummaryRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
const sideLabel = side === 'us' ? '美军' : '伊朗'
return (
<div className={`rounded border ${side === 'us' ? 'border-military-us/40' : 'border-military-iran/40'} bg-military-dark/50 p-4`}>
<div className="mb-3 font-medium">{sideLabel}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
{SUMMARY_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key] ?? 0}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="mt-3 flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
</div>
)
}
const DISPLAY_STATS_FIELDS: { key: keyof DisplayStatsRow; label: string }[] = [
{ key: 'cumulative', label: '看过' },
{ key: 'viewers', label: '在看' },
{ key: 'shareCount', label: '分享' },
{ key: 'likeCount', label: '点赞' },
{ key: 'feedbackCount', label: '留言' },
]
function DisplayStatsForm({
row,
onSave,
onClearOverrides,
saving,
}: {
row: DisplayStatsRow
onSave: (row: DisplayStatsRow) => void
onClearOverrides: () => void
saving: boolean
}) {
const [edit, setEdit] = useState<DisplayStatsRow>({ ...row })
useEffect(() => {
setEdit({ ...row })
}, [row])
return (
<div className="rounded border border-military-border/50 bg-military-dark/50 p-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3 md:grid-cols-5">
{DISPLAY_STATS_FIELDS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm">
<span className="w-14 shrink-0 text-military-text-secondary">{label}</span>
<input
type="number"
min={0}
value={edit[key]}
onChange={(e) => setEdit((r) => ({ ...r, [key]: num(e.target.value) }))}
className="w-20 rounded border border-military-border bg-military-panel px-2 py-1 text-right tabular-nums"
/>
</label>
))}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onSave(edit)}
disabled={saving}
className="flex items-center gap-1 rounded border border-cyan-600 bg-cyan-950/50 px-3 py-1.5 text-sm text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-50"
>
<Save className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClearOverrides}
disabled={saving}
className="rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
>
</button>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ export interface Stats {
cumulative?: number
feedbackCount?: number
shareCount?: number
likeCount?: number
}
interface StatsState {

106
src/utils/defenseLine.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* 战场防御线:反弓曲线路径 + 锯齿齿形
*/
export type LngLat = [number, number]
/** 二次贝塞尔 B(t) = (1-t)²P0 + 2(1-t)t P1 + t² P2 */
function quadraticBezier(P0: LngLat, P1: LngLat, P2: LngLat, t: number): LngLat {
const u = 1 - t
return [
u * u * P0[0] + 2 * u * t * P1[0] + t * t * P2[0],
u * u * P0[1] + 2 * u * t * P1[1] + t * t * P2[1],
]
}
/**
* 生成反弓曲线路径:通过 path 各点,弧面向进攻方向(西侧)凸出
* @param path 关键点 [[lng, lat], ...](如大不里士→萨南达季→克尔曼沙赫)
* @param bulgeWest 向西凸出量(经度,正值表示弧顶在西侧/面向进攻方)
* @param samplesPerSegment 每段贝塞尔采样点数
*/
export function createCurvedDefensePath(
path: LngLat[],
bulgeWest: number = 0.35,
samplesPerSegment: number = 24
): LngLat[] {
if (path.length < 2) return path
const out: LngLat[] = []
for (let i = 0; i < path.length - 1; i++) {
const a = path[i]
const b = path[i + 1]
const midLng = (a[0] + b[0]) / 2
const midLat = (a[1] + b[1]) / 2
const control: LngLat = [midLng - bulgeWest, midLat]
const startK = i === 0 ? 0 : 1
for (let k = startK; k <= samplesPerSegment; k++) {
out.push(quadraticBezier(a, control, b, k / samplesPerSegment))
}
}
return out
}
/**
* 沿路径线性插值增加点(直线段)
*/
export function interpolatePath(path: LngLat[], stepsPerSegment: number = 4): LngLat[] {
if (path.length < 2) return path
const out: LngLat[] = []
for (let i = 0; i < path.length - 1; i++) {
const a = path[i]
const b = path[i + 1]
const startK = i === 0 ? 0 : 1
for (let k = startK; k <= stepsPerSegment; k++) {
const t = k / stepsPerSegment
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
}
}
return out
}
/**
* 锯齿防御线:沿 path 每 toothWidth比例生成齿齿高 toothHeight朝向法向敌方侧
* @param path 路径点 [[lng, lat], ...](可先经 interpolatePath 插值)
* @param toothWidth 齿宽(与段长同单位,经纬度)
* @param toothHeight 齿高(法向伸出)
*/
export function createDefenseLine(
path: number[][],
toothWidth: number,
toothHeight: number
): number[][] {
const coords: number[][] = []
for (let i = 0; i < path.length - 1; i++) {
const start = path[i] as [number, number]
const end = path[i + 1] as [number, number]
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const L = Math.sqrt(dx * dx + dy * dy)
if (L < 1e-9) continue
const ux = dx / L
const uy = dy / L
const nx = -uy
const ny = ux
const numTeeth = Math.max(1, Math.floor(L / toothWidth))
const actualWidth = L / numTeeth
for (let j = 0; j < numTeeth; j++) {
const t1 = j * actualWidth
const t2 = t1 + actualWidth * 0.5
const t3 = (j + 1) * actualWidth
coords.push([start[0] + ux * t1, start[1] + uy * t1])
coords.push([
start[0] + ux * t2 + nx * toothHeight,
start[1] + uy * t2 + ny * toothHeight,
])
coords.push([start[0] + ux * t3, start[1] + uy * t3])
}
}
coords.push(path[path.length - 1] as [number, number])
return coords
}

View File

@@ -0,0 +1,282 @@
/**
* 军事钳形箭头多边形生成
* 支持两种形态:
* 1) 底宽头小 + 曲线轮廓 + 生长动画(单头)
* 2) 集团军级三叉戟Pro Tactical Trident22 点逻辑模型,基座→柄部收腰→三支独立箭指(左/右副箭 + 主箭),腋下回撤无自交
*/
export type LngLat = [number, number]
// ---------------------------------------------------------------------------
// 集团军级三叉戟18 点一笔画):基座 4 → 右颈 → 右副 3 → 右缝槽 → 主箭 3 → 左缝槽 → 左副 3 → 左颈 → 闭合
// 生长时 t>progress 则 offset 归零,前沿齐平防自交
// ---------------------------------------------------------------------------
/**
* 18 点三叉戟:槽位在指缝 (0.4L)左正右负t>progress 时收束到 progress 中心线
*/
function createProTacticalTrident(
start: LngLat,
end: LngLat,
progress: number
): LngLat[] {
const [x1, y1] = start
const dx = end[0] - x1
const dy = end[1] - y1
const L = Math.hypot(dx, dy) || 1e-6
const nx = -dy / L
const ny = dx / L
const pt = (tAlong: number, nOffset: number): LngLat => {
if (tAlong > progress) {
return [x1 + dx * progress, y1 + dy * progress]
}
const lateral = nOffset * L
return [x1 + dx * tAlong + nx * lateral, y1 + dy * tAlong + ny * lateral]
}
const GROOVE = 0.07
return [
pt(0, 0.15),
pt(0.05, 0.16),
pt(0.05, -0.16),
pt(0, -0.15),
pt(0.35, -0.05),
pt(0.45, -0.12),
pt(0.9, -0.18),
pt(0.55, -0.06),
pt(0.4, -GROOVE),
pt(0.65, -0.04),
pt(1, 0),
pt(0.65, 0.04),
pt(0.4, GROOVE),
pt(0.55, 0.06),
pt(0.9, 0.18),
pt(0.45, 0.12),
pt(0.35, 0.05),
pt(0, 0.15),
]
}
export function createArmyGroupPincer(
start: LngLat,
end: LngLat,
_prongCount: number = 3
): LngLat[] {
return createProTacticalTrident(start, end, 1)
}
/**
* 生长动画progress 0→1t>progress 时收束到中心线
*/
export function createArmyGroupPincerAtProgress(
start: LngLat,
end: LngLat,
progress: number,
_prongCount: number = 3
): LngLat[] {
if (progress <= 0.02) {
const [x1, y1] = start
const dx = end[0] - x1
const dy = end[1] - y1
const L = Math.hypot(dx, dy) || 1e-6
const nx = -dy / L
const ny = dx / L
const w = L * 0.15 * 0.5
const left: LngLat = [x1 + nx * w, y1 + ny * w]
const right: LngLat = [x1 - nx * w, y1 - ny * w]
return [left, right, [x1, y1], left]
}
return createProTacticalTrident(start, end, progress)
}
// ---------------------------------------------------------------------------
// 单头钳形(底宽头小、曲线、生长)
// ---------------------------------------------------------------------------
export interface TacticalPincerOptions {
/** 箭尾宽度系数(底宽),建议 0.040.1,默认 0.06 */
widthFactor?: number
/** 头部相对底部的宽度比(<1 即头小),默认 0.45 */
headWidthRatio?: number
/** 箭头头部占进攻方向向量的比例,默认 0.22 */
headLengthRatio?: number
/** 是否在箭体两侧用贝塞尔曲线平滑(底→肩),默认 true */
curved?: boolean
/** 曲线插值点数(每侧),默认 6 */
curveSteps?: number
}
const DEFAULT_OPTS: Required<Omit<TacticalPincerOptions, 'curved'>> & { curved: boolean } = {
widthFactor: 0.055,
headWidthRatio: 0.48,
headLengthRatio: 0.24,
curved: true,
curveSteps: 10,
}
/** 二次贝塞尔 B(t) = (1-t)²P0 + 2(1-t)t P1 + t² P2 */
function quadraticBezier(P0: LngLat, P1: LngLat, P2: LngLat, t: number): LngLat {
const u = 1 - t
return [
u * u * P0[0] + 2 * u * t * P1[0] + t * t * P2[0],
u * u * P0[1] + 2 * u * t * P1[1] + t * t * P2[1],
]
}
/** 在箭体两侧(左尾→左肩、右肩→右尾)插入贝塞尔曲线点,使轮廓柔和 */
function smoothRingWithCurves(
ring: LngLat[],
n: number,
leftTail: LngLat,
leftShoulder: LngLat,
rightTail: LngLat,
rightShoulder: LngLat,
nx: number,
ny: number
): LngLat[] {
const curveBulge = 0.2
// 左缘leftTail -> leftShoulder贝塞尔控制点向外凸出箭体呈弧线
const leftMid: LngLat = [
(leftTail[0] + leftShoulder[0]) / 2 + nx * curveBulge,
(leftTail[1] + leftShoulder[1]) / 2 + ny * curveBulge,
]
const leftCurve: LngLat[] = []
for (let i = 1; i <= n; i++) {
leftCurve.push(quadraticBezier(leftTail, leftMid, leftShoulder, i / (n + 1)))
}
// 右缘rightShoulder -> rightTail控制点向外凸出
const rightMid: LngLat = [
(rightShoulder[0] + rightTail[0]) / 2 - nx * curveBulge,
(rightShoulder[1] + rightTail[1]) / 2 - ny * curveBulge,
]
const rightCurve: LngLat[] = []
for (let i = 1; i <= n; i++) {
rightCurve.push(quadraticBezier(rightShoulder, rightMid, rightTail, i / (n + 1)))
}
// Ring: 0 leftTail, 1 rightTail, 2 shoulderR, 3 barbR, 4 tip, 5 barbL, 6 shoulderL, 7 leftTail
// 顺序:左尾 → 左曲线 → 左肩 → 左倒钩 → 顶点 → 右倒钩 → 右肩 → 右曲线 → 右尾 → 闭合
const out: LngLat[] = [
leftTail,
...leftCurve,
leftShoulder,
ring[5], // barbLeft
ring[4], // tip
ring[3], // barbRight
rightShoulder,
...rightCurve,
rightTail,
leftTail,
]
return out
}
function computeKeyPoints(
start: LngLat,
end: LngLat,
progress: number,
opts: Required<TacticalPincerOptions> & { curved: boolean }
): { ring: LngLat[]; nx: number; ny: number } {
const [x1, y1] = start
const dx = end[0] - x1
const dy = end[1] - y1
const tipX = x1 + progress * dx
const tipY = y1 + progress * dy
const headLen = opts.headLengthRatio
const BbackX = tipX - headLen * dx
const BbackY = tipY - headLen * dy
const nx = -dy * opts.widthFactor
const ny = dx * opts.widthFactor
const headScale = opts.headWidthRatio
const leftTail: LngLat = [x1 + nx, y1 + ny]
const rightTail: LngLat = [x1 - nx, y1 - ny]
const shoulderRight: LngLat = [BbackX - nx * headScale * 0.5, BbackY - ny * headScale * 0.5]
const barbRight: LngLat = [BbackX - nx * headScale * 1.2, BbackY - ny * headScale * 1.2]
const tip: LngLat = [tipX, tipY]
const barbLeft: LngLat = [BbackX + nx * headScale * 1.2, BbackY + ny * headScale * 1.2]
const shoulderLeft: LngLat = [BbackX + nx * headScale * 0.5, BbackY + ny * headScale * 0.5]
const ring: LngLat[] = [
leftTail,
rightTail,
shoulderRight,
barbRight,
tip,
barbLeft,
shoulderLeft,
leftTail,
]
return { ring, nx, ny }
}
/**
* 生成军事钳形箭头(底宽头小、可选曲线)
*/
export function createTacticalPincer(
start: LngLat,
end: LngLat,
widthFactor: number = DEFAULT_OPTS.widthFactor
): LngLat[] {
return createTacticalPincerExplicit(start, end, { widthFactor })
}
export function createTacticalPincerExplicit(
start: LngLat,
end: LngLat,
options: TacticalPincerOptions = {}
): LngLat[] {
const opts = { ...DEFAULT_OPTS, ...options }
const { ring, nx, ny } = computeKeyPoints(start, end, 1, opts as any)
if (!opts.curved) return ring
const steps = opts.curveSteps ?? 6
return smoothRingWithCurves(
ring,
steps,
ring[0],
ring[6],
ring[1],
ring[2],
nx,
ny
)
}
/**
* 生长动画progress 0→1 表示箭头从起点延伸到终点
* progress < headLengthRatio 时画三角 stub否则画完整箭头
*/
export function createTacticalPincerAtProgress(
start: LngLat,
end: LngLat,
progress: number,
options: TacticalPincerOptions = {}
): LngLat[] {
const opts = { ...DEFAULT_OPTS, ...options }
const [x1, y1] = start
const dx = end[0] - x1
const dy = end[1] - y1
const tipX = x1 + progress * dx
const tipY = y1 + progress * dy
const nx = -dy * opts.widthFactor
const ny = dx * opts.widthFactor
if (progress <= 0.02) {
const leftTail: LngLat = [x1 + nx * 0.5, y1 + ny * 0.5]
const rightTail: LngLat = [x1 - nx * 0.5, y1 - ny * 0.5]
return [leftTail, rightTail, [x1, y1], leftTail]
}
if (progress < opts.headLengthRatio) {
const leftTail: LngLat = [x1 + nx, y1 + ny]
const rightTail: LngLat = [x1 - nx, y1 - ny]
const tip: LngLat = [tipX, tipY]
return [leftTail, rightTail, tip, leftTail]
}
const { ring, nx: nxx, ny: nyy } = computeKeyPoints(start, end, progress, opts as any)
if (!opts.curved) return ring
const steps = opts.curveSteps ?? 6
return smoothRingWithCurves(ring, steps, ring[0], ring[6], ring[1], ring[2], nxx, nyy)
}

View File

@@ -1,13 +1,16 @@
#!/usr/bin/env bash
# 一键启动 US-Iran 态势面板API + 前端 + 爬虫服务
set -e
# 使用 UTF-8便于终端输入/显示中文
export LANG="${LANG:-en_US.UTF-8}"
export LC_ALL="${LC_ALL:-en_US.UTF-8}"
cd "$(dirname "$0")"
# 若存在 .env 则加载(可在此设置 DASHSCOPE_API_KEY=sk-xxx勿提交 .env
[ -f .env ] && set -a && . ./.env && set +a
# AI 模式:有 DASHSCOPE_API_KEY 时用通义(商业模型,无需 Ollama否则用 Ollama 或规则
export CLEANER_AI_DISABLED=0
# 面板实时数据1=仅用规则提取战损/据点(无需 Ollama0=用 Ollama/通义。有 DASHSCOPE_API_KEY 时自动用通义
export CLEANER_AI_DISABLED=1
export PARSER_AI_DISABLED=0
# GDELT 国内常超时,仅用 RSS 更新(如需 GDELT 可改为 0
export GDELT_DISABLED=1
@@ -27,6 +30,7 @@ if [ ! -f "$DB_FILE_PATH" ]; then
npm run api:seed
else
echo " - Existing DB detected at $DB_FILE_PATH, skip seeding"
echo " (若刚更新了 seed 想刷新地图/战损等,请先执行: npm run api:seed)"
fi
echo "==> Starting API (http://localhost:3001)..."

View File

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